Compare commits
3 Commits
26b395272d
...
56fa8126f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 56fa8126f3 | |||
| 7ced2172af | |||
| 9607ff3478 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -40,6 +40,12 @@ Thumbs.db
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# SQLite data
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Docker (optional local overrides)
|
||||
docker-compose.override.yml
|
||||
docker-compose.override.yaml
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# Performance Improvements Plan
|
||||
|
||||
## 1. Current State
|
||||
|
||||
The core rendering and state management architecture is already well-structured for performance:
|
||||
|
||||
- **Command/reducer store** — all mutations go through a pure reducer; individual selectors can prevent unnecessary re-renders.
|
||||
- **Delta-based undo/redo** — only diffs are stored, not full graph snapshots (max 100 entries).
|
||||
- **Plugin node registry** — node types are loaded once at app init, not dynamically on each render.
|
||||
- **Streaming AI responses** — `POST /api/agent/stream` uses SSE so the UI updates incrementally.
|
||||
|
||||
The sections below identify remaining bottlenecks and concrete next steps.
|
||||
|
||||
---
|
||||
|
||||
## 2. Known Bottlenecks
|
||||
|
||||
| # | Issue | Location | Impact | Root Cause |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | Unguarded re-renders on canvas | `CanvasPage.tsx` | Frame drops on large graphs | Components not subscribed to granular store slices |
|
||||
| 2 | Nunjucks template resolution on every render | `rendering.ts` resolve step | Slow Config node updates | No memoization of template output keyed to input hash |
|
||||
| 3 | Kroki SVG requests not deduplicated | `rendering.ts` render step | Redundant network calls | No in-flight request deduplication or client-side cache |
|
||||
| 4 | Sidebar tree renders all items | `KosmosPage` recollection tree | Scrolling lag with many workspaces | No list virtualization |
|
||||
| 5 | Backend cache not wired to agent routes | `agentRoutes.ts` | Repeated identical LLM calls | `InMemoryCache` and `rateLimiter` exist but are unused |
|
||||
| 6 | Full graph serialized to localStorage on every change | `useGraphStateWithHistory` | Storage I/O on every keypress | No debounce on the persistence write |
|
||||
| 7 | Initial bundle size | Vite build | Slow first load | Heavy deps (BlockNote, React Flow, Nunjucks) loaded eagerly |
|
||||
|
||||
---
|
||||
|
||||
## 3. Recommended Improvements
|
||||
|
||||
### 3.1 Granular Store Subscriptions
|
||||
|
||||
Zustand supports slice-level subscriptions. Node components should select only their own data slice:
|
||||
|
||||
```ts
|
||||
// Instead of subscribing to the entire graph:
|
||||
const node = useCanvasStore(s => s.graph.nodes.find(n => n.id === id))
|
||||
```
|
||||
|
||||
This prevents all nodes from re-rendering when a single node changes.
|
||||
|
||||
### 3.2 Memoize Template Resolution
|
||||
|
||||
Cache the Nunjucks resolution output keyed to a hash of the template source plus variable inputs. Invalidate only when those inputs change:
|
||||
|
||||
```ts
|
||||
const resolved = useMemo(
|
||||
() => resolveTemplate(template, variables),
|
||||
[templateHash, variableHash]
|
||||
)
|
||||
```
|
||||
|
||||
### 3.3 Deduplicate Kroki Requests
|
||||
|
||||
Add a simple in-flight map in the render step: if a request for the same PlantUML source is already pending, reuse its promise. Cache successful responses keyed to the source string with a short TTL (e.g. 5 minutes).
|
||||
|
||||
### 3.4 Wire Backend Cache and Rate Limiter
|
||||
|
||||
`InMemoryCache` and `rateLimiter` middleware are implemented in `backend/src/`. Connect them to `agentRoutes.ts`:
|
||||
|
||||
1. Add cache lookup before calling the AI service.
|
||||
2. Store the response on cache miss.
|
||||
3. Apply rate limiting per IP to prevent abuse.
|
||||
|
||||
### 3.5 Debounce localStorage Writes
|
||||
|
||||
Wrap the graph persistence call in a debounce (e.g., 300 ms) to avoid a write on every keystroke or node drag. The delta-based history already computes minimal diffs; the bottleneck is the frequency of writes.
|
||||
|
||||
### 3.6 Virtualize the Sidebar Tree
|
||||
|
||||
Integrate `react-arborist` (already installed) with virtualization enabled for the recollection sidebar when item count exceeds a threshold (~50).
|
||||
|
||||
### 3.7 Code Split Heavy Routes
|
||||
|
||||
Add lazy imports for the three heavy route components so the initial bundle only loads what the user navigates to:
|
||||
|
||||
```ts
|
||||
const FluxRoute = lazy(() => import('./app/recollections/flux/FluxRoute'))
|
||||
const LogosPage = lazy(() => import('./app/recollections/logos/LogosPage'))
|
||||
const KatalogosPage = lazy(() => import('./app/recollections/katalogos/KatalogosPage'))
|
||||
```
|
||||
|
||||
### 3.8 Enable Brotli Compression in Nginx
|
||||
|
||||
Add brotli/gzip compression to `frontend/nginx.conf` for JS, CSS, and SVG assets. This can cut transfer size by 60–70% for the JS bundle.
|
||||
|
||||
---
|
||||
|
||||
## 4. Success Metrics
|
||||
|
||||
| Metric | Current (estimated) | Target |
|
||||
| --- | --- | --- |
|
||||
| Frame time on 50-node canvas | ~16 ms | < 10 ms |
|
||||
| Initial JS bundle (gzipped) | ~800 KB | < 600 KB |
|
||||
| Repeated identical LLM calls | uncached | 0 network round-trips |
|
||||
| localStorage write frequency | every change | debounced 300 ms |
|
||||
|
||||
---
|
||||
|
||||
## 5. Contribution Path
|
||||
|
||||
1. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the module you're optimizing.
|
||||
2. Pick one item from section 2.
|
||||
3. Add a Vitest benchmark (`performance.now()` before/after) alongside your change.
|
||||
4. Submit a PR with the benchmark results in the description and update this file's "Current" column.
|
||||
|
||||
*This plan is a living document; update the metrics table when improvements land.*
|
||||
438
backend/package-lock.json
generated
438
backend/package-lock.json
generated
@@ -10,10 +10,12 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.0.0",
|
||||
"ai": "^4.0.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
@@ -561,6 +563,16 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -720,6 +732,60 @@
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.8.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
|
||||
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -744,6 +810,30 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -794,6 +884,12 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -856,6 +952,30 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -884,6 +1004,15 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-match-patch": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
|
||||
@@ -919,6 +1048,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -1006,6 +1144,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
@@ -1052,6 +1199,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -1088,6 +1241,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1162,6 +1321,12 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -1230,12 +1395,38 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -1337,6 +1528,33 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -1361,6 +1579,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
@@ -1370,6 +1594,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.89.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
|
||||
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -1403,6 +1639,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1418,6 +1663,33 @@
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -1431,6 +1703,16 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
@@ -1470,6 +1752,21 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
@@ -1480,6 +1777,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@@ -1522,6 +1833,18 @@
|
||||
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
@@ -1645,6 +1968,51 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -1654,6 +2022,24 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/swr": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
|
||||
@@ -1667,6 +2053,34 @@
|
||||
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/throttleit": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
|
||||
@@ -1708,6 +2122,18 @@
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -1760,6 +2186,12 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
@@ -1778,6 +2210,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"ai": "^4.0.0",
|
||||
"@ai-sdk/openai": "^1.0.0",
|
||||
"ai": "^4.0.0",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
79
backend/src/db/connection.ts
Normal file
79
backend/src/db/connection.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* SQLite database connection singleton.
|
||||
* Uses better-sqlite3 for synchronous, fast access.
|
||||
* DB_PATH env var controls location; defaults to ./data/zui.db
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.resolve('data', 'zui.db')
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (db) return db
|
||||
|
||||
// Ensure parent directory exists
|
||||
const dir = path.dirname(DB_PATH)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
|
||||
initSchema(db)
|
||||
migrateSchema(db)
|
||||
return db
|
||||
}
|
||||
|
||||
function initSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
recollectionId TEXT NOT NULL,
|
||||
sceneId TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
graphSnapshot TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_recollection
|
||||
ON runs(recollectionId, createdAt DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS run_steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
runId TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
||||
nodeId TEXT NOT NULL,
|
||||
nodeType TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
input TEXT,
|
||||
output TEXT,
|
||||
error TEXT,
|
||||
startedAt TEXT,
|
||||
endedAt TEXT,
|
||||
sortOrder INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_run_steps_run
|
||||
ON run_steps(runId, sortOrder);
|
||||
`)
|
||||
}
|
||||
|
||||
function migrateSchema(db: Database.Database): void {
|
||||
// Add sceneId column if it doesn't exist (added in Phase 2)
|
||||
const cols = db.prepare("PRAGMA table_info(runs)").all() as Array<{ name: string }>
|
||||
if (!cols.some((c) => c.name === 'sceneId')) {
|
||||
db.exec('ALTER TABLE runs ADD COLUMN sceneId TEXT')
|
||||
}
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
if (db) {
|
||||
db.close()
|
||||
db = null
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,8 @@ app.use(express.json())
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { handleAgentRequest, handleAgentStreamRequest } from './routes/agentRoutes.js'
|
||||
import { handleCreateRun, handleRunStream, handleGetRun, handleListRuns } from './routes/runRoutes.js'
|
||||
import { getDb } from './db/connection.js'
|
||||
|
||||
/** POST /api/agent - Run AI agent */
|
||||
app.post('/api/agent', handleAgentRequest)
|
||||
@@ -68,6 +70,22 @@ app.post('/api/agent', handleAgentRequest)
|
||||
/** POST /api/agent/stream - Stream AI agent response */
|
||||
app.post('/api/agent/stream', handleAgentStreamRequest)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** POST /api/runs - Create and queue a graph execution run */
|
||||
app.post('/api/runs', handleCreateRun)
|
||||
|
||||
/** GET /api/runs/:id/stream - SSE stream of run execution events */
|
||||
app.get('/api/runs/:id/stream', handleRunStream)
|
||||
|
||||
/** GET /api/runs/:id - Get run details with steps */
|
||||
app.get('/api/runs/:id', handleGetRun)
|
||||
|
||||
/** GET /api/recollections/:recollectionId/runs - List runs for a recollection */
|
||||
app.get('/api/recollections/:recollectionId/runs', handleListRuns)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health Check Endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,6 +109,9 @@ app.use((err: Error, req: express.Request, res: express.Response, next: express.
|
||||
// Start Server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Initialize database on startup
|
||||
getDb()
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
|
||||
})
|
||||
|
||||
68
backend/src/models/run.ts
Normal file
68
backend/src/models/run.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Run and RunStep domain types for graph execution.
|
||||
*/
|
||||
|
||||
export type RunStatus = 'pending' | 'running' | 'completed' | 'failed'
|
||||
|
||||
export type Run = {
|
||||
id: string
|
||||
recollectionId: string
|
||||
sceneId?: string | null
|
||||
status: RunStatus
|
||||
graphSnapshot: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export type RunStep = {
|
||||
id: string
|
||||
runId: string
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
status: RunStatus
|
||||
input?: string | null
|
||||
output?: string | null
|
||||
error?: string | null
|
||||
startedAt?: string | null
|
||||
endedAt?: string | null
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
/** SSE event types for run streaming */
|
||||
export type RunEventType =
|
||||
| 'connected'
|
||||
| 'run/started'
|
||||
| 'run/completed'
|
||||
| 'run/failed'
|
||||
| 'step/started'
|
||||
| 'step/completed'
|
||||
| 'step/failed'
|
||||
| 'step/chunk'
|
||||
| 'ping'
|
||||
|
||||
export type RunEvent = {
|
||||
type: RunEventType
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Graph snapshot types (subset of frontend StoredGraphState) */
|
||||
export type GraphNode = {
|
||||
id: string
|
||||
type?: string
|
||||
data?: Record<string, unknown>
|
||||
position?: { x: number; y: number }
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type GraphEdge = {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type GraphSnapshot = {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
}
|
||||
62
backend/src/repositories/runRepository.ts
Normal file
62
backend/src/repositories/runRepository.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Data access layer for runs and run_steps.
|
||||
*/
|
||||
|
||||
import { getDb } from '../db/connection.js'
|
||||
import type { Run, RunStep, RunStatus } from '../models/run.js'
|
||||
|
||||
export function createRun(run: Run): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
INSERT INTO runs (id, recollectionId, sceneId, status, graphSnapshot, createdAt, updatedAt, error)
|
||||
VALUES (@id, @recollectionId, @sceneId, @status, @graphSnapshot, @createdAt, @updatedAt, @error)
|
||||
`).run(run)
|
||||
}
|
||||
|
||||
export function getRun(id: string): Run | undefined {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM runs WHERE id = ?').get(id) as Run | undefined
|
||||
}
|
||||
|
||||
export function updateRunStatus(id: string, status: RunStatus, error?: string): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
UPDATE runs SET status = ?, error = ?, updatedAt = datetime('now') WHERE id = ?
|
||||
`).run(status, error ?? null, id)
|
||||
}
|
||||
|
||||
export function getRunsByRecollection(recollectionId: string, limit = 50, offset = 0): Run[] {
|
||||
const db = getDb()
|
||||
return db.prepare(`
|
||||
SELECT * FROM runs WHERE recollectionId = ? ORDER BY createdAt DESC LIMIT ? OFFSET ?
|
||||
`).all(recollectionId, limit, offset) as Run[]
|
||||
}
|
||||
|
||||
export function createRunStep(step: RunStep): void {
|
||||
const db = getDb()
|
||||
db.prepare(`
|
||||
INSERT INTO run_steps (id, runId, nodeId, nodeType, status, input, output, error, startedAt, endedAt, sortOrder)
|
||||
VALUES (@id, @runId, @nodeId, @nodeType, @status, @input, @output, @error, @startedAt, @endedAt, @sortOrder)
|
||||
`).run(step)
|
||||
}
|
||||
|
||||
export function updateRunStep(id: string, updates: Partial<Pick<RunStep, 'status' | 'output' | 'error' | 'startedAt' | 'endedAt'>>): void {
|
||||
const db = getDb()
|
||||
const fields: string[] = []
|
||||
const values: unknown[] = []
|
||||
|
||||
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status) }
|
||||
if (updates.output !== undefined) { fields.push('output = ?'); values.push(updates.output) }
|
||||
if (updates.error !== undefined) { fields.push('error = ?'); values.push(updates.error) }
|
||||
if (updates.startedAt !== undefined) { fields.push('startedAt = ?'); values.push(updates.startedAt) }
|
||||
if (updates.endedAt !== undefined) { fields.push('endedAt = ?'); values.push(updates.endedAt) }
|
||||
|
||||
if (fields.length === 0) return
|
||||
values.push(id)
|
||||
db.prepare(`UPDATE run_steps SET ${fields.join(', ')} WHERE id = ?`).run(...values)
|
||||
}
|
||||
|
||||
export function getRunSteps(runId: string): RunStep[] {
|
||||
const db = getDb()
|
||||
return db.prepare('SELECT * FROM run_steps WHERE runId = ? ORDER BY sortOrder').all(runId) as RunStep[]
|
||||
}
|
||||
128
backend/src/routes/runRoutes.ts
Normal file
128
backend/src/routes/runRoutes.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Run routes: create runs, stream execution, list history.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import type { Request, Response } from 'express'
|
||||
import type { Run, RunEvent } from '../models/run.js'
|
||||
import * as runRepo from '../repositories/runRepository.js'
|
||||
import { executeRun } from '../services/graphRunnerService.js'
|
||||
|
||||
/**
|
||||
* POST /api/runs
|
||||
* Body: { recollectionId, graph: { nodes, edges } }
|
||||
* Creates a new run and starts execution, returning the run ID immediately.
|
||||
* Client should connect to GET /api/runs/:id/stream for live updates.
|
||||
*/
|
||||
export async function handleCreateRun(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { recollectionId, sceneId, graph } = req.body
|
||||
|
||||
if (!recollectionId || typeof recollectionId !== 'string') {
|
||||
res.status(400).json({ error: 'recollectionId is required' })
|
||||
return
|
||||
}
|
||||
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) {
|
||||
res.status(400).json({ error: 'graph with nodes and edges is required' })
|
||||
return
|
||||
}
|
||||
|
||||
const run: Run = {
|
||||
id: crypto.randomUUID(),
|
||||
recollectionId,
|
||||
sceneId: typeof sceneId === 'string' ? sceneId : null,
|
||||
status: 'pending',
|
||||
graphSnapshot: JSON.stringify(graph),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
error: null,
|
||||
}
|
||||
|
||||
runRepo.createRun(run)
|
||||
res.status(201).json({ id: run.id, status: run.status })
|
||||
} catch (err) {
|
||||
console.error('Create run error:', err)
|
||||
res.status(500).json({ error: (err as Error).message ?? 'Failed to create run' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/runs/:id/stream
|
||||
* SSE endpoint that executes the run and streams events in real time.
|
||||
*/
|
||||
export async function handleRunStream(req: Request, res: Response): Promise<void> {
|
||||
const { id } = req.params
|
||||
|
||||
const run = runRepo.getRun(id)
|
||||
if (!run) {
|
||||
res.status(404).json({ error: 'Run not found' })
|
||||
return
|
||||
}
|
||||
|
||||
if (run.status !== 'pending') {
|
||||
res.status(409).json({ error: `Run already ${run.status}` })
|
||||
return
|
||||
}
|
||||
|
||||
// Set up SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream')
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Connection', 'keep-alive')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
res.flushHeaders()
|
||||
|
||||
const emit = (event: RunEvent) => {
|
||||
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`)
|
||||
}
|
||||
|
||||
emit({ type: 'connected', data: { runId: id } })
|
||||
|
||||
// Keepalive ping
|
||||
const pingInterval = setInterval(() => {
|
||||
emit({ type: 'ping', data: { timestamp: Date.now() } })
|
||||
}, 15_000)
|
||||
|
||||
req.on('close', () => {
|
||||
clearInterval(pingInterval)
|
||||
})
|
||||
|
||||
try {
|
||||
await executeRun(id, emit)
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
emit({ type: 'run/failed', data: { runId: id, error: errorMsg } })
|
||||
runRepo.updateRunStatus(id, 'failed', errorMsg)
|
||||
} finally {
|
||||
clearInterval(pingInterval)
|
||||
res.end()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/runs/:id
|
||||
* Returns run details with steps.
|
||||
*/
|
||||
export async function handleGetRun(req: Request, res: Response): Promise<void> {
|
||||
const { id } = req.params
|
||||
const run = runRepo.getRun(id)
|
||||
if (!run) {
|
||||
res.status(404).json({ error: 'Run not found' })
|
||||
return
|
||||
}
|
||||
|
||||
const steps = runRepo.getRunSteps(id)
|
||||
res.json({ ...run, steps })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/recollections/:recollectionId/runs
|
||||
* Returns run history for a recollection.
|
||||
*/
|
||||
export async function handleListRuns(req: Request, res: Response): Promise<void> {
|
||||
const { recollectionId } = req.params
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200)
|
||||
const offset = Number(req.query.offset) || 0
|
||||
|
||||
const runs = runRepo.getRunsByRecollection(recollectionId, limit, offset)
|
||||
res.json({ runs })
|
||||
}
|
||||
237
backend/src/services/graphRunnerService.ts
Normal file
237
backend/src/services/graphRunnerService.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Graph execution engine.
|
||||
* Topologically sorts graph nodes (Kahn's algorithm) and executes them in order.
|
||||
* Emits SSE events via a callback for real-time streaming to clients.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import type { GraphSnapshot, GraphNode, GraphEdge, RunEvent, RunStep, RunStatus } from '../models/run.js'
|
||||
import * as runRepo from '../repositories/runRepository.js'
|
||||
import { buildAgentRequest } from './agentService.js'
|
||||
|
||||
type EmitFn = (event: RunEvent) => void
|
||||
|
||||
/**
|
||||
* Topological sort using Kahn's algorithm.
|
||||
* Returns ordered node IDs or throws on cycle detection.
|
||||
*/
|
||||
function topologicalSort(nodes: GraphNode[], edges: GraphEdge[]): string[] {
|
||||
const nodeIds = new Set(nodes.map((n) => n.id))
|
||||
const inDegree = new Map<string, number>()
|
||||
const adjacency = new Map<string, string[]>()
|
||||
|
||||
for (const id of nodeIds) {
|
||||
inDegree.set(id, 0)
|
||||
adjacency.set(id, [])
|
||||
}
|
||||
|
||||
for (const edge of edges) {
|
||||
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) continue
|
||||
adjacency.get(edge.source)!.push(edge.target)
|
||||
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const queue: string[] = []
|
||||
for (const [id, deg] of inDegree) {
|
||||
if (deg === 0) queue.push(id)
|
||||
}
|
||||
|
||||
const sorted: string[] = []
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!
|
||||
sorted.push(current)
|
||||
for (const neighbor of adjacency.get(current) ?? []) {
|
||||
const newDeg = (inDegree.get(neighbor) ?? 1) - 1
|
||||
inDegree.set(neighbor, newDeg)
|
||||
if (newDeg === 0) queue.push(neighbor)
|
||||
}
|
||||
}
|
||||
|
||||
if (sorted.length !== nodeIds.size) {
|
||||
throw new Error('Cycle detected in graph')
|
||||
}
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
/** Node types that produce output when executed */
|
||||
const EXECUTABLE_TYPES = new Set(['agent', 'config', 'render'])
|
||||
|
||||
/** Node types that are pure inputs (no execution needed) */
|
||||
const INPUT_TYPES = new Set(['variable', 'data', 'function'])
|
||||
|
||||
/**
|
||||
* Execute a single node based on its type.
|
||||
* Returns the output string or null for input-only nodes.
|
||||
*/
|
||||
async function executeNode(
|
||||
node: GraphNode,
|
||||
_edges: GraphEdge[],
|
||||
_nodes: GraphNode[],
|
||||
_outputs: Map<string, string>,
|
||||
emit: EmitFn
|
||||
): Promise<string | null> {
|
||||
const nodeType = node.type ?? 'unknown'
|
||||
|
||||
if (INPUT_TYPES.has(nodeType)) {
|
||||
// Input nodes: extract their value as output
|
||||
const data = node.data ?? {}
|
||||
if (nodeType === 'variable') {
|
||||
const v = data.value
|
||||
return v === undefined || v === null ? '' : String(v)
|
||||
}
|
||||
if (nodeType === 'data') {
|
||||
const rows = (data.rows as Record<string, string>[]) ?? []
|
||||
return JSON.stringify(rows)
|
||||
}
|
||||
if (nodeType === 'function') {
|
||||
return (data.body as string) ?? ''
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (nodeType === 'agent') {
|
||||
// Agent nodes: call the AI service
|
||||
const configContents: string[] = []
|
||||
for (const edge of _edges) {
|
||||
if (edge.target !== node.id) continue
|
||||
const output = _outputs.get(edge.source)
|
||||
if (output) configContents.push(output)
|
||||
}
|
||||
|
||||
const prompt = configContents.join('\n\n---\n\n') || 'No prompt provided.'
|
||||
const connection = (node.data?.connection as Record<string, unknown>) ?? undefined
|
||||
|
||||
const built = buildAgentRequest({
|
||||
prompt,
|
||||
connection: connection as any,
|
||||
})
|
||||
|
||||
if ('error' in built) {
|
||||
throw new Error(built.error)
|
||||
}
|
||||
|
||||
// Use streaming for agent nodes
|
||||
const { streamText } = await import('ai')
|
||||
const result = streamText({
|
||||
model: built.openai as any,
|
||||
prompt: built.fullPrompt,
|
||||
})
|
||||
|
||||
let fullText = ''
|
||||
for await (const chunk of (await result).textStream) {
|
||||
fullText += chunk
|
||||
emit({
|
||||
type: 'step/chunk',
|
||||
data: { nodeId: node.id, chunk },
|
||||
})
|
||||
}
|
||||
|
||||
return fullText
|
||||
}
|
||||
|
||||
if (nodeType === 'config') {
|
||||
// Config nodes: return their template content as-is during execution
|
||||
// (Nunjucks resolution happens on the frontend; backend treats as passthrough)
|
||||
const content = (node.data?.content as string) ?? ''
|
||||
return content
|
||||
}
|
||||
|
||||
if (nodeType === 'render') {
|
||||
// Render nodes: collect input from connected source nodes
|
||||
const inputs: string[] = []
|
||||
for (const edge of _edges) {
|
||||
if (edge.target !== node.id) continue
|
||||
const output = _outputs.get(edge.source)
|
||||
if (output) inputs.push(output)
|
||||
}
|
||||
return inputs.join('\n\n')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full graph execution for a given run ID.
|
||||
* Emits SSE events for each step.
|
||||
*/
|
||||
export async function executeRun(runId: string, emit: EmitFn): Promise<void> {
|
||||
const run = runRepo.getRun(runId)
|
||||
if (!run) throw new Error(`Run not found: ${runId}`)
|
||||
|
||||
let graph: GraphSnapshot
|
||||
try {
|
||||
graph = JSON.parse(run.graphSnapshot) as GraphSnapshot
|
||||
} catch {
|
||||
throw new Error('Invalid graph snapshot')
|
||||
}
|
||||
|
||||
const { nodes, edges } = graph
|
||||
|
||||
// Sort nodes topologically
|
||||
const sortedIds = topologicalSort(nodes, edges)
|
||||
const nodeMap = new Map(nodes.map((n) => [n.id, n]))
|
||||
const outputs = new Map<string, string>()
|
||||
|
||||
// Create run_steps records
|
||||
const steps: RunStep[] = sortedIds.map((nodeId, i) => {
|
||||
const node = nodeMap.get(nodeId)!
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
runId,
|
||||
nodeId,
|
||||
nodeType: node.type ?? 'unknown',
|
||||
status: 'pending' as RunStatus,
|
||||
input: null,
|
||||
output: null,
|
||||
error: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
sortOrder: i,
|
||||
}
|
||||
})
|
||||
|
||||
for (const step of steps) {
|
||||
runRepo.createRunStep(step)
|
||||
}
|
||||
|
||||
// Mark run as running
|
||||
runRepo.updateRunStatus(runId, 'running')
|
||||
const stepNodeIds = steps.map((s) => s.nodeId)
|
||||
emit({ type: 'run/started', data: { runId, totalSteps: steps.length, nodeIds: stepNodeIds } })
|
||||
|
||||
// Execute each node in order
|
||||
for (const step of steps) {
|
||||
const node = nodeMap.get(step.nodeId)
|
||||
if (!node) continue
|
||||
|
||||
const now = new Date().toISOString()
|
||||
runRepo.updateRunStep(step.id, { status: 'running', startedAt: now })
|
||||
emit({ type: 'step/started', data: { stepId: step.id, nodeId: step.nodeId, nodeType: step.nodeType } })
|
||||
|
||||
try {
|
||||
const output = await executeNode(node, edges, nodes, outputs, emit)
|
||||
if (output !== null) {
|
||||
outputs.set(node.id, output)
|
||||
}
|
||||
|
||||
const endedAt = new Date().toISOString()
|
||||
runRepo.updateRunStep(step.id, { status: 'completed', output: output ?? '', endedAt })
|
||||
emit({ type: 'step/completed', data: { stepId: step.id, nodeId: step.nodeId, output: output?.slice(0, 500) ?? '' } })
|
||||
} catch (err) {
|
||||
const endedAt = new Date().toISOString()
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
runRepo.updateRunStep(step.id, { status: 'failed', error: errorMsg, endedAt })
|
||||
emit({ type: 'step/failed', data: { stepId: step.id, nodeId: step.nodeId, error: errorMsg } })
|
||||
|
||||
// Fail the entire run
|
||||
runRepo.updateRunStatus(runId, 'failed', errorMsg)
|
||||
emit({ type: 'run/failed', data: { runId, error: errorMsg } })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Mark run as completed
|
||||
runRepo.updateRunStatus(runId, 'completed')
|
||||
emit({ type: 'run/completed', data: { runId } })
|
||||
}
|
||||
@@ -68,6 +68,8 @@ import {
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { toast } from 'sonner'
|
||||
import { RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||
import { useRunStream, createAndStreamRun } from '@/hooks/useRunStream'
|
||||
import { useRunStore } from '@/lib/graph/runStore'
|
||||
|
||||
const SNAP_GRID: [number, number] = [15, 15]
|
||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||
@@ -200,11 +202,13 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
|
||||
export type CanvasPageProps = {
|
||||
/** Optional recollection id for per-recollection graph loading */
|
||||
recollectionId?: string
|
||||
/** Optional scene id for per-scene graph loading */
|
||||
sceneId?: string
|
||||
/** Optional node id to focus when the canvas loads (e.g. from Katalogos artifacts). */
|
||||
focusNodeId?: string
|
||||
}
|
||||
|
||||
export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||
export function CanvasPage({ recollectionId, sceneId, focusNodeId }: CanvasPageProps) {
|
||||
const { theme } = useTheme()
|
||||
const { showMinimap } = usePlatform()
|
||||
const {
|
||||
@@ -223,7 +227,7 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||
setStateImmediate,
|
||||
save,
|
||||
saveStatus,
|
||||
} = useCanvasGraph(recollectionId)
|
||||
} = useCanvasGraph(recollectionId, sceneId)
|
||||
|
||||
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
||||
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
||||
@@ -257,6 +261,33 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||
|
||||
const connectionPath = useCanvasConnectionPathFromStore()
|
||||
|
||||
// --- Run execution ---
|
||||
const { connectToRun } = useRunStream()
|
||||
const runStatus = useRunStore((s) => s.status)
|
||||
const resetRun = useRunStore((s) => s.reset)
|
||||
const markDirty = useRunStore((s) => s.markDirty)
|
||||
|
||||
// Mark run state as dirty when graph structure or data changes
|
||||
const prevNodesLenRef = useRef(nodes.length)
|
||||
const prevEdgesLenRef = useRef(edges.length)
|
||||
useEffect(() => {
|
||||
// Skip the initial render
|
||||
if (prevNodesLenRef.current === nodes.length && prevEdgesLenRef.current === edges.length) return
|
||||
prevNodesLenRef.current = nodes.length
|
||||
prevEdgesLenRef.current = edges.length
|
||||
markDirty()
|
||||
}, [nodes.length, edges.length, markDirty])
|
||||
const handleRun = useCallback(() => {
|
||||
if (!recollectionId) return
|
||||
if (runStatus === 'running' || runStatus === 'pending') return
|
||||
// Reset previous run state, save, then run
|
||||
resetRun()
|
||||
save()
|
||||
createAndStreamRun(recollectionId, { nodes, edges }, connectToRun, sceneId).catch((err) => {
|
||||
toast.error(`Run failed: ${err.message}`)
|
||||
})
|
||||
}, [recollectionId, sceneId, nodes, edges, connectToRun, save, runStatus, resetRun])
|
||||
|
||||
const nodesRef = useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
|
||||
@@ -464,6 +495,7 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||
canDuplicate: selectedNodes.length > 0,
|
||||
canCopy: selectedNodes.length === 1,
|
||||
onFitView: () => flowActionsRef.current?.fitView?.(),
|
||||
onRun: recollectionId ? handleRun : undefined,
|
||||
}
|
||||
setFluxSlot(slot)
|
||||
return () => setFluxSlot(null)
|
||||
@@ -481,6 +513,7 @@ export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||
handleCopy,
|
||||
handlePaste,
|
||||
selectedNodes.length,
|
||||
handleRun,
|
||||
])
|
||||
|
||||
const graphContextValue = useMemo(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
||||
import { loadGraphFromStorage } from '@/app/recollections/state/recollectionGraphStorage'
|
||||
import { getSceneGraph } from '@/app/recollections/state/recollectionStore'
|
||||
|
||||
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
|
||||
export function backfillEdgeTargetTypes(
|
||||
@@ -70,9 +71,12 @@ export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
export function getInitialGraph(recollectionId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
export function getInitialGraph(recollectionId: string | undefined, sceneId?: string): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
if (recollectionId) {
|
||||
const stored = loadGraphFromStorage(recollectionId)
|
||||
// Scene-aware: load from scene key; only fall back to legacy when no sceneId
|
||||
const stored = sceneId
|
||||
? getSceneGraph(recollectionId, sceneId)
|
||||
: loadGraphFromStorage(recollectionId)
|
||||
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
||||
const nodes = stored.nodes as AppNode[]
|
||||
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||
import { setSceneGraph } from '@/app/recollections/state/recollectionStore'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
||||
@@ -18,8 +19,8 @@ export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> &
|
||||
saveStatus: SaveStatus
|
||||
}
|
||||
|
||||
export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGraphResult {
|
||||
const initialGraph = useMemo(() => getInitialGraph(recollectionId), [recollectionId])
|
||||
export function useCanvasGraph(recollectionId: string | undefined, sceneId?: string): UseCanvasGraphResult {
|
||||
const initialGraph = useMemo(() => getInitialGraph(recollectionId, sceneId), [recollectionId, sceneId])
|
||||
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||
const { nodes, edges } = result
|
||||
|
||||
@@ -47,17 +48,23 @@ export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGra
|
||||
edges: edgesRef.current,
|
||||
})
|
||||
setIsSaving(true)
|
||||
saveGraphToStorage(recollectionId, {
|
||||
const graphState = {
|
||||
version: RECOLLECTION_VERSION,
|
||||
nodes: nodesRef.current,
|
||||
edges: edgesRef.current,
|
||||
})
|
||||
}
|
||||
// Save to scene-specific key if sceneId is provided, otherwise legacy key
|
||||
if (sceneId) {
|
||||
setSceneGraph(recollectionId, sceneId, graphState)
|
||||
} else {
|
||||
saveGraphToStorage(recollectionId, graphState)
|
||||
}
|
||||
const SAVING_DISPLAY_MS = 360
|
||||
setTimeout(() => {
|
||||
setLastSavedSerialized(snapshot)
|
||||
setIsSaving(false)
|
||||
}, SAVING_DISPLAY_MS)
|
||||
}, [recollectionId])
|
||||
}, [recollectionId, sceneId])
|
||||
|
||||
// Auto-save after 5 seconds of inactivity when there are unsaved changes.
|
||||
// Prevents data loss if the user closes the tab without pressing Ctrl+S.
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Outlet, useParams, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
|
||||
import { RecollectionSidebarProvider } from './layout/RecollectionSidebarContext'
|
||||
import { WorkspaceTreeProvider } from './layout/WorkspaceTreeContext'
|
||||
import { RecollectionMenubar } from './layout/RecollectionMenubar'
|
||||
import { RecollectionSidebar } from './layout/RecollectionSidebar'
|
||||
|
||||
@@ -49,7 +49,7 @@ export function RecollectionLayout() {
|
||||
|
||||
return (
|
||||
<RecollectionActionsProvider>
|
||||
<RecollectionSidebarProvider>
|
||||
<WorkspaceTreeProvider>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RecollectionMenubar />
|
||||
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
|
||||
@@ -59,7 +59,7 @@ export function RecollectionLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RecollectionSidebarProvider>
|
||||
</WorkspaceTreeProvider>
|
||||
</RecollectionActionsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Flux route: renders the graph canvas (CanvasPage) for the current recollection.
|
||||
* Flux route: renders the graph canvas (CanvasPage) for the current recollection + scene.
|
||||
* Supports optional focusNode query param to center on a specific node.
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, { useEffect, useRef } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { CanvasPage } from '@/app/canvas/CanvasPage'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { useFluxScenes } from '../layout/WorkspaceTreeContext'
|
||||
|
||||
export function FluxRoute() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
@@ -14,6 +15,7 @@ export function FluxRoute() {
|
||||
const { updateLastEdited } = usePlatform()
|
||||
const updateLastEditedRef = useRef(updateLastEdited)
|
||||
updateLastEditedRef.current = updateLastEdited
|
||||
const { activeSceneId } = useFluxScenes()
|
||||
|
||||
useEffect(() => {
|
||||
if (recollectionId) updateLastEditedRef.current(recollectionId)
|
||||
@@ -25,7 +27,12 @@ export function FluxRoute() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<CanvasPage key={recollectionId} recollectionId={recollectionId} focusNodeId={focusNodeId} />
|
||||
<CanvasPage
|
||||
key={`${recollectionId}_${activeSceneId}`}
|
||||
recollectionId={recollectionId}
|
||||
sceneId={activeSceneId}
|
||||
focusNodeId={focusNodeId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
146
frontend/src/app/recollections/flux/FluxSceneContext.tsx
Normal file
146
frontend/src/app/recollections/flux/FluxSceneContext.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Context for Flux scenes: scene tree, active scene, URL sync.
|
||||
* Mirrors RecollectionSidebarContext for Logos pages.
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
ensureFluxSceneTree,
|
||||
getFluxSceneTree,
|
||||
setFluxSceneTree,
|
||||
removeSceneGraph,
|
||||
DEFAULT_SCENE_ID,
|
||||
type FluxSceneMeta,
|
||||
type FluxSceneId,
|
||||
} from '../state/recollectionStore'
|
||||
|
||||
export type FluxSceneContextValue = {
|
||||
scenes: FluxSceneMeta[]
|
||||
activeSceneId: FluxSceneId
|
||||
setScenes: React.Dispatch<React.SetStateAction<FluxSceneMeta[]>>
|
||||
handleSelectScene: (id: FluxSceneId) => void
|
||||
handleAddScene: (title?: string) => FluxSceneId
|
||||
handleRenameScene: (id: FluxSceneId, title: string) => void
|
||||
handleDeleteScene: (id: FluxSceneId) => void
|
||||
handleReorderScenes: (scenes: FluxSceneMeta[]) => void
|
||||
}
|
||||
|
||||
const FluxSceneContext = createContext<FluxSceneContextValue | null>(null)
|
||||
|
||||
export function useFluxScenes(): FluxSceneContextValue {
|
||||
const ctx = useContext(FluxSceneContext)
|
||||
if (!ctx) throw new Error('useFluxScenes must be used within FluxSceneProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useOptionalFluxScenes(): FluxSceneContextValue | null {
|
||||
return useContext(FluxSceneContext)
|
||||
}
|
||||
|
||||
let sceneCounter = 0
|
||||
|
||||
export function FluxSceneProvider({ children }: { children: React.ReactNode }) {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const [scenes, setScenes] = useState<FluxSceneMeta[]>([])
|
||||
const [activeSceneId, setActiveSceneId] = useState<FluxSceneId>(DEFAULT_SCENE_ID)
|
||||
|
||||
// Load scene tree when recollection changes
|
||||
useEffect(() => {
|
||||
if (!recollectionId) return
|
||||
const tree = ensureFluxSceneTree(recollectionId)
|
||||
setScenes(tree)
|
||||
}, [recollectionId])
|
||||
|
||||
// Sync active scene from URL
|
||||
useEffect(() => {
|
||||
if (!recollectionId || scenes.length === 0) return
|
||||
const sceneFromUrl = searchParams.get('scene')
|
||||
setActiveSceneId((prev) => {
|
||||
if (sceneFromUrl && scenes.some((s) => s.id === sceneFromUrl)) return sceneFromUrl
|
||||
if (scenes.some((s) => s.id === prev)) return prev
|
||||
return scenes[0].id
|
||||
})
|
||||
}, [recollectionId, searchParams, scenes])
|
||||
|
||||
// Persist scene tree on changes
|
||||
useEffect(() => {
|
||||
if (!recollectionId || scenes.length === 0) return
|
||||
setFluxSceneTree(recollectionId, scenes)
|
||||
}, [recollectionId, scenes])
|
||||
|
||||
const handleSelectScene = useCallback(
|
||||
(id: FluxSceneId) => {
|
||||
setActiveSceneId(id)
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('scene', id)
|
||||
return next
|
||||
}, { replace: true })
|
||||
},
|
||||
[setSearchParams]
|
||||
)
|
||||
|
||||
const handleAddScene = useCallback(
|
||||
(title?: string) => {
|
||||
sceneCounter += 1
|
||||
const id = `scene_${Date.now()}_${sceneCounter}`
|
||||
const maxPos = scenes.reduce((max, s) => Math.max(max, s.position), -1)
|
||||
const newScene: FluxSceneMeta = {
|
||||
id,
|
||||
title: title ?? `Scene ${scenes.length + 1}`,
|
||||
position: maxPos + 1,
|
||||
}
|
||||
setScenes((prev) => [...prev, newScene])
|
||||
handleSelectScene(id)
|
||||
return id
|
||||
},
|
||||
[scenes, handleSelectScene]
|
||||
)
|
||||
|
||||
const handleRenameScene = useCallback(
|
||||
(id: FluxSceneId, title: string) => {
|
||||
setScenes((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s)))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDeleteScene = useCallback(
|
||||
(id: FluxSceneId) => {
|
||||
if (!recollectionId) return
|
||||
// Prevent deleting the last scene
|
||||
if (scenes.length <= 1) return
|
||||
removeSceneGraph(recollectionId, id)
|
||||
setScenes((prev) => {
|
||||
const next = prev.filter((s) => s.id !== id)
|
||||
// If we deleted the active scene, switch to the first remaining
|
||||
if (activeSceneId === id && next.length > 0) {
|
||||
handleSelectScene(next[0].id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[recollectionId, scenes.length, activeSceneId, handleSelectScene]
|
||||
)
|
||||
|
||||
const handleReorderScenes = useCallback(
|
||||
(newScenes: FluxSceneMeta[]) => {
|
||||
setScenes(newScenes)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const value: FluxSceneContextValue = {
|
||||
scenes,
|
||||
activeSceneId,
|
||||
setScenes,
|
||||
handleSelectScene,
|
||||
handleAddScene,
|
||||
handleRenameScene,
|
||||
handleDeleteScene,
|
||||
handleReorderScenes,
|
||||
}
|
||||
|
||||
return <FluxSceneContext.Provider value={value}>{children}</FluxSceneContext.Provider>
|
||||
}
|
||||
@@ -3,17 +3,19 @@
|
||||
* Shows live \"Artifacts\" from Flux rendering nodes in a card grid.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { getRenderOutputCache, formatTimeSinceLastUpdate } from '../state/recollectionStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RunsTab } from './RunsTab'
|
||||
|
||||
export function KatalogosPage() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { recollections } = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'artifacts' | 'runs'>('artifacts')
|
||||
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
|
||||
const title = recollection?.name ?? 'Untitled'
|
||||
|
||||
@@ -33,10 +35,25 @@ export function KatalogosPage() {
|
||||
<h1 className="mb-2 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Katalogos · Live artifacts produced by Flux rendering nodes for this recollection.
|
||||
</p>
|
||||
{artifacts.length === 0 ? (
|
||||
<div className="mb-4 flex gap-1 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('artifacts')}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'artifacts' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Artifacts
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('runs')}
|
||||
className={`px-3 py-1.5 text-sm font-medium transition-colors ${activeTab === 'runs' ? 'border-b-2 border-primary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Runs
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === 'runs' ? (
|
||||
<RunsTab />
|
||||
) : artifacts.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
|
||||
No artifacts yet. In Flux, run a graph with a rendering node; its output will be cached as an artifact and
|
||||
appear here, as well as in Logos blocks that insert artifacts.
|
||||
|
||||
139
frontend/src/app/recollections/katalogos/RunsTab.tsx
Normal file
139
frontend/src/app/recollections/katalogos/RunsTab.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Runs tab for Katalogos: shows execution history for the current recollection.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle2, XCircle, Clock, Loader2 } from 'lucide-react'
|
||||
|
||||
type RunSummary = {
|
||||
id: string
|
||||
sceneId?: string | null
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
type RunDetail = RunSummary & {
|
||||
steps: Array<{
|
||||
id: string
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
status: string
|
||||
error?: string | null
|
||||
startedAt?: string | null
|
||||
endedAt?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
const statusIcon: Record<string, React.ReactNode> = {
|
||||
pending: <Clock className="size-3.5 text-muted-foreground" />,
|
||||
running: <Loader2 className="size-3.5 animate-spin text-blue-500" />,
|
||||
completed: <CheckCircle2 className="size-3.5 text-emerald-500" />,
|
||||
failed: <XCircle className="size-3.5 text-destructive" />,
|
||||
}
|
||||
|
||||
export function RunsTab() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const [runs, setRuns] = useState<RunSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedRunId, setExpandedRunId] = useState<string | null>(null)
|
||||
const [runDetail, setRunDetail] = useState<RunDetail | null>(null)
|
||||
|
||||
const fetchRuns = useCallback(async () => {
|
||||
if (!recollectionId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/recollections/${recollectionId}/runs`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRuns(data.runs)
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [recollectionId])
|
||||
|
||||
useEffect(() => { fetchRuns() }, [fetchRuns])
|
||||
|
||||
const toggleExpand = async (runId: string) => {
|
||||
if (expandedRunId === runId) {
|
||||
setExpandedRunId(null)
|
||||
setRunDetail(null)
|
||||
return
|
||||
}
|
||||
setExpandedRunId(runId)
|
||||
try {
|
||||
const res = await fetch(`/api/runs/${runId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setRunDetail(data)
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Loading runs…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 bg-muted/10 p-4 text-sm text-muted-foreground">
|
||||
No runs yet. Use the Run button in Flux to execute your graph.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{runs.map((run) => (
|
||||
<div key={run.id} className="rounded-lg border border-border bg-card text-card-foreground shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(run.id)}
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left text-sm hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcon[run.status] ?? statusIcon.pending}
|
||||
<span className="font-medium capitalize">{run.status}</span>
|
||||
{run.sceneId && (
|
||||
<span className="text-xs text-muted-foreground">({run.sceneId})</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(run.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
{expandedRunId === run.id && runDetail && (
|
||||
<div className="border-t border-border/60 px-3 py-2">
|
||||
{run.error && (
|
||||
<p className="mb-2 text-xs text-destructive">{run.error}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{runDetail.steps.map((step) => (
|
||||
<div key={step.id} className="flex items-center gap-2 rounded px-2 py-1 text-xs">
|
||||
{statusIcon[step.status] ?? statusIcon.pending}
|
||||
<span className="font-mono text-muted-foreground">{step.nodeId.slice(0, 8)}</span>
|
||||
<span className="capitalize text-muted-foreground">{step.nodeType}</span>
|
||||
<span className="capitalize">{step.status}</span>
|
||||
{step.error && <span className="truncate text-destructive">{step.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,11 +12,16 @@ import {
|
||||
getLogosContent,
|
||||
setLogosContent,
|
||||
upsertRenderOutputEntry,
|
||||
getFluxSceneTree,
|
||||
setFluxSceneTree,
|
||||
getSceneGraph,
|
||||
setSceneGraph,
|
||||
RECOLLECTION_FILE_EXT,
|
||||
RECOLLECTION_VERSION,
|
||||
type StoredGraphState,
|
||||
type StoredLogosContent,
|
||||
type RenderOutputCacheEntry,
|
||||
type FluxSceneMeta,
|
||||
} from '../state/recollectionStore'
|
||||
|
||||
export type { RenderOutputCacheEntry }
|
||||
@@ -31,6 +36,8 @@ export type RecollectionFilePayload = {
|
||||
version?: number
|
||||
graph?: { nodes: unknown[]; edges: unknown[] }
|
||||
logos?: StoredLogosContent
|
||||
/** Flux scenes (Phase 2+). Each entry has scene metadata + graph. */
|
||||
scenes?: Array<{ id: string; title: string; position: number; graph: { nodes: unknown[]; edges: unknown[] } }>
|
||||
}
|
||||
|
||||
export type FluxSlot = {
|
||||
@@ -48,6 +55,7 @@ export type FluxSlot = {
|
||||
canDuplicate?: boolean
|
||||
canCopy?: boolean
|
||||
onFitView?: () => void
|
||||
onRun?: () => void
|
||||
}
|
||||
|
||||
export type LogosSlot = {
|
||||
@@ -103,6 +111,26 @@ function validateAndWritePayload(
|
||||
result.logos = payload.logos as StoredLogosContent
|
||||
}
|
||||
}
|
||||
// Import scenes
|
||||
if (payload.scenes && Array.isArray(payload.scenes)) {
|
||||
const sceneMetas: FluxSceneMeta[] = []
|
||||
for (const s of payload.scenes) {
|
||||
if (!s || typeof s.id !== 'string' || typeof s.title !== 'string') continue
|
||||
sceneMetas.push({ id: s.id, title: s.title, position: s.position ?? 0 })
|
||||
if (s.graph && Array.isArray(s.graph.nodes) && Array.isArray(s.graph.edges)) {
|
||||
const nodes = s.graph.nodes as AppNode[]
|
||||
const edges = backfillEdges(nodes, s.graph.edges as AppEdge[])
|
||||
setSceneGraph(recollectionId, s.id, {
|
||||
version: payload.version ?? RECOLLECTION_VERSION,
|
||||
nodes,
|
||||
edges,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (sceneMetas.length > 0) {
|
||||
setFluxSceneTree(recollectionId, sceneMetas)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -150,10 +178,17 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
|
||||
if (!recollectionId) return
|
||||
const graph = getGraph(recollectionId)
|
||||
const logosContent = getLogosContent(recollectionId)
|
||||
// Export scenes
|
||||
const sceneTree = getFluxSceneTree(recollectionId)
|
||||
const scenes = sceneTree.map((s) => {
|
||||
const sg = getSceneGraph(recollectionId, s.id)
|
||||
return { ...s, graph: sg ? { nodes: sg.nodes, edges: sg.edges } : { nodes: [], edges: [] } }
|
||||
})
|
||||
const payload: RecollectionFilePayload = {
|
||||
version: RECOLLECTION_VERSION,
|
||||
...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }),
|
||||
...(logosContent && { logos: logosContent }),
|
||||
...(scenes.length > 0 && { scenes }),
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
} from '@/components/ui/menubar'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { useRecollectionActions } from './RecollectionActionsContext'
|
||||
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2 } from 'lucide-react'
|
||||
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2, Play, Square } from 'lucide-react'
|
||||
import { useRunStore } from '@/lib/graph/runStore'
|
||||
|
||||
const UNDO_KEYS = { key: 'z', shiftKey: false }
|
||||
const REDO_KEYS = { key: 'z', shiftKey: true }
|
||||
@@ -26,8 +27,28 @@ function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
|
||||
|
||||
export function RecollectionEditViewMenus() {
|
||||
const { activeSlot, flux, isFluxActive } = useRecollectionActions()
|
||||
const runStatus = useRunStore((s) => s.status)
|
||||
const resetRun = useRunStore((s) => s.reset)
|
||||
const isDirty = useRunStore((s) => s.dirty)
|
||||
|
||||
const fluxSlot = isFluxActive ? flux : null
|
||||
const isRunning = runStatus === 'running' || runStatus === 'pending'
|
||||
const hasFinished = runStatus === 'completed' || runStatus === 'failed'
|
||||
|
||||
// Keyboard shortcut: Cmd+Enter to run
|
||||
useEffect(() => {
|
||||
if (!fluxSlot?.onRun) return
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
const mod = ev.ctrlKey || ev.metaKey
|
||||
if (mod && ev.key === 'Enter' && !isRunning) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
fluxSlot.onRun?.()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [fluxSlot?.onRun, isRunning])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSlot) return
|
||||
@@ -150,5 +171,40 @@ export function RecollectionEditViewMenus() {
|
||||
[activeSlot, fluxSlot, hasFluxOnly]
|
||||
)
|
||||
|
||||
return menus
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{menus}
|
||||
{fluxSlot?.onRun && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={fluxSlot.onRun}
|
||||
disabled={isRunning}
|
||||
className={`relative flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium shadow-sm transition-colors disabled:cursor-not-allowed ${
|
||||
isRunning
|
||||
? 'border-blue-500/40 bg-blue-500/10 text-blue-600 dark:text-blue-400'
|
||||
: isDirty
|
||||
? 'border-primary/50 bg-primary/10 text-primary hover:bg-primary/20'
|
||||
: 'border-border/60 bg-card text-foreground hover:bg-accent'
|
||||
}`}
|
||||
aria-label={isRunning ? 'Run in progress' : isDirty ? 'Changes pending — run graph (⌘↵)' : 'Run graph (⌘↵)'}
|
||||
title="⌘↵"
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<Square className="size-3 fill-current" />
|
||||
<span>Running</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="size-3 fill-current" />
|
||||
<span>Run</span>
|
||||
</>
|
||||
)}
|
||||
{isDirty && !isRunning && (
|
||||
<span className="absolute -top-1 -right-1 size-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,111 +1,275 @@
|
||||
/**
|
||||
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
|
||||
* Recollection sidebar: VS Code-style collapsible/resizable sections.
|
||||
* - Workspace: unified tree (pages, scenes, folders)
|
||||
* - Katalogos: artifacts & run history
|
||||
* Horizontally resizable via right-edge drag. Sections vertically resizable via divider drag.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, FileText } from 'lucide-react'
|
||||
import { Plus, FileText, Folder, ChevronDown, ChevronRight, Search, X, ChevronsDownUp, ChevronsUpDown } from 'lucide-react'
|
||||
import { FluxIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||
import { TreeBrowser } from './TreeBrowser'
|
||||
import { useWorkspaceTree } from './WorkspaceTreeContext'
|
||||
import { TreeBrowser, type TreeBrowserHandle } from './TreeBrowser'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
sidebarMenuButtonVariants,
|
||||
} from '@/components/ui/sidebar'
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function RecollectionSidebar() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const baseLogos = `${base}/logos`
|
||||
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
||||
const isFluxView = pathname.endsWith('/flux')
|
||||
const MIN_WIDTH = 180
|
||||
const MAX_WIDTH = 480
|
||||
const DEFAULT_WIDTH = 240
|
||||
const SECTION_HEADER_H = 28
|
||||
const MIN_SECTION_H = SECTION_HEADER_H + 32 // header + one row
|
||||
|
||||
const handleAddPage = useCallback(() => {
|
||||
// Add a new top-level page
|
||||
const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1)
|
||||
const newPage = {
|
||||
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
title: 'Untitled',
|
||||
parentId: null,
|
||||
position: maxPos + 1,
|
||||
}
|
||||
handleTreeChange([...tree, newPage])
|
||||
handleSelectPage(newPage.id)
|
||||
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
|
||||
}, [tree, handleTreeChange, handleSelectPage, baseLogos, navigate])
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section header (VS Code style: uppercase label, chevron, actions on hover)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
open: boolean
|
||||
onToggle: () => void
|
||||
actions?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
|
||||
style={{ '--sidebar-width': '16rem' } as React.CSSProperties}
|
||||
className="group/header flex h-7 shrink-0 items-center gap-1 px-2 select-none cursor-pointer border-b border-sidebar-border/50 bg-sidebar hover:bg-sidebar-accent/40 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
|
||||
<SidebarGroup>
|
||||
<div className="flex items-center justify-between gap-2 py-1.5">
|
||||
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
onClick={handleAddPage}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New page
|
||||
</Button>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<TreeBrowser />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Katalogos</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
data-active={isKatalogosView}
|
||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
||||
onClick={() => navigate(`${base}/logos/katalogos`)}
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
<span className="truncate">Artifacts</span>
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Flux</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
data-active={isFluxView}
|
||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
||||
onClick={() => navigate(`${base}/flux`)}
|
||||
>
|
||||
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
<span className="truncate">Canvas</span>
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
{open
|
||||
? <ChevronDown className="size-3 shrink-0 opacity-50" />
|
||||
: <ChevronRight className="size-3 shrink-0 opacity-50" />}
|
||||
<span className="flex-1 min-w-0 truncate text-[11px] font-semibold uppercase tracking-wider opacity-70">
|
||||
{title}
|
||||
</span>
|
||||
{actions && (
|
||||
<div
|
||||
className="flex shrink-0 items-center opacity-0 group-hover/header:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Katalogos section content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function KatalogosContent() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 p-1 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
data-active={isKatalogosView || undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-sm px-2 h-[30px] text-[13px] w-full text-left transition-colors',
|
||||
'hover:bg-sidebar-accent/60',
|
||||
isKatalogosView && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
|
||||
)}
|
||||
onClick={() => navigate(`${base}/logos/katalogos`)}
|
||||
>
|
||||
<FileText className="size-[15px] shrink-0 opacity-60" />
|
||||
<span className="truncate">Artifacts</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RecollectionSidebar() {
|
||||
const { handleAddNode } = useWorkspaceTree()
|
||||
|
||||
// Search & tree ref
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchVisible, setSearchVisible] = useState(false)
|
||||
const treeBrowserRef = useRef<TreeBrowserHandle>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Horizontal resize
|
||||
const [width, setWidth] = useState(DEFAULT_WIDTH)
|
||||
const onHResizeStart = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
const startX = e.clientX
|
||||
const startW = width
|
||||
const onMove = (ev: PointerEvent) => setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startW + (ev.clientX - startX))))
|
||||
const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = '' }
|
||||
document.addEventListener('pointermove', onMove)
|
||||
document.addEventListener('pointerup', onUp)
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [width])
|
||||
|
||||
// Section collapse
|
||||
const [workspaceOpen, setWorkspaceOpen] = useState(true)
|
||||
const [katalogosOpen, setKatalogosOpen] = useState(true)
|
||||
|
||||
// Vertical section resize (drag the divider between sections)
|
||||
// workspaceFlex: fraction of available space for workspace section (0.2–0.8)
|
||||
const [workspaceFlex, setWorkspaceFlex] = useState(0.75)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onVResizeStart = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const startY = e.clientY
|
||||
const startFlex = workspaceFlex
|
||||
const containerH = container.clientHeight
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = ev.clientY - startY
|
||||
const newFlex = startFlex + delta / containerH
|
||||
setWorkspaceFlex(Math.min(0.85, Math.max(0.15, newFlex)))
|
||||
}
|
||||
const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); document.body.style.cursor = ''; document.body.style.userSelect = '' }
|
||||
document.addEventListener('pointermove', onMove)
|
||||
document.addEventListener('pointerup', onUp)
|
||||
document.body.style.cursor = 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [workspaceFlex])
|
||||
|
||||
const bothOpen = workspaceOpen && katalogosOpen
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground overflow-hidden"
|
||||
style={{ width }}
|
||||
>
|
||||
<div ref={containerRef} className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Workspace section */}
|
||||
<div
|
||||
className="flex flex-col overflow-hidden"
|
||||
style={workspaceOpen && bothOpen ? { flex: `${workspaceFlex} 1 0%`, minHeight: MIN_SECTION_H } : workspaceOpen ? { flex: '1 1 0%', minHeight: MIN_SECTION_H } : undefined}
|
||||
>
|
||||
<SectionHeader
|
||||
title="Workspace"
|
||||
open={workspaceOpen}
|
||||
onToggle={() => setWorkspaceOpen((v) => !v)}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
|
||||
title="Search"
|
||||
onClick={() => { setSearchVisible((v) => { if (!v) setTimeout(() => searchInputRef.current?.focus(), 0); return !v }); if (searchVisible) setSearchQuery('') }}
|
||||
>
|
||||
<Search className="size-3 opacity-70" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
|
||||
title="Collapse all"
|
||||
onClick={() => treeBrowserRef.current?.closeAll()}
|
||||
>
|
||||
<ChevronsDownUp className="size-3 opacity-70" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
|
||||
<Plus className="size-3 opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => handleAddNode('page')}>
|
||||
<FileText className="mr-2 size-3.5" /> Page
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAddNode('scene')}>
|
||||
<FluxIcon className="mr-2 size-3.5" /> Scene
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleAddNode('folder')}>
|
||||
<Folder className="mr-2 size-3.5" /> Folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{workspaceOpen && (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{searchVisible && (
|
||||
<div className="flex items-center gap-1 px-1.5 py-1 shrink-0">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Filter..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') { setSearchQuery(''); setSearchVisible(false) } }}
|
||||
className="h-6 text-xs pl-2 pr-6 bg-sidebar-accent/30 border-sidebar-border/50 focus:bg-sidebar-accent/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button type="button" className="absolute right-1 top-1/2 -translate-y-1/2 flex size-4 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent" onClick={() => setSearchQuery('')}>
|
||||
<X className="size-2.5 opacity-60" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<TreeBrowser ref={treeBrowserRef} searchQuery={searchQuery} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vertical resize divider (only when both sections open) */}
|
||||
{bothOpen && (
|
||||
<div
|
||||
className="h-px shrink-0 cursor-row-resize bg-sidebar-border/50 hover:bg-primary/30 active:bg-primary/40 transition-colors"
|
||||
onPointerDown={onVResizeStart}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Katalogos section */}
|
||||
<div
|
||||
className="flex flex-col overflow-hidden"
|
||||
style={katalogosOpen && bothOpen ? { flex: `${1 - workspaceFlex} 1 0%`, minHeight: MIN_SECTION_H } : katalogosOpen ? { flex: '1 1 0%', minHeight: MIN_SECTION_H } : undefined}
|
||||
>
|
||||
<SectionHeader
|
||||
title="Katalogos"
|
||||
open={katalogosOpen}
|
||||
onToggle={() => setKatalogosOpen((v) => !v)}
|
||||
/>
|
||||
{katalogosOpen && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<KatalogosContent />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal resize handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors z-10"
|
||||
onPointerDown={onHResizeStart}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
/**
|
||||
* TreeBrowser: A tree browser component using react-arborist for managing
|
||||
* Logos pages in a hierarchical folder structure.
|
||||
*
|
||||
* Features:
|
||||
* - Drag and drop reordering
|
||||
* - Folder expansion/collapse
|
||||
* - Context menu for actions
|
||||
* - Shadcn theme integration
|
||||
* - Inline editing for page titles
|
||||
* - Smooth animations
|
||||
* - Search functionality
|
||||
* - Expand/collapse all
|
||||
* TreeBrowser: Unified workspace tree (pages, scenes, folders).
|
||||
* Notion/VS Code inspired design. Only folders accept children.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useMemo, useRef } from 'react'
|
||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||
import {
|
||||
Tree,
|
||||
NodeApi,
|
||||
NodeRendererProps,
|
||||
RowRendererProps,
|
||||
TreeApi,
|
||||
@@ -32,13 +21,10 @@ import {
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Search,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
X,
|
||||
GripVertical,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FluxIcon } from '@/lib/icons'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -46,489 +32,332 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import type { LogosPageMeta } from '../state/recollectionStore'
|
||||
import { removeLogosPageContent } from '../state/recollectionStore'
|
||||
import { useWorkspaceTree } from './WorkspaceTreeContext'
|
||||
import type { WorkspaceNodeMeta } from '../state/recollectionStore'
|
||||
|
||||
// Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object).
|
||||
type TreeNode = LogosPageMeta & {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree node type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TreeNode = WorkspaceNodeMeta & {
|
||||
children?: TreeNode[]
|
||||
isFolder: boolean
|
||||
}
|
||||
|
||||
// Convert flat tree to hierarchical structure
|
||||
function buildTree(pages: LogosPageMeta[]): TreeNode[] {
|
||||
const pageMap = new Map<string, TreeNode>()
|
||||
function buildTree(nodes: WorkspaceNodeMeta[]): TreeNode[] {
|
||||
const nodeMap = new Map<string, TreeNode>()
|
||||
const rootNodes: TreeNode[] = []
|
||||
|
||||
// First pass: create all nodes
|
||||
pages.forEach((page) => {
|
||||
pageMap.set(page.id, {
|
||||
...page,
|
||||
children: [],
|
||||
isFolder: false,
|
||||
})
|
||||
})
|
||||
for (const n of nodes) nodeMap.set(n.id, { ...n, children: n.kind === 'folder' ? [] : undefined })
|
||||
|
||||
// Second pass: build parent-child relationships
|
||||
pages.forEach((page) => {
|
||||
const node = pageMap.get(page.id)!
|
||||
if (page.parentId === null) {
|
||||
rootNodes.push(node)
|
||||
for (const n of nodes) {
|
||||
const tn = nodeMap.get(n.id)!
|
||||
if (n.parentId === null) {
|
||||
rootNodes.push(tn)
|
||||
} else {
|
||||
const parent = pageMap.get(page.parentId)
|
||||
if (parent) {
|
||||
parent.children?.push(node)
|
||||
parent.isFolder = true
|
||||
}
|
||||
const parent = nodeMap.get(n.parentId)
|
||||
if (parent?.children) parent.children.push(tn)
|
||||
else rootNodes.push(tn) // orphan fallback
|
||||
}
|
||||
})
|
||||
|
||||
// Sort children by position
|
||||
function sortNodes(nodes: TreeNode[]) {
|
||||
nodes.sort((a, b) => a.position - b.position)
|
||||
nodes.forEach((node) => sortNodes(node.children || []))
|
||||
}
|
||||
sortNodes(rootNodes)
|
||||
|
||||
const sort = (ns: TreeNode[]) => {
|
||||
ns.sort((a, b) => {
|
||||
// Folders first, then by position
|
||||
const aIsFolder = a.kind === 'folder' ? 0 : 1
|
||||
const bIsFolder = b.kind === 'folder' ? 0 : 1
|
||||
if (aIsFolder !== bIsFolder) return aIsFolder - bIsFolder
|
||||
return a.position - b.position
|
||||
})
|
||||
ns.forEach((n) => { if (n.children) sort(n.children) })
|
||||
}
|
||||
sort(rootNodes)
|
||||
return rootNodes
|
||||
}
|
||||
|
||||
// Memoize the buildTree result to avoid unnecessary re-creation
|
||||
function useTreeNodes(tree: LogosPageMeta[]) {
|
||||
return useMemo(() => buildTree(tree), [tree])
|
||||
}
|
||||
|
||||
// Inline editable title component
|
||||
function EditableTitle({
|
||||
title,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
title: string
|
||||
onSave: (newTitle: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSave(inputRef.current?.value || '')
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline title editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditableTitle({ title, onSave, onCancel }: { title: string; onSave: (v: string) => void; onCancel: () => void }) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
React.useEffect(() => { ref.current?.focus(); ref.current?.select() }, [])
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
ref={ref}
|
||||
type="text"
|
||||
defaultValue={title}
|
||||
onBlur={(e) => onSave(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') onSave(ref.current?.value || ''); else if (e.key === 'Escape') onCancel() }}
|
||||
className="w-full rounded-sm px-1 py-0.5 text-[13px] outline-none ring-1 ring-primary/50 bg-background"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ROW_INDENT_PX = 20
|
||||
const ROW_GUTTER_PX = 8
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Line cursor = “insert as sibling at this indent” (not “drop into folder”).
|
||||
* Triangle + dashed rail read as a slot between rows; matches sidebar theme.
|
||||
*/
|
||||
const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) {
|
||||
const INDENT = 10
|
||||
const GUTTER = 2
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drop cursor (line between rows)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DropCursor = React.memo(function DropCursor({ top, left, indent }: CursorProps) {
|
||||
return (
|
||||
<div
|
||||
role="presentation"
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute z-20 flex items-center gap-1.5"
|
||||
style={{
|
||||
top: top - 5,
|
||||
left: left + ROW_GUTTER_PX,
|
||||
right: Math.max(indent, ROW_GUTTER_PX),
|
||||
}}
|
||||
className="pointer-events-none absolute z-20 flex items-center"
|
||||
style={{ top: top - 1, left: left + GUTTER + 4, right: GUTTER + 4 }}
|
||||
>
|
||||
<span className="flex h-3 w-2.5 shrink-0 items-center justify-center text-primary drop-shadow-sm">
|
||||
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor" aria-hidden>
|
||||
<path d="M0 5 L9 1.5 L9 8.5 Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="flex h-3 min-w-[2rem] flex-1 items-center">
|
||||
<div
|
||||
className={cn(
|
||||
'h-0 w-full border-t-2 border-dashed border-primary',
|
||||
'shadow-[0_1px_0_0_hsl(var(--primary)/0.35)]',
|
||||
'motion-safe:animate-[tree-drop-line-pulse_1.8s_ease-in-out_infinite]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="size-1.5 rounded-full bg-primary shrink-0" />
|
||||
<div className="h-0.5 flex-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover
|
||||
* (always visible on coarse pointers). Title/icon strip is click-to-navigate only.
|
||||
*/
|
||||
function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||
// ---------------------------------------------------------------------------
|
||||
// Node icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const baseLogos = `${base}/logos`
|
||||
function NodeIcon({ kind, isOpen }: { kind: string; isOpen?: boolean }) {
|
||||
const cls = 'size-[15px] shrink-0 opacity-60'
|
||||
if (kind === 'scene') return <FluxIcon className={cls} />
|
||||
if (kind === 'folder') return isOpen ? <FolderOpen className={cls} /> : <Folder className={cls} />
|
||||
return <FileText className={cls} />
|
||||
}
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
node.toggle()
|
||||
},
|
||||
[node]
|
||||
)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree node renderer (Notion/VS Code style)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!node.state.isEditing && !node.state.isDragging) {
|
||||
handleSelectPage(node.data.id)
|
||||
navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`)
|
||||
}
|
||||
},
|
||||
[handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging]
|
||||
)
|
||||
function WorkspaceTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
|
||||
const { handleSelectItem, handleAddNode, handleRenameNode, handleDeleteNode } = useWorkspaceTree()
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
node.edit()
|
||||
}, [node])
|
||||
const isFolder = node.data.kind === 'folder'
|
||||
const hasChildren = isFolder && (node.children?.length || 0) > 0
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
const toDelete = new Set<string>()
|
||||
function collectDescendants(id: string) {
|
||||
toDelete.add(id)
|
||||
tree.forEach((p) => {
|
||||
if (p.parentId === id) collectDescendants(p.id)
|
||||
})
|
||||
}
|
||||
collectDescendants(node.data.id)
|
||||
if (recollectionId) {
|
||||
toDelete.forEach((pageId) => {
|
||||
removeLogosPageContent(recollectionId, pageId)
|
||||
})
|
||||
}
|
||||
handleTreeChange(tree.filter((p) => !toDelete.has(p.id)))
|
||||
}, [tree, handleTreeChange, node.data.id, recollectionId])
|
||||
|
||||
const handleAddChild = useCallback(() => {
|
||||
const maxPos = Math.max(
|
||||
0,
|
||||
...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position),
|
||||
-1
|
||||
)
|
||||
const newPage: LogosPageMeta = {
|
||||
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
title: 'Untitled',
|
||||
parentId: node.data.id,
|
||||
position: maxPos + 1,
|
||||
}
|
||||
handleTreeChange([...tree, newPage])
|
||||
if (!node.isOpen) node.open()
|
||||
}, [tree, handleTreeChange, node])
|
||||
|
||||
const hasChildren = (node.children?.length || 0) > 0
|
||||
const pageTitle = node.data.title || 'Untitled'
|
||||
const handleClick = useCallback(() => {
|
||||
if (node.state.isEditing || node.state.isDragging) return
|
||||
if (isFolder) node.toggle()
|
||||
else handleSelectItem(node.data.id)
|
||||
}, [handleSelectItem, node, isFolder])
|
||||
|
||||
// The entire row is the drag handle — Notion style
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center overflow-hidden',
|
||||
'transition-[width,opacity] duration-200 ease-out',
|
||||
'w-0 opacity-0 group-hover:w-4 group-hover:opacity-100',
|
||||
'[@media(pointer:coarse)]:w-5 [@media(pointer:coarse)]:opacity-100',
|
||||
node.state.isDragging && 'w-4 opacity-100 [@media(pointer:coarse)]:w-5',
|
||||
node.state.isEditing && 'pointer-events-none w-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={{ ...style, paddingLeft: 0 }}
|
||||
title={node.state.isEditing ? undefined : 'Drag to reorder'}
|
||||
className={cn(
|
||||
'logos-tree-drag-handle group/drag',
|
||||
'relative flex h-6 w-4 shrink-0 touch-none items-center justify-center rounded-md',
|
||||
'[@media(pointer:coarse)]:h-7 [@media(pointer:coarse)]:w-5',
|
||||
'border border-sidebar-border/50 bg-sidebar-accent/40',
|
||||
'text-sidebar-foreground/70',
|
||||
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out',
|
||||
'hover:border-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
'hover:shadow-sm',
|
||||
'active:scale-[0.97] active:cursor-grabbing',
|
||||
'cursor-grab outline-none',
|
||||
'dark:border-sidebar-border/55 dark:bg-sidebar-accent/35 dark:text-sidebar-foreground/75',
|
||||
'dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground',
|
||||
node.state.isDragging &&
|
||||
'cursor-grabbing border-primary/55 bg-primary/18 text-primary shadow-sm ring-1 ring-primary/30 dark:border-primary/50 dark:bg-primary/22 dark:text-primary'
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical
|
||||
className={cn(
|
||||
'size-3 shrink-0 text-current transition-opacity duration-150 opacity-85 group-hover/drag:opacity-100',
|
||||
node.state.isDragging && 'opacity-100'
|
||||
)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren ? (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={{ ...style, paddingLeft: 0 }}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 items-center gap-0.5 select-none overflow-hidden',
|
||||
node.state.isEditing ? 'cursor-text' : node.state.isDragging ? 'cursor-grabbing' : 'cursor-default',
|
||||
node.state.isDragging && 'opacity-40'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Chevron (folders only) */}
|
||||
{isFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||
onClick={handleToggle}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); node.toggle() }}
|
||||
>
|
||||
{node.isOpen ? (
|
||||
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
|
||||
)}
|
||||
{node.isOpen
|
||||
? <ChevronDown className="size-3 opacity-50" />
|
||||
: <ChevronRight className="size-3 opacity-50" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4 shrink-0" />
|
||||
<div className="w-5 shrink-0" />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-[28px] min-w-0 flex-1 items-center gap-1 rounded-md px-0.5 -mx-0.5',
|
||||
node.state.isEditing ? 'cursor-text' : 'cursor-pointer hover:bg-sidebar-accent/40'
|
||||
{/* Icon */}
|
||||
<NodeIcon kind={node.data.kind} isOpen={node.isOpen} />
|
||||
|
||||
{/* Title */}
|
||||
<div className="min-w-0 flex-1 overflow-hidden pl-1.5">
|
||||
{node.state.isEditing ? (
|
||||
<EditableTitle
|
||||
title={node.data.title}
|
||||
onSave={(v) => { if (v.trim()) handleRenameNode(node.data.id, v.trim()); node.submit(v) }}
|
||||
onCancel={() => node.reset()}
|
||||
/>
|
||||
) : (
|
||||
<span className="block truncate text-[13px] leading-tight cursor-pointer" title={node.data.title}>{node.data.title || 'Untitled'}</span>
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{node.state.isEditing ? (
|
||||
<EditableTitle
|
||||
title={node.data.title}
|
||||
onSave={(newTitle) => {
|
||||
if (newTitle.trim()) {
|
||||
handleTreeChange(
|
||||
tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p))
|
||||
)
|
||||
}
|
||||
node.submit(newTitle)
|
||||
}}
|
||||
onCancel={() => node.reset()}
|
||||
/>
|
||||
) : (
|
||||
<span className="block truncate select-none">{pageTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddChild()
|
||||
}}
|
||||
title="Add child page"
|
||||
{/* Hover actions */}
|
||||
{!node.state.isEditing && (
|
||||
<div
|
||||
className="flex shrink-0 items-center cursor-default opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5 text-sidebar-foreground/70" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={handleRename}>
|
||||
<Pencil className="mr-2 size-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{/* Plus — only on folders (add inside) or any node (add sibling) */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
|
||||
<Plus className="size-3 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('page', pid); if (isFolder && !node.isOpen) node.open() }}>
|
||||
<FileText className="mr-2 size-3.5" /> Page
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('scene', pid); if (isFolder && !node.isOpen) node.open() }}>
|
||||
<FluxIcon className="mr-2 size-3.5" /> Scene
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { const pid = isFolder ? node.data.id : node.data.parentId; handleAddNode('folder', pid); if (isFolder && !node.isOpen) node.open() }}>
|
||||
<Folder className="mr-2 size-3.5" /> Folder
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* More */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="flex size-5 items-center justify-center rounded-sm cursor-pointer hover:bg-sidebar-accent transition-colors">
|
||||
<MoreHorizontal className="size-3 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-36">
|
||||
<DropdownMenuItem onClick={() => node.edit()}>
|
||||
<Pencil className="mr-2 size-3.5" /> Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleDeleteNode(node.data.id)} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 size-3.5" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
|
||||
const { pathname } = useLocation()
|
||||
const { activePageId } = useRecollectionSidebar()
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const baseLogos = `${base}/logos`
|
||||
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
|
||||
const level = node.level
|
||||
const indentPx = ROW_GUTTER_PX + level * ROW_INDENT_PX
|
||||
const { activeItemId } = useWorkspaceTree()
|
||||
const isActive = activeItemId === node.data.id
|
||||
const indentPx = GUTTER + node.level * INDENT
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
{...attrs}
|
||||
style={{
|
||||
...attrs.style,
|
||||
paddingLeft: `${indentPx}px`,
|
||||
}}
|
||||
style={{ ...attrs.style, paddingLeft: `${indentPx}px` }}
|
||||
className={cn(
|
||||
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md transition-[box-shadow,background-color,ring-color] duration-150',
|
||||
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
||||
node.state.isDragging && 'cursor-grabbing opacity-45',
|
||||
// Folder drop target: index === null in library — reads as “open container”, not a line between rows
|
||||
node.state.willReceiveDrop &&
|
||||
'cursor-copy bg-sidebar-accent/30 ring-2 ring-inset ring-dashed ring-sidebar-ring/80 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]'
|
||||
'group relative flex items-center pr-1 h-[30px] text-[13px] rounded-sm mx-1',
|
||||
'transition-colors duration-100',
|
||||
'hover:bg-sidebar-accent/60',
|
||||
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground font-medium',
|
||||
node.state.isDragging && 'opacity-40',
|
||||
node.state.willReceiveDrop && node.data.kind === 'folder' &&
|
||||
'bg-sidebar-accent/40 ring-1 ring-primary/40 cursor-copy'
|
||||
)}
|
||||
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
|
||||
>
|
||||
{level > 0 && (
|
||||
{/* VS Code-style indent guides */}
|
||||
{Array.from({ length: node.level }, (_, i) => (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 border-l-2 border-sidebar-border/45"
|
||||
style={{ left: `${ROW_GUTTER_PX + (level - 1) * ROW_INDENT_PX + ROW_INDENT_PX / 2}px` }}
|
||||
key={i}
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px bg-sidebar-border/40"
|
||||
style={{ left: `${GUTTER + i * INDENT + INDENT / 2}px` }}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TreeBrowser() {
|
||||
const { tree, handleTreeChange } = useRecollectionSidebar()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
// ---------------------------------------------------------------------------
|
||||
// TreeBrowser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TreeBrowserHandle = {
|
||||
openAll: () => void
|
||||
closeAll: () => void
|
||||
}
|
||||
|
||||
export type TreeBrowserProps = {
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export const TreeBrowser = React.forwardRef<TreeBrowserHandle, TreeBrowserProps>(
|
||||
function TreeBrowser({ searchQuery = '' }, ref) {
|
||||
const { tree, handleTreeChange } = useWorkspaceTree()
|
||||
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600)
|
||||
const [treeContainerHeight, treeContainerRef] = useResizeHeight(200)
|
||||
|
||||
// Build tree from flat structure
|
||||
const treeNodes = useTreeNodes(tree)
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
openAll: () => treeRef.current?.openAll(),
|
||||
closeAll: () => treeRef.current?.closeAll(),
|
||||
}), [])
|
||||
|
||||
const treeNodes = useMemo(() => buildTree(tree), [tree])
|
||||
|
||||
// Filter tree based on search query
|
||||
const filteredTree = useMemo(() => {
|
||||
if (!searchQuery.trim()) return treeNodes
|
||||
|
||||
const searchTerm = searchQuery.toLowerCase()
|
||||
|
||||
function filterNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
const result: TreeNode[] = []
|
||||
for (const node of nodes) {
|
||||
const matches = node.title.toLowerCase().includes(searchTerm)
|
||||
const children = filterNodes(node.children || [])
|
||||
|
||||
if (matches || children.length > 0) {
|
||||
result.push({
|
||||
...node,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
})
|
||||
}
|
||||
const term = searchQuery.toLowerCase()
|
||||
const filter = (nodes: TreeNode[]): TreeNode[] => {
|
||||
const out: TreeNode[] = []
|
||||
for (const n of nodes) {
|
||||
const matches = n.title.toLowerCase().includes(term)
|
||||
const kids = n.children ? filter(n.children) : []
|
||||
if (matches || kids.length > 0) out.push({ ...n, children: kids.length > 0 ? kids : n.children ? [] : undefined })
|
||||
}
|
||||
return result
|
||||
return out
|
||||
}
|
||||
|
||||
return filterNodes(treeNodes)
|
||||
return filter(treeNodes)
|
||||
}, [treeNodes, searchQuery])
|
||||
|
||||
// Get all folder IDs for initial open state
|
||||
const initialOpenState = useMemo(() => {
|
||||
const folderIds: Record<string, boolean> = {}
|
||||
function collectIds(nodes: TreeNode[]) {
|
||||
nodes.forEach((node) => {
|
||||
if (node.isFolder) {
|
||||
folderIds[node.id] = true
|
||||
collectIds(node.children || [])
|
||||
}
|
||||
})
|
||||
}
|
||||
collectIds(treeNodes)
|
||||
return folderIds
|
||||
const ids: Record<string, boolean> = {}
|
||||
const collect = (ns: TreeNode[]) => ns.forEach((n) => { if (n.kind === 'folder') { ids[n.id] = true; if (n.children) collect(n.children) } })
|
||||
collect(treeNodes)
|
||||
return ids
|
||||
}, [treeNodes])
|
||||
|
||||
// Handle drag and drop reordering (react-arborist onMove)
|
||||
const handleMove = useCallback(
|
||||
({
|
||||
dragIds,
|
||||
parentId: newParentId,
|
||||
index,
|
||||
}: {
|
||||
dragIds: string[]
|
||||
parentId: string | null
|
||||
index: number
|
||||
}) => {
|
||||
({ dragIds, parentId: newParentId, index }: { dragIds: string[]; parentId: string | null; index: number }) => {
|
||||
const dragId = dragIds[0]
|
||||
if (!dragId) return
|
||||
|
||||
const dragged = tree.find((p) => p.id === dragId)
|
||||
if (newParentId !== null) {
|
||||
const target = tree.find((n) => n.id === newParentId)
|
||||
if (target && target.kind !== 'folder') return
|
||||
}
|
||||
const dragged = tree.find((n) => n.id === dragId)
|
||||
if (!dragged) return
|
||||
|
||||
const oldParentId = dragged.parentId
|
||||
const moved: LogosPageMeta = { ...dragged, parentId: newParentId }
|
||||
const moved: WorkspaceNodeMeta = { ...dragged, parentId: newParentId }
|
||||
|
||||
const newSiblings = tree
|
||||
.filter((p) => p.parentId === newParentId && p.id !== dragId)
|
||||
.filter((n) => n.parentId === newParentId && n.id !== dragId)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
newSiblings.splice(index, 0, moved)
|
||||
|
||||
const updates = new Map<string, LogosPageMeta>()
|
||||
newSiblings.forEach((p, i) => {
|
||||
updates.set(p.id, { ...p, parentId: newParentId, position: i })
|
||||
})
|
||||
const updates = new Map<string, WorkspaceNodeMeta>()
|
||||
newSiblings.forEach((n, i) => updates.set(n.id, { ...n, parentId: newParentId, position: i }))
|
||||
|
||||
if (oldParentId !== newParentId) {
|
||||
tree
|
||||
.filter((p) => p.parentId === oldParentId && p.id !== dragId)
|
||||
.filter((n) => n.parentId === oldParentId && n.id !== dragId)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.forEach((p, i) => {
|
||||
updates.set(p.id, { ...p, position: i })
|
||||
})
|
||||
.forEach((n, i) => updates.set(n.id, { ...n, position: i }))
|
||||
}
|
||||
|
||||
handleTreeChange(tree.map((p) => updates.get(p.id) ?? p))
|
||||
handleTreeChange(tree.map((n) => updates.get(n.id) ?? n))
|
||||
},
|
||||
[tree, handleTreeChange]
|
||||
)
|
||||
|
||||
// Expand all folders
|
||||
const handleExpandAll = useCallback(() => {
|
||||
treeRef.current?.openAll()
|
||||
}, [])
|
||||
|
||||
// Collapse all folders
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
treeRef.current?.closeAll()
|
||||
}, [])
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
|
||||
// Fixed prop shape: react-arborist TreeProvider uses [...Object.values(treeProps), …] as useMemo deps;
|
||||
// varying key counts on the props object trigger "dependency array changed size" warnings.
|
||||
const arboristTreeProps = useMemo(
|
||||
() => ({
|
||||
data: filteredTree,
|
||||
@@ -536,74 +365,23 @@ export function TreeBrowser() {
|
||||
childrenAccessor: 'children' as const,
|
||||
width: '100%' as const,
|
||||
height: Math.max(treeContainerHeight, 100),
|
||||
rowHeight: 32,
|
||||
indent: ROW_INDENT_PX,
|
||||
rowHeight: 30,
|
||||
indent: INDENT,
|
||||
renderRow: TreeRow,
|
||||
initialOpenState,
|
||||
onMove: handleMove,
|
||||
disableDrag: Boolean(searchQuery),
|
||||
disableDrop: Boolean(searchQuery),
|
||||
className: 'react-arborist-tree',
|
||||
renderCursor: LogosDropCursor,
|
||||
children: LogosTreeNode,
|
||||
renderCursor: DropCursor,
|
||||
children: WorkspaceTreeNode,
|
||||
}),
|
||||
[filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Search Toolbar */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search pages..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
title="Expand/Collapse options"
|
||||
>
|
||||
<ChevronsUpDown className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleExpandAll}>
|
||||
<ChevronsDownUp className="mr-2 size-3.5" />
|
||||
Expand all
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||
<ChevronsUpDown className="mr-2 size-3.5" />
|
||||
Collapse all
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
|
||||
<Tree ref={treeRef} {...arboristTreeProps} />
|
||||
</div>
|
||||
<div ref={treeContainerRef} className="h-full min-h-0 overflow-hidden">
|
||||
<Tree ref={treeRef} {...arboristTreeProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
265
frontend/src/app/recollections/layout/WorkspaceTreeContext.tsx
Normal file
265
frontend/src/app/recollections/layout/WorkspaceTreeContext.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Unified workspace tree context: pages, scenes, and folders in a single tree.
|
||||
* Replaces both RecollectionSidebarContext and FluxSceneContext.
|
||||
* Exports backward-compat shim hooks for existing consumers.
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
ensureWorkspaceTree,
|
||||
setWorkspaceTree,
|
||||
removeLogosPageContent,
|
||||
removeSceneGraph,
|
||||
DEFAULT_SCENE_ID,
|
||||
type WorkspaceNodeMeta,
|
||||
type WorkspaceNodeId,
|
||||
type WorkspaceNodeKind,
|
||||
type LogosPageMeta,
|
||||
type FluxSceneMeta,
|
||||
type FluxSceneId,
|
||||
} from '../state/recollectionStore'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WorkspaceTreeContextValue = {
|
||||
tree: WorkspaceNodeMeta[]
|
||||
setTree: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
|
||||
|
||||
activeItemId: WorkspaceNodeId | null
|
||||
activeItemKind: WorkspaceNodeKind | null
|
||||
|
||||
handleSelectItem: (id: WorkspaceNodeId) => void
|
||||
handleTreeChange: (tree: WorkspaceNodeMeta[]) => void
|
||||
|
||||
handleAddNode: (kind: WorkspaceNodeKind, parentId?: WorkspaceNodeId | null, title?: string) => WorkspaceNodeId
|
||||
handleRenameNode: (id: WorkspaceNodeId, title: string) => void
|
||||
handleDeleteNode: (id: WorkspaceNodeId) => void
|
||||
}
|
||||
|
||||
const WorkspaceTreeContext = createContext<WorkspaceTreeContextValue | null>(null)
|
||||
|
||||
export function useWorkspaceTree(): WorkspaceTreeContextValue {
|
||||
const ctx = useContext(WorkspaceTreeContext)
|
||||
if (!ctx) throw new Error('useWorkspaceTree must be used within WorkspaceTreeProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let nodeCounter = 0
|
||||
|
||||
export function WorkspaceTreeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const [tree, setTree] = useState<WorkspaceNodeMeta[]>([])
|
||||
const [activeItemId, setActiveItemId] = useState<WorkspaceNodeId | null>(null)
|
||||
|
||||
// Load tree on recollection change
|
||||
useEffect(() => {
|
||||
if (!recollectionId) return
|
||||
const t = ensureWorkspaceTree(recollectionId)
|
||||
setTree(t)
|
||||
}, [recollectionId])
|
||||
|
||||
// Sync activeItemId from URL
|
||||
useEffect(() => {
|
||||
if (!recollectionId || tree.length === 0) return
|
||||
const pageFromUrl = searchParams.get('page')
|
||||
const sceneFromUrl = searchParams.get('scene')
|
||||
setActiveItemId((prev) => {
|
||||
if (pageFromUrl && tree.some((n) => n.id === pageFromUrl && n.kind === 'page')) return pageFromUrl
|
||||
if (sceneFromUrl && tree.some((n) => n.id === sceneFromUrl && n.kind === 'scene')) return sceneFromUrl
|
||||
if (prev != null && tree.some((n) => n.id === prev)) return prev
|
||||
// Default to first page or scene
|
||||
const firstContent = tree.find((n) => n.kind === 'page' || n.kind === 'scene')
|
||||
return firstContent?.id ?? null
|
||||
})
|
||||
}, [recollectionId, searchParams, tree])
|
||||
|
||||
// Persist tree on changes
|
||||
useEffect(() => {
|
||||
if (!recollectionId || tree.length === 0) return
|
||||
setWorkspaceTree(recollectionId, tree)
|
||||
}, [recollectionId, tree])
|
||||
|
||||
const activeItemKind = useMemo(() => {
|
||||
if (!activeItemId) return null
|
||||
return tree.find((n) => n.id === activeItemId)?.kind ?? null
|
||||
}, [activeItemId, tree])
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(id: WorkspaceNodeId) => {
|
||||
if (!recollectionId) return
|
||||
const node = tree.find((n) => n.id === id)
|
||||
if (!node) return
|
||||
setActiveItemId(id)
|
||||
const base = `/recollections/${recollectionId}`
|
||||
if (node.kind === 'page') {
|
||||
navigate(`${base}/logos?page=${encodeURIComponent(id)}`)
|
||||
} else if (node.kind === 'scene') {
|
||||
navigate(`${base}/flux?scene=${encodeURIComponent(id)}`)
|
||||
}
|
||||
// folder: no navigation, just select
|
||||
},
|
||||
[recollectionId, tree, navigate]
|
||||
)
|
||||
|
||||
const handleTreeChange = useCallback((newTree: WorkspaceNodeMeta[]) => {
|
||||
setTree(newTree)
|
||||
}, [])
|
||||
|
||||
const handleAddNode = useCallback(
|
||||
(kind: WorkspaceNodeKind, parentId?: WorkspaceNodeId | null, title?: string) => {
|
||||
nodeCounter += 1
|
||||
const prefix = kind === 'page' ? 'page' : kind === 'scene' ? 'scene' : 'folder'
|
||||
const id = `${prefix}_${Date.now()}_${nodeCounter}`
|
||||
const siblings = tree.filter((n) => n.parentId === (parentId ?? null))
|
||||
const maxPos = siblings.reduce((max, n) => Math.max(max, n.position), -1)
|
||||
const defaultTitle = kind === 'page' ? 'Untitled' : kind === 'scene' ? `Scene ${tree.filter((n) => n.kind === 'scene').length + 1}` : 'New Folder'
|
||||
const node: WorkspaceNodeMeta = {
|
||||
id,
|
||||
title: title ?? defaultTitle,
|
||||
kind,
|
||||
parentId: parentId ?? null,
|
||||
position: maxPos + 1,
|
||||
}
|
||||
setTree((prev) => [...prev, node])
|
||||
if (kind !== 'folder') {
|
||||
handleSelectItem(id)
|
||||
}
|
||||
return id
|
||||
},
|
||||
[tree, handleSelectItem]
|
||||
)
|
||||
|
||||
const handleRenameNode = useCallback(
|
||||
(id: WorkspaceNodeId, title: string) => {
|
||||
setTree((prev) => prev.map((n) => (n.id === id ? { ...n, title } : n)))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDeleteNode = useCallback(
|
||||
(id: WorkspaceNodeId) => {
|
||||
if (!recollectionId) return
|
||||
// Collect this node and all descendants
|
||||
const toDelete = new Set<string>()
|
||||
const collect = (nodeId: string) => {
|
||||
toDelete.add(nodeId)
|
||||
for (const child of tree.filter((n) => n.parentId === nodeId)) {
|
||||
collect(child.id)
|
||||
}
|
||||
}
|
||||
collect(id)
|
||||
|
||||
// Clean up storage for deleted nodes
|
||||
for (const nodeId of toDelete) {
|
||||
const node = tree.find((n) => n.id === nodeId)
|
||||
if (!node) continue
|
||||
if (node.kind === 'page') removeLogosPageContent(recollectionId, nodeId)
|
||||
if (node.kind === 'scene') removeSceneGraph(recollectionId, nodeId)
|
||||
}
|
||||
|
||||
setTree((prev) => {
|
||||
const next = prev.filter((n) => !toDelete.has(n.id))
|
||||
// If we deleted the active item, select the first remaining content node
|
||||
if (activeItemId && toDelete.has(activeItemId) && next.length > 0) {
|
||||
const firstContent = next.find((n) => n.kind === 'page' || n.kind === 'scene')
|
||||
if (firstContent) handleSelectItem(firstContent.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[recollectionId, tree, activeItemId, handleSelectItem]
|
||||
)
|
||||
|
||||
const value: WorkspaceTreeContextValue = {
|
||||
tree,
|
||||
setTree,
|
||||
activeItemId,
|
||||
activeItemKind,
|
||||
handleSelectItem,
|
||||
handleTreeChange,
|
||||
handleAddNode,
|
||||
handleRenameNode,
|
||||
handleDeleteNode,
|
||||
}
|
||||
|
||||
return <WorkspaceTreeContext.Provider value={value}>{children}</WorkspaceTreeContext.Provider>
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compat shim: useRecollectionSidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RecollectionSidebarContextValue = {
|
||||
tree: LogosPageMeta[]
|
||||
activePageId: string | null
|
||||
setTree: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
|
||||
handleSelectPage: (id: string) => void
|
||||
handleTreeChange: (tree: WorkspaceNodeMeta[]) => void
|
||||
handleDeletePage: (pageId: string) => void
|
||||
}
|
||||
|
||||
export function useRecollectionSidebar(): RecollectionSidebarContextValue {
|
||||
const ctx = useWorkspaceTree()
|
||||
return useMemo(() => ({
|
||||
tree: ctx.tree.filter((n) => n.kind === 'page').map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
parentId: n.parentId,
|
||||
position: n.position,
|
||||
})),
|
||||
activePageId: ctx.activeItemKind === 'page' ? ctx.activeItemId : null,
|
||||
setTree: ctx.setTree,
|
||||
handleSelectPage: ctx.handleSelectItem,
|
||||
handleTreeChange: ctx.handleTreeChange,
|
||||
handleDeletePage: ctx.handleDeleteNode,
|
||||
}), [ctx])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compat shim: useFluxScenes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FluxSceneContextValue = {
|
||||
scenes: FluxSceneMeta[]
|
||||
activeSceneId: FluxSceneId
|
||||
setScenes: React.Dispatch<React.SetStateAction<WorkspaceNodeMeta[]>>
|
||||
handleSelectScene: (id: FluxSceneId) => void
|
||||
handleAddScene: (title?: string) => FluxSceneId
|
||||
handleRenameScene: (id: FluxSceneId, title: string) => void
|
||||
handleDeleteScene: (id: FluxSceneId) => void
|
||||
handleReorderScenes: (scenes: FluxSceneMeta[]) => void
|
||||
}
|
||||
|
||||
export function useFluxScenes(): FluxSceneContextValue {
|
||||
const ctx = useWorkspaceTree()
|
||||
return useMemo(() => ({
|
||||
scenes: ctx.tree.filter((n) => n.kind === 'scene').map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
position: n.position,
|
||||
})),
|
||||
activeSceneId: ctx.activeItemKind === 'scene' && ctx.activeItemId ? ctx.activeItemId : DEFAULT_SCENE_ID,
|
||||
setScenes: ctx.setTree,
|
||||
handleSelectScene: ctx.handleSelectItem,
|
||||
handleAddScene: (title?: string) => ctx.handleAddNode('scene', null, title),
|
||||
handleRenameScene: ctx.handleRenameNode,
|
||||
handleDeleteScene: ctx.handleDeleteNode,
|
||||
handleReorderScenes: () => {},
|
||||
}), [ctx])
|
||||
}
|
||||
|
||||
export function useOptionalFluxScenes(): FluxSceneContextValue | null {
|
||||
const ctx = useContext(WorkspaceTreeContext)
|
||||
if (!ctx) return null
|
||||
return useFluxScenes()
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuContro
|
||||
import { BlockNoteView } from '@blocknote/shadcn'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { useRecollectionActions } from '../layout/RecollectionActionsContext'
|
||||
import { useRecollectionSidebar } from '../layout/RecollectionSidebarContext'
|
||||
import { useRecollectionSidebar } from '../layout/WorkspaceTreeContext'
|
||||
import {
|
||||
getLogosContentForPage,
|
||||
setLogosContentForPage,
|
||||
|
||||
@@ -47,16 +47,61 @@ export function formatTimeSinceLastUpdate(updatedAt: number | undefined): string
|
||||
export const RECOLLECTION_FILE_EXT = '.zui.json'
|
||||
export const RECOLLECTION_VERSION = 1
|
||||
|
||||
/** Id for a Flux scene. */
|
||||
export type FluxSceneId = string
|
||||
|
||||
/** Metadata for one scene in the Flux scene tree. */
|
||||
export type FluxSceneMeta = {
|
||||
id: FluxSceneId
|
||||
title: string
|
||||
/** Order among scenes. */
|
||||
position: number
|
||||
}
|
||||
|
||||
export const DEFAULT_SCENE_ID: FluxSceneId = '_default'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified Workspace Tree (pages + scenes + folders in one tree)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type WorkspaceNodeKind = 'page' | 'scene' | 'folder'
|
||||
export type WorkspaceNodeId = string
|
||||
|
||||
/** Unified node in the workspace tree. Replaces separate LogosPageMeta and FluxSceneMeta. */
|
||||
export type WorkspaceNodeMeta = {
|
||||
id: WorkspaceNodeId
|
||||
title: string
|
||||
kind: WorkspaceNodeKind
|
||||
parentId: WorkspaceNodeId | null
|
||||
position: number
|
||||
}
|
||||
|
||||
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||
const FLUX_SCENE_TREE_PREFIX = 'zui_flux_scenes_'
|
||||
const FLUX_SCENE_GRAPH_PREFIX = 'zui_flux_scene_'
|
||||
const WORKSPACE_TREE_PREFIX = 'zui_workspace_tree_'
|
||||
const LOGOS_KEY_PREFIX = 'zui_logos_'
|
||||
const LOGOS_PAGE_TREE_PREFIX = 'zui_logos_pagetree_'
|
||||
const LOGOS_PAGE_CONTENT_PREFIX = 'zui_logos_page_'
|
||||
const RENDER_CACHE_KEY_PREFIX = 'zui_render_cache_'
|
||||
|
||||
/** Legacy single-graph key (pre-scenes). */
|
||||
function getGraphKey(recollectionId: string): string {
|
||||
return `${GRAPH_KEY_PREFIX}${recollectionId}`
|
||||
}
|
||||
|
||||
function getFluxSceneTreeKey(recollectionId: string): string {
|
||||
return `${FLUX_SCENE_TREE_PREFIX}${recollectionId}`
|
||||
}
|
||||
|
||||
function getFluxSceneGraphKey(recollectionId: string, sceneId: FluxSceneId): string {
|
||||
return `${FLUX_SCENE_GRAPH_PREFIX}${recollectionId}_${sceneId}`
|
||||
}
|
||||
|
||||
function getWorkspaceTreeKey(recollectionId: string): string {
|
||||
return `${WORKSPACE_TREE_PREFIX}${recollectionId}`
|
||||
}
|
||||
|
||||
function getLogosKey(recollectionId: string): string {
|
||||
return `${LOGOS_KEY_PREFIX}${recollectionId}`
|
||||
}
|
||||
@@ -155,6 +200,168 @@ export function setGraph(recollectionId: string, state: StoredGraphState): void
|
||||
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flux Scene Storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Scene tree: ordered list of scene metas. */
|
||||
export function getFluxSceneTree(recollectionId: string): FluxSceneMeta[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(getFluxSceneTreeKey(recollectionId))
|
||||
if (!raw) return []
|
||||
const data = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(data)) return []
|
||||
return data.filter(
|
||||
(item): item is FluxSceneMeta =>
|
||||
item != null &&
|
||||
typeof item === 'object' &&
|
||||
typeof (item as FluxSceneMeta).id === 'string' &&
|
||||
typeof (item as FluxSceneMeta).title === 'string' &&
|
||||
typeof (item as FluxSceneMeta).position === 'number'
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function setFluxSceneTree(recollectionId: string, tree: FluxSceneMeta[]): void {
|
||||
localStorage.setItem(getFluxSceneTreeKey(recollectionId), JSON.stringify(tree))
|
||||
}
|
||||
|
||||
/** Per-scene graph data. */
|
||||
export function getSceneGraph(recollectionId: string, sceneId: FluxSceneId): StoredGraphState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(getFluxSceneGraphKey(recollectionId, sceneId))
|
||||
if (!raw) return null
|
||||
const data = JSON.parse(raw) as unknown
|
||||
if (
|
||||
!data ||
|
||||
typeof data !== 'object' ||
|
||||
!Array.isArray((data as StoredGraphState).nodes) ||
|
||||
!Array.isArray((data as StoredGraphState).edges)
|
||||
)
|
||||
return null
|
||||
return data as StoredGraphState
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setSceneGraph(recollectionId: string, sceneId: FluxSceneId, state: StoredGraphState): void {
|
||||
localStorage.setItem(getFluxSceneGraphKey(recollectionId, sceneId), JSON.stringify(state))
|
||||
}
|
||||
|
||||
export function removeSceneGraph(recollectionId: string, sceneId: FluxSceneId): void {
|
||||
localStorage.removeItem(getFluxSceneGraphKey(recollectionId, sceneId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize scene tree for a recollection. If no scenes exist, migrate
|
||||
* the legacy single graph into a '_default' scene.
|
||||
*/
|
||||
export function ensureFluxSceneTree(recollectionId: string): FluxSceneMeta[] {
|
||||
let tree = getFluxSceneTree(recollectionId)
|
||||
if (tree.length > 0) return tree
|
||||
|
||||
// Migrate legacy single graph to default scene
|
||||
const legacyGraph = getGraph(recollectionId)
|
||||
const defaultScene: FluxSceneMeta = { id: DEFAULT_SCENE_ID, title: 'Main', position: 0 }
|
||||
tree = [defaultScene]
|
||||
setFluxSceneTree(recollectionId, tree)
|
||||
|
||||
if (legacyGraph) {
|
||||
setSceneGraph(recollectionId, DEFAULT_SCENE_ID, legacyGraph)
|
||||
}
|
||||
|
||||
return tree
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unified Workspace Tree Storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getWorkspaceTree(recollectionId: string): WorkspaceNodeMeta[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(getWorkspaceTreeKey(recollectionId))
|
||||
if (!raw) return []
|
||||
const data = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(data)) return []
|
||||
return data.filter(
|
||||
(item): item is WorkspaceNodeMeta =>
|
||||
item != null &&
|
||||
typeof item === 'object' &&
|
||||
typeof (item as WorkspaceNodeMeta).id === 'string' &&
|
||||
typeof (item as WorkspaceNodeMeta).title === 'string' &&
|
||||
typeof (item as WorkspaceNodeMeta).kind === 'string' &&
|
||||
((item as WorkspaceNodeMeta).parentId === null || typeof (item as WorkspaceNodeMeta).parentId === 'string') &&
|
||||
typeof (item as WorkspaceNodeMeta).position === 'number'
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function setWorkspaceTree(recollectionId: string, tree: WorkspaceNodeMeta[]): void {
|
||||
localStorage.setItem(getWorkspaceTreeKey(recollectionId), JSON.stringify(tree))
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize workspace tree for a recollection. Migrates existing separate
|
||||
* page tree + scene tree into a unified workspace tree on first access.
|
||||
*/
|
||||
export function ensureWorkspaceTree(recollectionId: string): WorkspaceNodeMeta[] {
|
||||
const existing = getWorkspaceTree(recollectionId)
|
||||
if (existing.length > 0) return existing
|
||||
|
||||
const merged: WorkspaceNodeMeta[] = []
|
||||
|
||||
// Migrate Logos pages
|
||||
let pages = getLogosPageTree(recollectionId)
|
||||
if (pages.length === 0) {
|
||||
// Check for legacy single-document Logos content
|
||||
const legacy = getLogosContent(recollectionId)
|
||||
const mainPage: LogosPageMeta = { id: 'main', title: 'Main', parentId: null, position: 0 }
|
||||
setLogosContentForPage(recollectionId, 'main', legacy ?? [])
|
||||
setLogosPageTree(recollectionId, [mainPage])
|
||||
pages = [mainPage]
|
||||
}
|
||||
for (const page of pages) {
|
||||
merged.push({
|
||||
id: page.id,
|
||||
title: page.title,
|
||||
kind: 'page',
|
||||
parentId: page.parentId,
|
||||
position: page.position,
|
||||
})
|
||||
}
|
||||
|
||||
// Migrate Flux scenes
|
||||
const scenes = getFluxSceneTree(recollectionId)
|
||||
if (scenes.length === 0) {
|
||||
// Migrate legacy single graph to default scene
|
||||
const legacyGraph = getGraph(recollectionId)
|
||||
const defaultScene: WorkspaceNodeMeta = { id: DEFAULT_SCENE_ID, title: 'Main', kind: 'scene', parentId: null, position: merged.length }
|
||||
merged.push(defaultScene)
|
||||
if (legacyGraph) {
|
||||
setSceneGraph(recollectionId, DEFAULT_SCENE_ID, legacyGraph)
|
||||
}
|
||||
} else {
|
||||
const maxRootPos = merged.filter((n) => n.parentId === null).reduce((max, n) => Math.max(max, n.position), -1)
|
||||
for (const scene of scenes) {
|
||||
merged.push({
|
||||
id: scene.id,
|
||||
title: scene.title,
|
||||
kind: 'scene',
|
||||
parentId: null,
|
||||
position: maxRootPos + 1 + scene.position,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
setWorkspaceTree(recollectionId, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logos Storage
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -270,14 +477,25 @@ export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOut
|
||||
// Remove Recollection Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */
|
||||
/** Removes all data for the recollection: graph, scenes, logos, and render cache. */
|
||||
export function removeRecollectionData(recollectionId: string): void {
|
||||
// Legacy graph
|
||||
localStorage.removeItem(getGraphKey(recollectionId))
|
||||
// Flux scenes
|
||||
const scenes = getFluxSceneTree(recollectionId)
|
||||
for (const scene of scenes) {
|
||||
removeSceneGraph(recollectionId, scene.id)
|
||||
}
|
||||
localStorage.removeItem(getFluxSceneTreeKey(recollectionId))
|
||||
// Logos
|
||||
localStorage.removeItem(getLogosKey(recollectionId))
|
||||
const tree = getLogosPageTree(recollectionId)
|
||||
for (const page of tree) {
|
||||
const logosTree = getLogosPageTree(recollectionId)
|
||||
for (const page of logosTree) {
|
||||
removeLogosPageContent(recollectionId, page.id)
|
||||
}
|
||||
localStorage.removeItem(getLogosPageTreeKey(recollectionId))
|
||||
// Workspace tree
|
||||
localStorage.removeItem(getWorkspaceTreeKey(recollectionId))
|
||||
// Render cache
|
||||
localStorage.removeItem(getRenderCacheKey(recollectionId))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useContext } from "react";
|
||||
import { FlowUIContext } from "@/lib/graph/flowContext";
|
||||
import { useConnectionPathRoleFromStore } from "@/app/canvas/useCanvasConnectionPathFromStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeRunStatusOverlay } from "./NodeRunStatusOverlay";
|
||||
|
||||
/** Default min size for resizable nodes (used by NodeResizer). */
|
||||
export const RESIZE_MIN_WIDTH = 120;
|
||||
@@ -102,6 +103,7 @@ export function BaseNode({
|
||||
{!isFullscreenInstance && (
|
||||
<div className={cn(!selected && "pointer-events-none")}>{handles}</div>
|
||||
)}
|
||||
{nodeId && <NodeRunStatusOverlay nodeId={nodeId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GraphContext } from '@/lib/graph/flowContext'
|
||||
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
|
||||
import { NodeHelpPopover } from '@/components/graph/NodeHelpPopover'
|
||||
import { getNodeType, getNodeClassificationLabel } from '@/lib/graph/nodeRegistry'
|
||||
import { NodeRunStatusBadge } from './NodeRunStatusOverlay'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
@@ -55,6 +56,7 @@ export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props)
|
||||
<span className="shrink-0" title="Node classification">{classificationLabel}</span>
|
||||
</>
|
||||
)}
|
||||
<NodeRunStatusBadge nodeId={nodeId} />
|
||||
<span className="shrink-0 ml-auto">
|
||||
<NodeHelpPopover nodeType={nodeType} />
|
||||
</span>
|
||||
|
||||
84
frontend/src/components/graph/NodeRunStatusOverlay.tsx
Normal file
84
frontend/src/components/graph/NodeRunStatusOverlay.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Run status indicator for nodes. Two exports:
|
||||
* - NodeRunStatusBadge: inline badge for node footers (primary integration point)
|
||||
* - NodeRunStatusOverlay: subtle border overlay for running/pending/failed states
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { useRunStore, type NodeRunStatus } from '@/lib/graph/runStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CheckCircle2, Loader2, XCircle, Clock } from 'lucide-react'
|
||||
|
||||
const badgeConfig: Record<NodeRunStatus, { icon: React.ReactNode; label: string; className: string }> = {
|
||||
pending: {
|
||||
icon: <Clock className="size-3" />,
|
||||
label: 'Pending',
|
||||
className: 'text-muted-foreground',
|
||||
},
|
||||
running: {
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
label: 'Running',
|
||||
className: 'text-blue-500',
|
||||
},
|
||||
completed: {
|
||||
icon: <CheckCircle2 className="size-3" />,
|
||||
label: 'Done',
|
||||
className: 'text-emerald-500',
|
||||
},
|
||||
failed: {
|
||||
icon: <XCircle className="size-3" />,
|
||||
label: 'Failed',
|
||||
className: 'text-destructive',
|
||||
},
|
||||
}
|
||||
|
||||
/** Inline badge for node footers — shows run status next to edge indicators. */
|
||||
export function NodeRunStatusBadge({ nodeId }: { nodeId: string }) {
|
||||
const nodeState = useRunStore((s) => s.nodeStates[nodeId])
|
||||
const runStatus = useRunStore((s) => s.status)
|
||||
|
||||
if (runStatus === 'idle') return null
|
||||
if (!nodeState) return null
|
||||
|
||||
const { icon, label, className } = badgeConfig[nodeState.status]
|
||||
|
||||
return (
|
||||
<span className={cn('flex items-center gap-1 shrink-0', className)} title={label}>
|
||||
{icon}
|
||||
<span className="text-[10px]">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const overlayBorder: Record<NodeRunStatus, string> = {
|
||||
pending: 'border-muted-foreground/20',
|
||||
running: 'border-blue-500/40',
|
||||
completed: 'border-transparent',
|
||||
failed: 'border-destructive/40',
|
||||
}
|
||||
|
||||
/** Subtle border overlay — only visible for running/pending/failed. */
|
||||
export function NodeRunStatusOverlay({ nodeId }: { nodeId: string }) {
|
||||
const nodeState = useRunStore((s) => s.nodeStates[nodeId])
|
||||
const runStatus = useRunStore((s) => s.status)
|
||||
|
||||
if (runStatus === 'idle') return null
|
||||
if (!nodeState) return null
|
||||
// No overlay needed for completed — the footer badge is enough
|
||||
if (nodeState.status === 'completed') return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-10 pointer-events-none rounded-md border-2 transition-colors duration-300',
|
||||
overlayBorder[nodeState.status]
|
||||
)}
|
||||
>
|
||||
{nodeState.error && (
|
||||
<div className="absolute bottom-0 left-0 right-0 truncate rounded-b-md bg-destructive/90 px-2 py-0.5 text-[10px] text-destructive-foreground">
|
||||
{nodeState.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,17 +22,9 @@ import {
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
} from '@/components/ui/menubar'
|
||||
import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react'
|
||||
import { Sparkles, Loader2, RotateCw } from 'lucide-react'
|
||||
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import {
|
||||
useRenderingNodeState,
|
||||
@@ -165,95 +157,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
: undefined
|
||||
}
|
||||
right={
|
||||
state.incomingIds.length > 0 ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ButtonGroup className="nodrag nopan">
|
||||
{state.effectiveUpdateMode === 'manual' ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={state.loading || !state.hasPendingInputs}
|
||||
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
state.incrementRunTrigger()
|
||||
}}
|
||||
title={
|
||||
state.hasPendingInputs
|
||||
? 'Inputs changed — click to render'
|
||||
: !state.hasPendingInputs
|
||||
? 'No new data to render'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{state.loading ? (
|
||||
<Loader2 className="size-3.5 animate-spin shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Play className="size-3.5 shrink-0" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
) : state.loading ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
|
||||
aria-label="Updating"
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin shrink-0" aria-hidden />
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`h-7 min-w-[4.5rem] gap-1 pl-2 pr-1.5 text-xs font-normal ${state.effectiveUpdateMode === 'manual' || state.loading ? 'rounded-l-none' : 'rounded-l-md'}`}
|
||||
aria-label="Update mode"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{state.effectiveUpdateMode === 'manual' ? 'Manual' : 'Auto'}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
When to re-render
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={state.effectiveUpdateMode === 'auto'}
|
||||
onCheckedChange={(checked) =>
|
||||
checked && state.setUpdateMode('auto')
|
||||
}
|
||||
className="flex flex-col items-start gap-0.5 py-2"
|
||||
>
|
||||
<span className="font-medium">Auto</span>
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
Re-renders when upstream content changes
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={state.effectiveUpdateMode === 'manual'}
|
||||
onCheckedChange={(checked) =>
|
||||
checked && state.setUpdateMode('manual')
|
||||
}
|
||||
className="flex flex-col items-start gap-0.5 py-2"
|
||||
>
|
||||
<span className="font-medium">Manual</span>
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
Re-renders only when you click Run
|
||||
</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
state.incomingIds.length > 0 && state.loading ? (
|
||||
<Loader2 className="size-3.5 animate-spin shrink-0 text-muted-foreground" aria-label="Rendering" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph/renderingSignatures'
|
||||
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
|
||||
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||
import { useRunStore } from '@/lib/graph/runStore'
|
||||
|
||||
export type OutputMode = 'image' | 'string'
|
||||
|
||||
@@ -207,8 +208,11 @@ export function useRenderingNodeState(
|
||||
() => (srcNode?.type ? getSourceRenderingLogic(srcNode.type as string) : null),
|
||||
[srcNode?.type]
|
||||
)
|
||||
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual'
|
||||
const runTrigger = data?.runTrigger ?? 0
|
||||
// All rendering is now triggered by the global Run button — no auto/manual distinction.
|
||||
const effectiveUpdateMode: 'auto' | 'manual' = 'manual'
|
||||
// Use global run trigger from runStore instead of per-node trigger
|
||||
const globalRunTrigger = useRunStore((s) => s.globalRunTrigger)
|
||||
const runTrigger = globalRunTrigger
|
||||
const outputMode: OutputMode = (data?.outputMode ?? 'image') as OutputMode
|
||||
const setOutputMode = useCallback(
|
||||
(mode: OutputMode) => updateData({ outputMode: mode }),
|
||||
@@ -330,7 +334,7 @@ export function useRenderingNodeState(
|
||||
if (!hasCachedOutput) {
|
||||
setRenderedContent(null)
|
||||
setResolvedContent(null)
|
||||
setError({ kind: 'no-content', message: 'Click Run to render.' })
|
||||
setError({ kind: 'no-content', message: 'Press Run (⌘↵) to render.' })
|
||||
setLoading(false)
|
||||
}
|
||||
return
|
||||
|
||||
110
frontend/src/hooks/useRunStream.ts
Normal file
110
frontend/src/hooks/useRunStream.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* SSE subscriber hook for graph run execution.
|
||||
* Connects to GET /api/runs/:id/stream and updates runStore.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useRunStore } from '@/lib/graph/runStore'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
|
||||
export function useRunStream() {
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const { startRun, setRunStatus, setNodeStatus, appendChunk } = useRunStore()
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connectToRun = useCallback(
|
||||
(runId: string) => {
|
||||
disconnect()
|
||||
startRun(runId)
|
||||
|
||||
const es = new EventSource(`/api/runs/${runId}/stream`)
|
||||
eventSourceRef.current = es
|
||||
|
||||
es.addEventListener('run/started', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
setRunStatus('running')
|
||||
// Initialize all nodes as pending so overlays appear immediately
|
||||
const nodeIds = data.nodeIds as string[] | undefined
|
||||
if (nodeIds) {
|
||||
for (const nodeId of nodeIds) {
|
||||
setNodeStatus(nodeId, { status: 'pending' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('run/completed', () => {
|
||||
setRunStatus('completed')
|
||||
es.close()
|
||||
})
|
||||
|
||||
es.addEventListener('run/failed', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
setRunStatus('failed', data.error)
|
||||
es.close()
|
||||
})
|
||||
|
||||
es.addEventListener('step/started', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
setNodeStatus(data.nodeId, { status: 'running' })
|
||||
// Fire trail animation along edges leading to this node
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: data.nodeId })
|
||||
})
|
||||
|
||||
es.addEventListener('step/completed', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
setNodeStatus(data.nodeId, { status: 'completed', output: data.output })
|
||||
})
|
||||
|
||||
es.addEventListener('step/failed', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
setNodeStatus(data.nodeId, { status: 'failed', error: data.error })
|
||||
})
|
||||
|
||||
es.addEventListener('step/chunk', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
appendChunk(data.nodeId, data.chunk)
|
||||
})
|
||||
|
||||
es.onerror = () => {
|
||||
setRunStatus('failed', 'Connection lost')
|
||||
es.close()
|
||||
}
|
||||
},
|
||||
[disconnect, startRun, setRunStatus, setNodeStatus, appendChunk]
|
||||
)
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => disconnect, [disconnect])
|
||||
|
||||
return { connectToRun, disconnect }
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a new run: POST the graph, then connect SSE.
|
||||
*/
|
||||
export async function createAndStreamRun(
|
||||
recollectionId: string,
|
||||
graph: { nodes: unknown[]; edges: unknown[] },
|
||||
connectToRun: (runId: string) => void,
|
||||
sceneId?: string
|
||||
): Promise<void> {
|
||||
const res = await fetch('/api/runs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recollectionId, sceneId, graph }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Failed to create run' }))
|
||||
throw new Error(err.error ?? 'Failed to create run')
|
||||
}
|
||||
|
||||
const { id } = await res.json()
|
||||
connectToRun(id)
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
|
||||
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
||||
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData()
|
||||
* also marks this node as a connection-path trigger so edges update on data changes.
|
||||
* also marks the run state as dirty so the Run button shows pending changes.
|
||||
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
|
||||
*
|
||||
* **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should
|
||||
@@ -17,9 +17,23 @@
|
||||
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import { GraphContext } from './flowContext'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
import { nodePropsAreEqual } from './flowUtils'
|
||||
import type { AppNode } from './nodeTypes'
|
||||
import { useRunStore } from './runStore'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Data keys written by the render pipeline cache — these should NOT mark the run state as dirty. */
|
||||
const CACHE_DATA_KEYS = new Set([
|
||||
'cachedRenderedContent',
|
||||
'cachedResolvedContent',
|
||||
'cachedReasoningContent',
|
||||
'cachedOutputValue',
|
||||
'lastRunSourceSignature',
|
||||
'outputMarkdown',
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -87,6 +101,7 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
const setNodes = graphCtx?.setNodes
|
||||
const setEdges = graphCtx?.setEdges
|
||||
|
||||
const markDirty = useRunStore((s) => s.markDirty)
|
||||
const updateData = useCallback(
|
||||
(partial: Partial<TData>) => {
|
||||
if (!setNodes) return
|
||||
@@ -95,9 +110,12 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||
) as AppNode[]
|
||||
)
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||
// Only mark dirty for user-facing changes, not internal cache writes from the render pipeline
|
||||
const keys = Object.keys(partial)
|
||||
const isCacheOnly = keys.length > 0 && keys.every((k) => CACHE_DATA_KEYS.has(k))
|
||||
if (!isCacheOnly) markDirty()
|
||||
},
|
||||
[id, setNodes]
|
||||
[id, setNodes, markDirty]
|
||||
)
|
||||
|
||||
const incomingEdges = useMemo(
|
||||
|
||||
99
frontend/src/lib/graph/runStore.ts
Normal file
99
frontend/src/lib/graph/runStore.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Run execution state (Zustand). Tracks active run status and per-node execution state.
|
||||
* Separate from canvasStore to avoid coupling graph editing with run state.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type NodeRunStatus = 'pending' | 'running' | 'completed' | 'failed'
|
||||
export type RunStatus = 'idle' | 'pending' | 'running' | 'completed' | 'failed'
|
||||
|
||||
export type NodeStepState = {
|
||||
status: NodeRunStatus
|
||||
output?: string
|
||||
error?: string
|
||||
chunk?: string
|
||||
}
|
||||
|
||||
export type RunState = {
|
||||
/** Current run ID (null when no run is active) */
|
||||
activeRunId: string | null
|
||||
/** Overall run status */
|
||||
status: RunStatus
|
||||
/** Per-node execution state, keyed by node ID */
|
||||
nodeStates: Record<string, NodeStepState>
|
||||
/** Error message if the run failed */
|
||||
error: string | null
|
||||
/** Whether the graph has changed since the last run */
|
||||
dirty: boolean
|
||||
/** Global trigger counter — render nodes subscribe to this to trigger their pipeline */
|
||||
globalRunTrigger: number
|
||||
}
|
||||
|
||||
type RunActions = {
|
||||
startRun: (runId: string) => void
|
||||
setRunStatus: (status: RunStatus, error?: string) => void
|
||||
setNodeStatus: (nodeId: string, state: Partial<NodeStepState>) => void
|
||||
appendChunk: (nodeId: string, chunk: string) => void
|
||||
markDirty: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const initialState: RunState = {
|
||||
activeRunId: null,
|
||||
status: 'idle',
|
||||
nodeStates: {},
|
||||
error: null,
|
||||
dirty: true,
|
||||
globalRunTrigger: 0,
|
||||
}
|
||||
|
||||
export const useRunStore = create<RunState & RunActions>((set) => ({
|
||||
...initialState,
|
||||
|
||||
startRun: (runId) =>
|
||||
set((s) => ({
|
||||
activeRunId: runId,
|
||||
status: 'pending',
|
||||
nodeStates: {},
|
||||
error: null,
|
||||
dirty: false,
|
||||
globalRunTrigger: s.globalRunTrigger + 1,
|
||||
})),
|
||||
|
||||
setRunStatus: (status, error) =>
|
||||
set({ status, error: error ?? null }),
|
||||
|
||||
setNodeStatus: (nodeId, partial) =>
|
||||
set((s) => ({
|
||||
nodeStates: {
|
||||
...s.nodeStates,
|
||||
[nodeId]: { ...s.nodeStates[nodeId], ...partial } as NodeStepState,
|
||||
},
|
||||
})),
|
||||
|
||||
appendChunk: (nodeId, chunk) =>
|
||||
set((s) => {
|
||||
const prev = s.nodeStates[nodeId]
|
||||
return {
|
||||
nodeStates: {
|
||||
...s.nodeStates,
|
||||
[nodeId]: {
|
||||
...prev,
|
||||
chunk: (prev?.chunk ?? '') + chunk,
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
markDirty: () => set((s) => {
|
||||
// Clear completed/failed overlays when graph changes — stale results no longer meaningful
|
||||
const isFinished = s.status === 'completed' || s.status === 'failed'
|
||||
return {
|
||||
dirty: true,
|
||||
...(isFinished ? { nodeStates: {}, status: 'idle' as RunStatus } : {}),
|
||||
}
|
||||
}),
|
||||
|
||||
reset: () => set((s) => ({ ...initialState, globalRunTrigger: s.globalRunTrigger })),
|
||||
}))
|
||||
@@ -418,6 +418,7 @@ pre {
|
||||
.react-arborist-tree {
|
||||
background: transparent !important;
|
||||
font-size: 0.875rem;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.react-arborist-tree * {
|
||||
|
||||
@@ -16,6 +16,14 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/runs': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/recollections': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// Kroki diagram service
|
||||
'/api/kroki': {
|
||||
target: 'https://kroki.io',
|
||||
|
||||
Reference in New Issue
Block a user