Compare commits

...

125 Commits

Author SHA1 Message Date
223336c606 fix: improvements 2026-03-28 22:20:19 +01:00
cbd8f1568b fix: documentation 2026-03-20 10:48:06 +01:00
166c7b0b7f feat: improve sidebar 2026-03-20 10:20:51 +01:00
2635b45973 feat: sidebar tree 2026-03-20 08:49:00 +01:00
c3cbe35883 fIx: smaller nits 2026-03-20 00:04:46 +01:00
bc67d01bc2 feat: small improvements 2026-03-16 22:49:29 +01:00
96cb3701ba feat: redesign navs 2026-03-16 22:28:30 +01:00
5bce586db6 feat: refactor katalogos 2026-03-16 21:29:09 +01:00
febca67c9b fix: hover 2026-03-16 20:41:59 +01:00
9076a45510 fix: broken build 2026-03-16 20:40:26 +01:00
be0f743970 fix: defaults 2026-03-16 16:40:50 +01:00
3de9a33074 feat: multiple pages 2026-03-16 16:33:11 +01:00
9d38b1df2b feat: artifacts 2026-03-16 15:25:22 +01:00
9f56b728c0 feat: refactoeing 2026-03-16 09:51:45 +01:00
f6193bf180 freat: initroduce katalogos 2026-03-16 09:30:11 +01:00
e64e29d773 feat: switcher 2026-03-16 00:35:05 +01:00
7c7d470f15 fix: font size 2026-03-16 00:21:43 +01:00
c2db0b33d6 feat: improvements 2026-03-16 00:15:36 +01:00
f67c491deb feat: flux to logos 2026-03-15 23:41:10 +01:00
45c387c9e6 feat: improvements 2026-03-15 22:08:31 +01:00
4d673ab556 fix: scroll events 2026-03-15 21:46:08 +01:00
290217791f feat: add logos editor 2026-03-15 21:12:05 +01:00
929359b4bc feat: refactor menus 2026-03-15 21:11:57 +01:00
a8bd579043 feat: flip card transition 2026-03-15 20:19:00 +01:00
202462bad7 feat: custom icons 2026-03-15 15:13:41 +01:00
449d2cb382 feat: logos and flux 2026-03-15 14:43:31 +01:00
04c039bdde freat: renaming 2026-03-15 14:11:11 +01:00
2326bbe035 feat: render image in markdown 2026-03-14 22:58:57 +01:00
ce58673d18 fix: performance 2026-03-14 22:39:38 +01:00
bcb9c5946d fix: several smaller improvements 2026-03-14 22:13:38 +01:00
a1ad4d52cf feat: autosave 2026-03-14 10:33:03 +01:00
78f5a51aa8 fix: tests 2026-03-14 08:02:40 +01:00
1ee078c350 feat: run button 2026-03-14 07:58:50 +01:00
b642a7aab2 feat: simplify paths 2026-03-14 07:52:34 +01:00
353c2db335 feat: meaningful labels by status 2026-03-13 23:35:34 +01:00
fc265266c7 feat: introdce store 2026-03-13 00:32:19 +01:00
2d8f13aebb nit 2026-03-13 00:19:14 +01:00
2ff7e1f5f2 fix: classes 2026-03-12 23:38:13 +01:00
4dca9c6478 feat: replace codemirror and image viewr, opt for simplier approach 2026-03-12 23:07:51 +01:00
b71d32da5e fix: remove backend placeholders 2026-03-12 21:10:00 +01:00
0578b26241 feat: save button 2026-03-12 21:06:47 +01:00
029bab8917 refactor: performance 2026-03-12 21:01:43 +01:00
92fdba7eef fix: edge animation 2026-03-12 20:45:40 +01:00
fb1df18256 refactor: markdown split form agent 2026-03-12 20:37:16 +01:00
35b2e9d538 fix: codemirror util 2026-03-12 20:23:39 +01:00
e9ed508bfc refactor: resize performance 2026-03-12 18:28:31 +01:00
a0bf9c6b70 refactor: improvements 2026-03-12 18:14:21 +01:00
51c96641f0 refactor: deta histroy 2026-03-12 18:08:54 +01:00
923bf4bff0 feat: performance 1 2026-03-12 18:01:27 +01:00
4bd2f1b458 refactoring: graph state 2026-03-12 17:40:05 +01:00
bfd1a40332 refactoring 2026-03-12 17:23:47 +01:00
f5b12949d3 refactoring 2026-03-12 17:14:33 +01:00
e29c5d643c refactoring 2026-03-12 17:07:42 +01:00
084863909a feat: refactoring rendering 2026-03-12 16:56:53 +01:00
79e4586ed1 feat: refactor rendering I 2026-03-12 16:24:24 +01:00
2a0507ca02 vieport fix 2026-03-12 15:34:14 +01:00
e4d58750d3 fix fullscren 2026-03-12 15:31:21 +01:00
909359ea53 feat: renderin imrpovements 2026-03-12 14:48:19 +01:00
f43e2df019 feat: rendering for agents 2026-03-12 13:05:52 +01:00
871fd346e3 fix: color 2026-03-12 12:36:37 +01:00
68804b95bb fix: styles 2026-03-12 12:35:22 +01:00
192358822e fix: styles 2026-03-12 12:32:27 +01:00
5f6f16ab90 fix: node button styles 2026-03-12 12:28:56 +01:00
57207fa082 fix: improve rendering menu 2026-03-12 12:22:47 +01:00
49a80e8d91 fix: imports 2026-03-12 11:29:26 +01:00
7a6a4a43c5 fix: folder org 2026-03-12 11:22:01 +01:00
4184a60706 lint: change folder names 2026-03-12 11:21:10 +01:00
86238b7efc refactor: nodes as builder pattern 2026-03-12 11:01:35 +01:00
302107e710 fix: style run 2026-03-12 10:09:12 +01:00
48dcf38e01 feat: error connections 2026-03-11 23:05:09 +01:00
e79821bd81 feat: connections improvements 2026-03-11 23:05:01 +01:00
3ef30bd58c fix: rendering animaiton 2026-03-11 22:20:21 +01:00
42676b2674 fix: color path 2026-03-11 22:18:01 +01:00
fddc6488ea fix: remove init constraint 2026-03-11 22:10:58 +01:00
1232d148e6 feat: ant path rendering on cahnges 2026-03-11 22:08:08 +01:00
ea6bdeb05f feat: fullscreen for data node 2026-03-11 20:31:43 +01:00
86d264e83a feat: full screen node 2026-03-11 20:22:32 +01:00
0b8c18a43f fix: Style 2026-03-11 17:43:37 +01:00
fc58dcdaa2 feat: refactor names 2026-03-11 17:24:32 +01:00
6471cee05f feat: menu refactoring 2026-03-11 17:16:13 +01:00
b92088f583 feat: add keroma 2026-03-11 17:07:27 +01:00
f201fe92f4 feat: agent node 2026-03-11 14:39:38 +01:00
94da42f9fc feat: canvas options 2026-03-11 14:17:12 +01:00
581cfca506 feat: Settings panel 2026-03-11 14:12:48 +01:00
9c0161b21d fix: minimap off by default 2026-03-11 13:46:23 +01:00
95e1c13dcd feat: minimap toggle 2026-03-11 13:44:48 +01:00
1cc6b98973 fix: VariableNode size 2026-03-11 13:39:49 +01:00
deb1be0d66 feat: improve mouse events 2026-03-11 10:27:17 +01:00
c330713fa7 fix: node resize 2026-03-11 00:38:15 +01:00
2873f13875 feat: contextual zoom 2026-03-11 00:30:10 +01:00
7c1039f4c6 fix: minor styles and ux 2026-03-11 00:21:37 +01:00
08d66b59c2 fix: nunjuclk rendering 2026-03-11 00:05:32 +01:00
6014f34f76 fix: aspec ratio of rendering 2026-03-10 23:59:04 +01:00
6545c4fc51 fix: card image vertical alignment 2026-03-10 23:53:05 +01:00
38e1ae3692 feat: add initial data node 2026-03-10 23:49:28 +01:00
dd1de7cb1e fix: border style 2026-03-10 22:51:49 +01:00
bfc8c560b2 fix: styles of border and copy 2026-03-10 22:49:07 +01:00
0c368f4b24 improve rendering raw 2026-03-10 22:44:58 +01:00
d5ddd762cd feat: fix viewport 2026-03-10 22:24:24 +01:00
30d62e878a feat: add the waters 2026-03-10 21:43:27 +01:00
87c46c25c6 feat: add node calssification 2026-03-10 21:34:39 +01:00
eed880e7ef feat: new projects start with empty state 2026-03-10 00:19:14 +01:00
80653b7fce feat: enhance platform context with recent project tracking and access recording 2026-03-10 00:16:37 +01:00
bb58727e04 feat: add navigation link to projects in CanvasMenubar and enhance AppSidebar with tooltip for expand button 2026-03-10 00:01:59 +01:00
3ad02cfb76 feat: integrate Radix UI toggle components and replace custom toast implementation with Sonner
- Added @radix-ui/react-toggle and @radix-ui/react-toggle-group to package dependencies.
- Implemented ToggleGroup and Toggle components for improved UI interaction.
- Replaced custom toast notifications with Sonner for better user feedback.
- Updated CanvasPage and ProjectsPage to utilize new toast notifications.
- Removed obsolete toast context and provider.
- Enhanced ProjectsPage with a toggle view for project display (cards/table).
2026-03-09 23:43:05 +01:00
57dfcb6458 feat: enhance project management with duplicate name checks and toast notifications
- Added duplicate name check in NewProjectDialog to warn users of existing project names.
- Integrated toast notifications for actions like project deletion and export in ProjectsPage.
- Implemented project ordering and restoration functionality in platform context.
- Updated ProjectsPage to include search, sorting, and pagination features.
- Created a new Toast component for displaying notifications with optional undo actions.
- Refactored main application entry to include ToastProvider for global toast management.
2026-03-09 22:36:14 +01:00
e441d36cf4 feat: implement project management features with routing, including project creation, deletion, renaming, and graph persistence 2026-03-09 22:10:32 +01:00
c73aaca45a Enhance AppSidebar: Update sidebar button to display "Zoë" with new styling and icon 2026-03-09 21:56:35 +01:00
eb1413322f feat: enhance connection validation and node creation logic, improve type handling in various components 2026-03-09 21:45:05 +01:00
649d866bef lint 2026-03-09 21:37:18 +01:00
4fcc8b4fdd fix: update border color for nodes and sidebar to align with design system 2026-03-09 21:24:40 +01:00
e789a0a164 feat: implement CanvasMenubar component and integrate keyboard shortcuts 2026-03-09 21:19:08 +01:00
4e51b6866f feat: add Sheet and Sidebar components for improved UI layout
- Introduced a new Sheet component for modal-like functionality with customizable content and animations.
- Implemented a Sidebar component with mobile responsiveness, state management, and keyboard shortcuts for toggling.
- Added Skeleton component for loading states and Tooltip component for enhanced user guidance.
- Created a useIsMobile hook to manage mobile view detection.
- Updated main entry point to render the new PlatformPage component.
- Enhanced styles for sidebar and scrollbar customization in CSS.
- Extended Tailwind configuration to include sidebar color variables for better theming.
2026-03-09 20:51:29 +01:00
b3c2c6711f feat: add node help system and registry for extensible node types
- Implemented a help system for different node types (config, render, variable, function) with detailed usage instructions.
- Created a node registry to manage node types, including registration, retrieval, and validation of connections between nodes.
- Defined central node and edge types for the application to streamline state management.
- Added Nunjucks autocomplete functionality to enhance user experience in template editing.
- Developed a syntax highlighting parser for PlantUML and Nunjucks within the CodeMirror editor.
- Registered built-in node types at application startup, including their default configurations and help entries.
- Introduced a theme context provider to manage light/dark mode preferences across the application.
- Created utility functions for class name management using clsx and tailwind-merge.
- Set up Tailwind CSS for styling with custom themes and responsive design.
- Configured Vite for development with proxy settings for backend API calls and Kroki diagram service.
2026-03-09 20:04:31 +01:00
11fd9cd54d improve labels 2026-03-09 16:26:02 +01:00
c596fc70eb shortcuts 2026-03-09 16:22:22 +01:00
40379161e0 iprov 2026-03-09 16:08:56 +01:00
c55bde60e6 devs 2026-03-09 15:56:02 +01:00
88629e6dcc performance 2026-03-09 15:50:11 +01:00
50d34bfb00 improve empty and initial state 2026-03-09 15:45:19 +01:00
2d6687b4ad improv 1 2026-03-09 15:31:34 +01:00
84e3647fb5 improve rendering 2026-03-09 14:56:54 +01:00
9565a425b6 refactor node 2026-03-09 14:11:33 +01:00
24efc597b2 refactor nodes 2026-03-09 14:04:13 +01:00
76039bdd0e help 2026-03-09 13:42:03 +01:00
207 changed files with 25442 additions and 3320 deletions

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
# Monorepo: each service builds from its own context (frontend/, backend/).
# This file applies to root-level builds only.
node_modules
**/node_modules
dist
**/dist
.git
.gitignore
*.md
.env*
.env.*
!.env.example
.idea
.vscode
*.log
.DS_Store
Thumbs.db
**/__tests__
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
coverage
.nyc_output

18
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# dependencies
/node_modules
node_modules
/.pnp
.pnp.js
@@ -9,14 +10,15 @@
# production build
/build
/dist
frontend/dist
# environment (keep .env.example if you add one)
.env
.env.*
!.env.example
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# logs
npm-debug.log*
@@ -38,3 +40,7 @@ Thumbs.db
.cache
.parcel-cache
# Docker (optional local overrides)
docker-compose.override.yml
docker-compose.override.yaml

View File

@@ -0,0 +1,8 @@
{
"hash": "dba24d0a",
"configHash": "beca0bba",
"lockfileHash": "85459ac0",
"browserHash": "3fd5087e",
"optimized": {},
"chunks": {}
}

3
.vite/deps/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

216
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,216 @@
# Architecture Overview
## 1. Executive Summary
Zui is a node-based visual editor for composing configs, templates, variables, and AI-generated content. The frontend is a React 18 SPA backed by a thin Express API for AI agent calls. All graph data is persisted in `localStorage`; the backend is stateless.
---
## 2. Technology Stack
| Layer | Technology |
| --- | --- |
| Frontend framework | React 18, TypeScript (strict), Vite 7 |
| Graph canvas | `@xyflow/react` v12 (React Flow) |
| State management | Zustand 5 (command/reducer pattern) |
| Styling | Tailwind CSS + Shadcn UI (`@radix-ui/*`) |
| Rich text | BlockNote 0.36 with custom blocks |
| Templating | Nunjucks |
| Graph traversal | `graphology` + `graphology-traversal` |
| Routing | React Router v6 |
| Backend | Node.js 20+, TypeScript, Express 4 |
| AI SDK | Vercel AI SDK v4 + `@ai-sdk/openai` |
| Testing | Vitest, React Testing Library |
---
## 3. Project Structure
```text
frontend/src/
├── main.tsx # Entry: registers node types & config types, mounts app
├── app/
│ ├── canvas/ # CanvasPage, canvasStore (Zustand), canvasStore.test.ts
│ ├── kosmos/ # KosmosPage, sidebar, AI settings, KosmosContext
│ └── recollections/
│ ├── RecollectionsPage # Workspace list
│ ├── RecollectionLayout # Shared layout (sidebar + outlet)
│ ├── flux/ # Flux view (graph canvas)
│ ├── logos/ # Logos view (BlockNote rich text)
│ └── katalogos/ # Katalogos view (artifact gallery)
├── components/
│ ├── graph/ # AnimatedEdge, BaseNode, handles, keyboard shortcuts
│ ├── nodes/ # One folder per node type
│ └── ui/ # Shadcn primitives
├── hooks/ # useGraphStateWithHistory, useResizeHeight, …
└── lib/
└── graph/
├── nodeRegistry.ts # Mutable plugin registry
├── registerBuiltinNodes.tsx
├── nodeTypeBuilder.ts # Fluent builder for node descriptors
├── configTypes.ts # Output type registry (plantuml, markdown, wireframe)
├── rendering.ts # 3-step rendering pipeline contract
├── state.ts # Graph state types
└── canvasStore/ # Reducer + selectors
backend/src/
├── index.ts # Express app, CORS, routes, error handler
├── routes/agentRoutes.ts # POST /api/agent, POST /api/agent/stream
├── services/agentService.ts # LLM call logic (Vercel AI SDK)
├── repositories/cacheRepository.ts
├── models/index.ts
└── middleware/rateLimiter.ts
```
---
## 4. Key Architectural Patterns
### 4.1 Command / Reducer Store (canvasStore)
The Zustand canvas store uses a pure reducer (`canvasStoreReducer`) with a discriminated union of `CanvasCommand` types. All mutations go through `dispatch(command)`. In dev mode every command is logged to the console.
The store has three slices:
- **`graph`** — nodes and edges (the React Flow state)
- **`path`** — trigger pulse for edge animation
- **`ui`** — renaming state, fullscreen node, pending connection
### 4.2 Delta-based Undo / Redo
`useGraphStateWithHistory` computes *inverse deltas* (only the nodes/edges that changed) rather than snapshotting the full graph. Maximum 100 history entries. Drag operations commit a pre-drag snapshot on `onNodeDragStop`.
### 4.3 Node Plugin Registry
Node types are registered via a mutable registry in `nodeRegistry.ts`. The built-ins are registered at app init from `registerBuiltinNodes.tsx`. Adding a new node type requires:
1. Implement a React component for the node.
2. Create a descriptor using `NodeTypeBuilder`.
3. Call `registerNodeType(descriptor)` — no edits to core code.
### 4.4 Fluent Builder for Node Descriptors
`NodeTypeBuilder` provides a type-safe method-chaining API (`idPrefix`, `withInputOutput`, `classification`, `allowedSourceTypes`, `help`, `menu`, `withFullscreen`, `sourceRenderingLogic`, …). Required fields are validated at `.build()`.
### 4.5 Three-Step Rendering Pipeline
Defined as an explicit contract in `rendering.ts`:
1. **Resolve** — source node (Config/Agent) produces `{ resolved, outputTypeId, reasoning? }`
2. **Render** — output type handler converts the resolved string (PlantUML → Kroki SVG, Markdown → HTML, Wireframe → SVG)
3. **Display** — the Rendering node shows an image viewport or scrollable HTML
### 4.6 Node Classification System
Four metaphorical classes drive the create menu grouping:
| Class | Node type | Metaphor |
| --- | --- | --- |
| `psyche` | Variable | source / input |
| `physis` | Data (CSV) | physical data |
| `pneuma` | Config / Render | transformation |
| `archon` | Agent | AI / LLM |
### 4.7 Recollections (Workspaces)
Each workspace (recollection) is stored in `localStorage`:
| Key | Content |
| --- | --- |
| `zui_platform_recollections` | Metadata index (id, name, icon, timestamps) |
| `zui_graph_<id>` | Flux graph (nodes + edges) |
| `zui_logos_<id>` | BlockNote rich text document |
| `zui_render_cache_<id>` | Rendered artifact cache |
Legacy key prefix `emanations` is migrated to `recollections` on first load.
### 4.8 User-configurable AI Connection
`KosmosContext` persists `aiConnection` (provider, baseURL, model, apiKey) in `localStorage` under `zui_ai_connection`. The Agent node forwards this to the backend with each request, so users can switch between OpenAI and local LLMs (LM Studio, Ollama) from the UI without env variable changes.
### 4.9 Diagram Rendering via Kroki
PlantUML diagrams are rendered by proxying to `https://kroki.io` via `/api/kroki/plantuml/svg`. This proxy is configured in both the Vite dev server and Nginx production config. Timeout: 15 seconds.
---
## 5. Data Flow
```text
User interaction
→ dispatch(CanvasCommand) [canvasStore reducer]
→ Zustand state update [graph / path / ui slices]
→ React re-render [React Flow nodes/edges]
Rendering node triggered
→ Resolve source node [Config/Agent → resolved string]
→ Render output type [plantuml/markdown/wireframe]
→ Kroki proxy (PlantUML) [GET /api/kroki/plantuml/svg]
→ Agent backend (AI) [POST /api/agent or /api/agent/stream]
→ Display artifact [image / HTML / SVG viewport]
Agent node (streaming)
→ POST /api/agent/stream [Express → agentService]
→ Vercel AI SDK streams SSE [backend → frontend]
→ useStreamingContent hook consumes [updates node data]
```
---
## 6. Backend Architecture
The backend is intentionally thin and stateless. It has three responsibilities:
1. **Proxy AI calls** — forwards prompts to the configured LLM (OpenAI or OpenAI-compatible).
2. **Stream responses**`POST /api/agent/stream` uses `pipeTextStreamToResponse` from the Vercel AI SDK.
3. **Health check**`GET /health` for Docker/orchestration readiness probes.
The `InMemoryCache` and `rateLimiter` middleware are implemented but not yet wired into the agent routes.
---
## 7. Contribution Workflow
1. Fork the repository and clone locally.
2. Install dependencies: `cd frontend && npm install` and `cd backend && npm install`.
3. Run the dev servers (see README).
4. Create a feature branch: `git checkout -b feat/your-feature`.
5. Follow the Coding Standards (see [CONTRIBUTING.md](CONTRIBUTING.md)).
6. Submit a Pull Request with a descriptive title, linked issue(s), updated tests, and documentation changes if relevant.
### PR Review Checklist
- [ ] Readability: clear naming, minimal nesting
- [ ] TypeScript: no `any`, strict types throughout
- [ ] Performance: no unnecessary re-renders, proper memoization
- [ ] Accessibility: semantic HTML, ARIA attributes where needed
- [ ] Security: no hardcoded secrets, input validated at boundaries
---
## 8. Coding Standards
- **TypeScript**: strict mode (`strict`, `noImplicitAny`, `noUnusedLocals`).
- **Naming**: PascalCase for components, camelCase for functions/variables, UPPER_SNAKE_CASE for constants.
- **File structure**: domain-grouped under `src/app/<domain>/` and `src/components/<category>/`.
- **Formatting**: Prettier via `frontend/prettier.config.cjs`; line length ≤ 120.
- **Comments**: JSDoc for public APIs; inline only where logic is non-obvious.
---
## 9. Quick Reference for AI Agents
| Topic | Location |
| --- | --- |
| Entry point | `frontend/src/main.tsx` |
| Canvas store | `frontend/src/app/canvas/canvasStore.ts` |
| Store reducer | `frontend/src/app/canvas/canvasStoreReducer.ts` |
| Node registry | `frontend/src/lib/graph/nodeRegistry.ts` |
| Node builder | `frontend/src/lib/graph/nodeTypeBuilder.ts` |
| Rendering pipeline | `frontend/src/lib/graph/rendering.ts` |
| Built-in node registration | `frontend/src/lib/graph/registerBuiltinNodes.tsx` |
| AI settings context | `frontend/src/app/kosmos/KosmosContext.tsx` |
| Backend entry | `backend/src/index.ts` |
| Agent service | `backend/src/services/agentService.ts` |
*This document is a living reference; update it when architectural decisions change.*

61
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,61 @@
# Contribution Guide
## 1. Coding Standards
- Use TypeScript with `strict` mode enabled; no `any` types.
- Import order: core libs → third-party → project aliases (`@/`) → relative.
- Naming: PascalCase for components, camelCase for functions/variables, UPPER_SNAKE_CASE for constants.
- Add JSDoc comments for all public APIs and exported types.
- Line length ≤ 120 characters; use Prettier for formatting (`frontend/prettier.config.cjs`).
- Run `npm run lint` and fix all warnings before opening a PR.
## 2. Branch Workflow
- Create a feature branch: `git checkout -b feat/<short-description>`
- Keep branches up to date with `main` via `git rebase`.
- Submit a Pull Request with a clear title and description.
- Ensure the PR passes CI (tests, lint, type-check).
- Address review comments promptly.
## 3. Running Tests
```bash
# Install dependencies
cd frontend && npm install
# Run tests in watch mode
npm run test
# Run tests once (CI)
npm run test:run
# Type-check
npx tsc --noEmit
```
## 4. Making Your First Contribution
1. Pick a beginner-friendly issue labeled `good first issue`.
2. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the relevant module.
3. Make a small, focused change; avoid refactoring unrelated code.
4. Run the relevant tests and confirm they pass.
5. Commit and push, then open a PR with a clear description of what and why.
## 5. Where to Extend
| Goal | Where to look |
| --- | --- |
| Add a new node type | `src/lib/graph/nodeTypeBuilder.ts` + `registerBuiltinNodes.tsx` |
| Add a new output/render type | `src/lib/graph/configTypes.ts` + `rendering.ts` |
| Change canvas state shape | `src/app/canvas/canvasStore.ts` + reducer + tests |
| Add a new Logos block | `src/app/recollections/logos/logosSchema.ts` |
| Add a backend endpoint | `backend/src/routes/``services/``index.ts` |
## 6. Useful Links
- [ARCHITECTURE.md](ARCHITECTURE.md) — architecture overview, key patterns, module map
- [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) — bottleneck analysis and improvement plan
- [docs/CODE_REVIEW_CHECKLIST.md](docs/CODE_REVIEW_CHECKLIST.md) — PR review checklist
- [docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md](docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md) — node plugin system design
*Thank you for contributing!*

108
PERFORMANCE_IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,108 @@
# 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 6070% 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.*

153
README.md
View File

@@ -0,0 +1,153 @@
# Zui
Node-based visual editor (React Flow) for composing configs, variables, templates, and AI-generated content. Three views per workspace: **Flux** (graph canvas), **Logos** (rich text), and **Katalogos** (artifact gallery). Optional Node.js backend for AI agent calls.
## Project layout
```text
zui/
├── frontend/ # React SPA (Vite, TypeScript)
│ ├── src/
│ │ ├── main.tsx # Entry point: registers node/config types, mounts React
│ │ ├── app/
│ │ │ ├── canvas/ # Core graph editor (React Flow + Zustand)
│ │ │ ├── kosmos/ # Platform shell: sidebar, recollection list, AI settings
│ │ │ └── recollections/ # Logos (rich text), Flux (canvas), Katalogos (gallery)
│ │ ├── components/
│ │ │ ├── graph/ # AnimatedEdge, BaseNode, handles, keyboard shortcuts
│ │ │ ├── nodes/ # One folder per node type (agent, config, variable, …)
│ │ │ └── ui/ # Shadcn-based primitives
│ │ ├── hooks/ # Custom React hooks
│ │ └── lib/
│ │ └── graph/ # Registry, types, state, rendering pipeline, Nunjucks utils
│ ├── Dockerfile # Multi-stage: Vite build → Nginx
│ ├── Dockerfile.dev # Dev with hot reload
│ ├── nginx.conf
│ └── package.json
├── backend/ # Node.js/Express API
│ ├── src/
│ │ ├── index.ts # Express entry: registers routes, CORS, error handler
│ │ ├── routes/agentRoutes.ts
│ │ ├── services/agentService.ts
│ │ ├── repositories/cacheRepository.ts
│ │ ├── models/index.ts
│ │ └── middleware/rateLimiter.ts
│ └── package.json
├── docs/
│ ├── ARCHITECTURE.md
│ ├── PERFORMANCE.md
│ ├── NODE_TYPE_EXTENSIBILITY_PROPOSAL.md
│ └── CODE_REVIEW_CHECKLIST.md
├── docker-compose.yml
└── .gitignore
```
Ignored by git: `node_modules`, `dist`, `.env`, `.env.*`. Local Docker overrides: `docker-compose.override.yml` (optional, not committed).
---
## Run locally (dev)
**Frontend only** (no AI agent):
```bash
cd frontend && npm install && npm run dev
# → http://localhost:3000
```
**Frontend + backend** (for AI agent and health check):
```bash
# Terminal 1 backend
cd backend && npm install && npm run dev
# → http://localhost:8080
# Terminal 2 frontend
cd frontend && npm install && npm run dev
# → http://localhost:3000 (Vite proxies /api/* and /health to backend)
```
---
## Run with Docker
```bash
docker compose up --build
```
- **Frontend**: <http://localhost:3000> (Nginx; `/api/*` proxied to backend).
- **Backend**: <http://localhost:8080> (Express).
Environment variables (backend service):
| Variable | Default | Description |
|------------------|---------------------------|--------------------------------------------|
| `PORT` | `8080` | Backend listen port. |
| `CORS_ORIGIN` | `http://localhost:3000` | Allowed origin for CORS. |
| `AI_BASE_URL` | *(unset)* | OpenAI-compatible base URL (local LLMs). |
| `AI_MODEL` | `gpt-4o-mini` | Model ID for the AI agent. |
| `OPENAI_API_KEY` | *(unset)* | Required when using OpenAI directly. |
For self-hosting (e.g., Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL and put Caddy or Nginx in front for TLS.
---
## Scripts
| Command | Description |
|--------------------------------------|----------------------------------------|
| `cd frontend && npm run dev` | Vite dev server (port 3000). |
| `cd frontend && npm run build` | Build frontend for production. |
| `cd frontend && npm run preview` | Preview production build. |
| `cd frontend && npm run test` | Run tests in watch mode (Vitest). |
| `cd frontend && npm run test:run` | Run tests once (CI). |
| `cd backend && npm run dev` | Backend with `tsx --watch`. |
| `cd backend && npm start` | Backend production run. |
| `docker compose up --build` | Run full stack in Docker. |
---
## Backend API
| Method | Path | Body / Response |
|--------|-----------------------|---------------------------------------------------------------------------------|
| GET | `/health` | `{ ok: true, timestamp: number }` — health check for Docker/orchestration. |
| POST | `/api/agent` | Body `{ prompt, context?, contextNodes? }``{ markdown }` (one-shot). |
| POST | `/api/agent/stream` | Body `{ prompt, context?, contextNodes? }` → SSE text stream (streaming). |
---
## Agent node (local LLM or OpenAI)
AI connection settings are configured **directly in the UI** — open the sidebar and go to **AI Settings**. You can switch between providers without restarting the server.
The backend reads its AI config from environment variables as a fallback:
### Local LLM (e.g. LM Studio)
1. Install [LM Studio](https://lmstudio.ai/), load a model, and start the local server (default: `http://localhost:1234`).
2. Set env vars (backend):
```bash
export AI_BASE_URL=http://localhost:1234/v1
export AI_MODEL=your-model-name # optional; matches the name shown in LM Studio
```
### OpenAI
```bash
export OPENAI_API_KEY=sk-...
export AI_MODEL=gpt-4o-mini # optional; defaults to gpt-4o-mini
```
---
## Contribute
- [ARCHITECTURE.md](ARCHITECTURE.md) — architecture overview, key patterns, module map
- [CONTRIBUTING.md](CONTRIBUTING.md) — coding standards, branch workflow, test commands
- [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) — bottleneck analysis and improvement plan
- [docs/CODE_REVIEW_CHECKLIST.md](docs/CODE_REVIEW_CHECKLIST.md) — PR review checklist
- [docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md](docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md) — node plugin system design
*Thank you for contributing!*

5
backend/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.git
*.log
.env*
.env.*

14
backend/.env.example Normal file
View File

@@ -0,0 +1,14 @@
# Optional: backend env vars. Copy to .env and adjust.
# --- Agent (AI) ---
# For OpenAI: set your key (model defaults to gpt-4o-mini).
# OPENAI_API_KEY=sk-...
# For local LLM (LM Studio, Ollama, etc.): set base URL. No API key required.
# AI_BASE_URL=http://localhost:1234/v1
# Optional: model name (LM Studio shows the loaded model id).
# AI_MODEL=local-model
# --- Server ---
# PORT=8080
# CORS_ORIGIN=http://localhost:3000

21
backend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Single-stage, Alpine, Node 20. Production backend (no DB).
FROM node:20-alpine
WORKDIR /app
# Dependencies only
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY src ./src
# Non-root user
RUN addgroup -g 1001 -S app && adduser -u 1001 -S app -G app
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:8080/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
CMD ["node", "src/index.js"]

1801
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
backend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "zui-backend",
"version": "1.0.0",
"private": true,
"description": "Minimal Express API for Zui (agent, health; no DB)",
"type": "module",
"main": "src/index.ts",
"scripts": {
"start": "node src/index.ts",
"dev": "tsx watch src/index.ts",
"build": "tsc"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"ai": "^4.0.0",
"@ai-sdk/openai": "^1.0.0",
"cors": "^2.8.5",
"express": "^4.21.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
}
}

102
backend/src/index.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Backend API: Layered architecture for scalability and maintainability.
*
* ## Architecture Layers
*
* ### Controllers (routes/)
* - Handle HTTP requests and responses
* - Validate input and format output
* - Call services for business logic
*
* ### Services (services/)
* - Implement business logic
* - Coordinate between controllers and repositories
* - Handle validation and transformation
*
* ### Repositories (repositories/)
* - Data access layer
* - Interact with databases or external APIs
* - Return domain models
*
* ### Models (models/)
* - Data structures and types
* - Validation schemas
*
* ### Middleware (middleware/)
* - Request validation
* - Authentication/authorization
* - Rate limiting
* - Error handling
*
* ## Adding New Endpoints
*
* 1. Define the model in `models/`
* 2. Implement repository logic in `repositories/`
* 3. Implement service logic in `services/`
* 4. Create controller in `routes/`
* 5. Register route in `index.ts`
*/
import express from 'express'
import cors from 'cors'
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const PORT = Number(process.env.PORT) || 8080
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:3000'
// ---------------------------------------------------------------------------
// Express App Setup
// ---------------------------------------------------------------------------
const app = express()
app.use(cors({ origin: CORS_ORIGIN }))
app.use(express.json())
// ---------------------------------------------------------------------------
// Agent Routes
// ---------------------------------------------------------------------------
import { handleAgentRequest, handleAgentStreamRequest } from './routes/agentRoutes.js'
/** POST /api/agent - Run AI agent */
app.post('/api/agent', handleAgentRequest)
/** POST /api/agent/stream - Stream AI agent response */
app.post('/api/agent/stream', handleAgentStreamRequest)
// ---------------------------------------------------------------------------
// Health Check Endpoint
// ---------------------------------------------------------------------------
/** GET /health - Health check for Docker / orchestration */
app.get('/health', (req, res) => {
res.status(200).json({ ok: true, timestamp: Date.now() })
})
// ---------------------------------------------------------------------------
// Error Handling Middleware
// ---------------------------------------------------------------------------
/** Global error handler for consistent error responses */
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Error:', err)
res.status(500).json({ error: err.message ?? 'Internal server error' })
})
// ---------------------------------------------------------------------------
// Start Server
// ---------------------------------------------------------------------------
app.listen(PORT, '0.0.0.0', () => {
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
})
// ---------------------------------------------------------------------------
// Export for testing
// ---------------------------------------------------------------------------
export default app

View File

@@ -0,0 +1,88 @@
/**
* Middleware: Rate limiting for API endpoints.
*
* This module provides rate limiting middleware to protect the API
* from abuse and ensure fair usage.
*/
/**
* Rate limit configuration
*/
export type RateLimitConfig = {
/** Maximum number of requests allowed in the window */
max: number
/** Window size in milliseconds */
windowMs: number
/** Error message to return when rate limited */
message?: string
}
/**
* Default rate limit configuration
*/
export const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = {
max: 100,
windowMs: 60 * 1000, // 1 minute
message: 'Too many requests, please try again later.',
}
/**
* In-memory store for rate limiting
*/
const rateLimitStore = new Map<string, { count: number; resetTime: number }>()
/**
* Rate limiting middleware
*
* @param config - Rate limit configuration
* @returns Express middleware function
*/
export function rateLimit(config: RateLimitConfig = DEFAULT_RATE_LIMIT_CONFIG) {
return (req: { ip?: string; socket?: { remoteAddress?: string } }, res: { status: (code: number) => { json: (body: { error: string }) => void }, locals: Record<string, unknown> }, next: () => void): void => {
const ip = req.ip || req.socket?.remoteAddress || 'unknown'
const now = Date.now()
let entry = rateLimitStore.get(ip)
if (!entry || now > entry.resetTime) {
// Reset the counter for a new window
entry = { count: 1, resetTime: now + config.windowMs }
rateLimitStore.set(ip, entry)
next()
return
}
entry.count++
if (entry.count > config.max) {
res.status(429).json({ error: config.message ?? 'Too many requests' })
return
}
next()
}
}
/**
* Clean up expired entries from the rate limit store
*/
export function cleanupRateLimitStore(): void {
const now = Date.now()
for (const [ip, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
rateLimitStore.delete(ip)
}
}
}
/**
* Get rate limit status for an IP
*/
export function getRateLimitStatus(ip: string): { remaining: number; resetTime: number } | null {
const entry = rateLimitStore.get(ip)
if (!entry) return null
return {
remaining: Math.max(0, DEFAULT_RATE_LIMIT_CONFIG.max - entry.count),
resetTime: entry.resetTime,
}
}

View File

@@ -0,0 +1,63 @@
/**
* Models: Data structures and validation schemas.
*
* This module defines the domain models used throughout the application.
*/
/**
* Agent request payload
*/
export type AgentRequest = {
prompt: string
context?: string
contextNodes?: AgentContextNode[]
connection?: AgentConnection
reasoning?: boolean
}
/**
* Context node for agent requests
*/
export type AgentContextNode = {
id: string
content?: string
}
/**
* AI connection configuration
*/
export type AgentConnection = {
provider: 'openai' | 'local'
baseURL?: string
apiKey?: string
model?: string
}
/**
* Agent response
*/
export type AgentResponse = {
markdown: string
}
/**
* Agent stream response
*/
export type AgentStreamResponse = {
markdown: string
}
/**
* Health check response
*/
export type HealthResponse = {
ok: boolean
timestamp: number
}
/**
* Error response
*/
export type ErrorResponse = {
error: string
}

View File

@@ -0,0 +1,103 @@
/**
* Repositories: Data access layer.
*
* This module handles data persistence and retrieval.
* It abstracts the underlying storage mechanism.
*/
/**
* Cache entry for expensive computations
*/
export type CacheEntry<T> = {
/** Unique key for the cache entry */
key: string
/** Cached data */
data: T
/** Timestamp (ms) when this entry was created */
createdAt: number
/** Time-to-live in milliseconds */
ttl?: number
}
/**
* In-memory cache implementation
*/
export class InMemoryCache {
private cache = new Map<string, CacheEntry<unknown>>()
private defaultTtl = 5 * 60 * 1000 // 5 minutes
/**
* Get a value from the cache
*
* @param key - Cache key
* @returns Cached value or null if not found or expired
*/
get<T>(key: string): T | null {
const entry = this.cache.get(key) as CacheEntry<T> | undefined
if (!entry) return null
// Check if entry has expired
if (entry.ttl && Date.now() - entry.createdAt > entry.ttl) {
this.cache.delete(key)
return null
}
return entry.data
}
/**
* Set a value in the cache
*
* @param key - Cache key
* @param data - Data to cache
* @param ttl - Optional time-to-live in milliseconds (uses default if not specified)
*/
set<T>(key: string, data: T, ttl?: number): void {
this.cache.set(key, {
key,
data,
createdAt: Date.now(),
ttl: ttl ?? this.defaultTtl,
})
}
/**
* Delete a value from the cache
*
* @param key - Cache key
*/
delete(key: string): void {
this.cache.delete(key)
}
/**
* Clear all entries from the cache
*/
clear(): void {
this.cache.clear()
}
/**
* Clean up expired entries
*/
cleanup(): void {
const now = Date.now()
for (const [key, entry] of this.cache.entries()) {
if (entry.ttl && now - entry.createdAt > entry.ttl) {
this.cache.delete(key)
}
}
}
/**
* Get the number of entries in the cache
*/
size(): number {
return this.cache.size
}
}
/**
* Global cache instance
*/
export const cache = new InMemoryCache()

View File

@@ -0,0 +1,72 @@
/**
* Routes: HTTP request handlers.
*
* This module contains the route handlers for the API.
* Routes validate input and format output, then call services for business logic.
*/
import type { AgentRequest, AgentResponse, ErrorResponse } from '../models'
/**
* POST /api/agent - Run AI agent
*
* Request body: { prompt, context?, contextNodes?, connection? }
* Response: { markdown }
*/
export async function handleAgentRequest(req: any, res: any): Promise<void> {
try {
const body = req.body
const { buildAgentRequest } = await import('../services/agentService')
const built = buildAgentRequest(body)
if ('error' in built) {
res.status(503).json({ error: built.error } as ErrorResponse)
return
}
const { generateText } = await import('ai')
const result = await generateText({
model: built.openai as any,
prompt: built.fullPrompt,
})
const { processAgentResponse } = await import('../services/agentService')
const response = processAgentResponse(result)
res.json(response)
} catch (err) {
console.error('Agent error:', err)
res.status(500).json({ error: (err as Error).message ?? 'Agent request failed' } as ErrorResponse)
}
}
/**
* POST /api/agent/stream - Stream AI agent response
*
* Request body: { prompt, context?, contextNodes?, connection? }
* Response: plain text (markdown) chunks
*/
export async function handleAgentStreamRequest(req: any, res: any): Promise<void> {
try {
const body = req.body
const { buildAgentRequest } = await import('../services/agentService')
const built = buildAgentRequest(body)
if ('error' in built) {
res.status(503).json({ error: built.error } as ErrorResponse)
return
}
const { streamText } = await import('ai')
const result = streamText({
model: built.openai as any,
prompt: built.fullPrompt,
})
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.setHeader('Transfer-Encoding', 'chunked')
result.pipeTextStreamToResponse(res)
} catch (err) {
console.error('Agent stream error:', err)
res.status(500).json({ error: (err as Error).message ?? 'Agent request failed' } as ErrorResponse)
}
}

View File

@@ -0,0 +1,65 @@
/**
* Services: Business logic layer.
*
* This module contains the business logic for the application.
* Services coordinate between controllers and repositories.
*/
import type { AgentRequest, AgentResponse, AgentConnection } from '../models'
/**
* Build OpenAI client and full prompt from request body.
* Returns { openai, modelId, fullPrompt } or { error }.
*/
export function buildAgentRequest(body: AgentRequest): { openai: unknown; modelId: string; fullPrompt: string } | { error: string } {
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
const reasoningEnabled = Boolean(reasoning)
let baseURL = process.env.AI_BASE_URL?.trim() || null
let apiKey = process.env.OPENAI_API_KEY?.trim() || null
let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
if (conn && typeof conn === 'object') {
const c = conn as AgentConnection
const provider = c.provider === 'openai' ? 'openai' : 'local'
if (provider === 'local') {
baseURL = (typeof c.baseURL === 'string' && c.baseURL.trim()) ? c.baseURL.trim() : baseURL
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : (apiKey || 'lm-studio')
} else {
baseURL = null
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : apiKey
}
if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
}
if (!baseURL && !apiKey) {
return { error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.' }
}
const basePrompt = [
typeof prompt === 'string' ? prompt : 'No prompt provided.',
context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
Array.isArray(contextNodes) && contextNodes.length > 0
? `\n\nContext from connected nodes:\n${contextNodes.map((n) => (n.content != null ? n.content : `${n.id}: (no content)`)).join('\n\n')}`
: '',
].join('')
const fullPrompt = reasoningEnabled
? basePrompt + '\n\nRespond in exactly two markdown sections. First: "## Reasoning" with your step-by-step reasoning. Then: "## Output" with only the final answer. No preamble.'
: basePrompt + '\n\nRespond with structured markdown only. No preamble.'
const { createOpenAI } = require('@ai-sdk/openai')
const openai = createOpenAI({
apiKey: apiKey || 'lm-studio',
...(baseURL && { baseURL, compatibility: 'compatible' }),
})
return { openai, modelId, fullPrompt }
}
/**
* Process agent response
*/
export function processAgentResponse(result: { text?: string }): AgentResponse {
const markdown = result?.text ?? ''
return { markdown }
}

20
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

40
docker-compose.yml Normal file
View File

@@ -0,0 +1,40 @@
# Zui: frontend (Nginx) + backend (Express). No DB.
# Usage: docker compose up --build
# Dev with hot reload: use frontend/Dockerfile.dev and mount ./frontend, or run npm run dev locally.
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:80"
depends_on:
backend:
condition: service_healthy
environment:
- VITE_API_URL=http://localhost:8080
networks:
- app
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
- CORS_ORIGIN=http://localhost:3000
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:8080/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 3s
start_period: 5s
retries: 3
networks:
- app
networks:
app:
driver: bridge

View File

@@ -0,0 +1,48 @@
# Code Review Checklist
Use this checklist during code reviews to ensure high standards of readability, maintainability, and performance.
## 1. Readability
- [ ] **Naming**: Variables, functions, and components use descriptive, consistent names (camelCase for functions/variables, PascalCase for components).
- [ ] **JSDoc**: All public APIs have complete JSDoc comments with `@param`, `@returns`, and `@example` tags.
- [ ] **Line Length**: No line exceeds 120 characters; wrap long lines for readability.
- [ ] **Comments**: Explain *why* something is done, not just *what* is done. Avoid redundant comments.
- [ ] **Whitespace**: Consistent spacing and indentation (2 spaces for TypeScript/JSX).
## 2. Maintainability
- [ ] **Component Structure**: Large components are split into smaller, focused subcomponents.
- [ ] **Hooks**: Custom hooks encapsulate reusable logic and are named with `use` prefix.
- [ ] **Utility Functions**: Pure functions live in `src/lib/` and are exported for reuse.
- [ ] **Type Safety**: All function signatures include explicit TypeScript types; no `any` usage.
- [ ] **Import Order**: Core → Library → Project → Relative imports; alphabetical within groups.
## 3. Performance
- [ ] **Memoization**: Expensive calculations use `useMemo`; event handlers use `useCallback`.
- [ ] **Lazy Loading**: Heavy modules are loaded via `import()`; code splitting is configured in Vite.
- [ ] **Virtualization**: Large lists use `react-window` or similar for efficient rendering.
- [ ] **State Granularity**: State updates target only the minimal portion of state; avoid unnecessary rerenders.
- [ ] **Bundle Size**: No unused dependencies; assets are compressed (gzip/brotli) in production.
## 4. Accessibility & Security
- [ ] **ARIA**: Semantic HTML and ARIA attributes are present for interactive elements.
- [ ] **Input Validation**: User inputs are validated before processing; no hardcoded secrets.
- [ ] **Error Handling**: Errors are caught and logged appropriately; userfriendly messages are shown.
## 5. Testing
- [ ] **Unit Tests**: Cover all new logic with tests; aim for ≥80% coverage on critical paths.
- [ ] **Integration Tests**: Verify that components interact correctly with state/store.
- [ ] **Test Naming**: Test files end with `.test.tsx` and describe the behavior being tested.
## 6. Documentation Links
- [ ] Architecture Overview: [[ARCHITECTURE.md](ARCHITECTURE.md)]
- [ ] Performance Plan: [[PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md)]
- [ ] Junior Developer Quickstart: [[QUICKSTART_FOR_JUNIORS.md](QUICKSTART_FOR_JUNIORS.md)]
- [ ] Contribution Guide: [[CONTRIBUTING.md](CONTRIBUTING.md)]
*Reviewers should mark any failing items as **[ ]** and request changes before approving.*

View File

@@ -0,0 +1,101 @@
# Node Type Extensibility — Proposal
## Goal
Allow developers to add new node types and their rendering/logic without editing core app code. Each new type should provide: its React component, default data/style, connection rules, help text, and context-menu entry.
## Current State
- **Registration:** In `App.tsx`, `nodeTypes` is a hardcoded map (config → ConfigNode, render → RenderingNode, etc.).
- **Defaults & IDs:** `flowUtils.ts` has `PREFIX_BY_TYPE`, `DEFAULT_NODE_STYLE`, `DEFAULT_DATA`, `getDefaultDataForType`, `getResetDataForType` — all branch on type.
- **Validation:** `isValidConnection` in `App.tsx` encodes rules (e.g. nothing → variable, only config → render) with explicit type checks.
- **UI:** Context menu “Create node” items, paste allowlist (`VALID_NODE_TYPES`), node help (`nodeHelp.tsx`), footer indicators (`NODE_HAS_INPUT` / `NODE_HAS_OUTPUT`), and `NodeMenubar` / `AnimatedEdge` use type strings or fixed type unions.
Adding a fifth type today requires editing App, flowUtils, nodeHelp, NodeFooterEdgeIndicators, and possibly NodeMenubar/AnimatedEdge.
---
## Alternative 1: Central Node Type Registry (single module)
**Idea:** One module (e.g. `src/lib/nodeRegistry.ts` or `src/config/nodeTypes.ts`) holds a **single registry object** keyed by type id. Each entry describes the type:
- `component` — React component for the node
- `defaultStyle`, `defaultData`, `idPrefix`
- `hasInput` / `hasOutput` (for footer and validation)
- `allowedSourceTypes` / `allowedTargetTypes` (for connection validation)
- `help` (title + content for NodeHelpPopover)
- `menuLabel`, `menuIcon` (for context menu “Create node”)
App and other consumers **import the registry** and iterate: build `nodeTypes` from registry, build context menu from registry, call `getDefaultDataForType(registry, type)`, and run connection validation using registry metadata.
**Pros:** Single source of truth; no new concepts (no plugins).
**Cons:** Extensions still require editing that one file (or a dedicated “contrib” section inside it). Good for a small, curated set of types.
---
## Alternative 2: Plugin / Contribution API (mutable registry + `registerNodeType()`)
**Idea:** The registry is **mutable**. A bootstrap phase (e.g. in `main.tsx` or a dedicated `registerBuiltinNodes.ts`) calls `registerNodeType(descriptor)` for each built-in type. Third-party code (or feature modules) can call the same API to add types without touching the core registry module.
- Export `registerNodeType(descriptor: NodeTypeDescriptor)` and `getRegisteredNodeTypes()` (and optionally `getNodeType(id)`).
- Descriptor shape: same as in Alternative 1 (component, defaults, connection rules, help, menu).
- Built-in types are registered at app init; the registry is then read-only for the rest of the session (or remains open for dynamic plugins).
**Pros:** True extensibility: new types = call `registerNodeType` (e.g. from a separate package or an async-loaded chunk).
**Cons:** Need a clear descriptor type and lifecycle (when registration runs, ordering). Slightly more indirection when reading “what types exist.”
---
## Alternative 3: Convention-based discovery (folder or config)
**Idea:** Node types are **discovered** from the filesystem or a config file.
- **Variant A:** Each type lives under a folder, e.g. `src/nodes/<typeId>/index.ts` exporting a descriptor. A loader (at build or runtime) imports all and registers them.
- **Variant B:** A config file (e.g. `nodeTypes.config.ts`) lists type ids and module paths; the app dynamically imports and registers them.
**Pros:** “Add a folder” or “add a line” to add a type; no direct call to a central registry from feature code.
**Cons:** Build/runtime setup (e.g. `import.meta.glob` or dynamic `import()`), ordering and error handling. May be overkill for an in-repo extension story.
---
## Recommendation
**Use Alternative 2 (Plugin / Contribution API)** with a single registry module:
1. **Define** a `NodeTypeDescriptor` type and a small API: `registerNodeType(descriptor)`, `getRegisteredNodeTypes()`, `getNodeType(id)`.
2. **Move** all type-specific data (component, defaultStyle, defaultData, idPrefix, hasInput, hasOutput, connection rules, help, menu label/icon) into the descriptor. Connection rules can be expressed as `allowedSourceTypes` / `allowedTargetTypes` so validation is data-driven.
3. **Register** the four built-in types (config, render, variable, function) at startup from a single “builtin” registration file (or from within the registry module). No change to the public shape of existing components; they are just passed to `registerNodeType`.
4. **Refactor** App, flowUtils, nodeHelp, NodeFooterEdgeIndicators, and related UI to use the registry: `nodeTypes` and context menu from `getRegisteredNodeTypes()`, defaults and validation from descriptor, help from descriptor (or still from a function that reads descriptor.help).
5. **Result:** Adding a fifth type = implement a component + call `registerNodeType({ id: 'mytype', component: MyNode, ... })` (e.g. in a feature module or a separate entry that runs before the app). No edits to Apps branching logic or to flowUtils/nodeHelps type unions.
Optional follow-ups: move help content into the descriptor (so `nodeHelp.tsx` only re-exports from registry), and add a small “node type list” config so the context menu order is explicit.
---
## Implementation outline (Alternative 2)
- **New:** `src/lib/nodeRegistry.ts``NodeTypeDescriptor` type, `registerNodeType()`, `getRegisteredNodeTypes()`, `getNodeType(id)`. Help content can live in the descriptor or be merged from current `nodeHelp.tsx` for built-ins.
- **New (optional):** `src/lib/registerBuiltinNodes.ts` — imports ConfigNode, RenderingNode, VariableNode, FunctionNode and their help/defaults, calls `registerNodeType` for each; invoked from `main.tsx` before React render.
- **Refactor:** `flowUtils.ts``getDefaultDataForType`, `getResetDataForType`, `DEFAULT_NODE_STYLE`, `PREFIX_BY_TYPE` (and `getNextNodeId`) take type and optionally the registry, or read from registry when called (registry imported inside flowUtils). Prefer: registry holds defaults/prefix; flowUtils exports thin wrappers that use registry.
- **Refactor:** `App.tsx``nodeTypes` from `getRegisteredNodeTypes().map(...)`, context menu from same, `createNode`/paste use registry for valid types and defaults, `isValidConnection` uses descriptors `allowedSourceTypes`/`allowedTargetTypes`.
- **Refactor:** `nodeHelp.tsx``getNodeHelp(type)` returns descriptor.help from registry (built-ins register help in descriptor).
- **Refactor:** `NodeFooterEdgeIndicators.tsx``hasInput`/`hasOutput` from descriptor.
- **Refactor:** `NodeMenubar` / `NodeHelpPopover` — keep `nodeType` as string; descriptor is looked up by id where needed.
- **Refactor:** `AnimatedEdge` — edge label by target type can stay as a small map or move to descriptor (e.g. `connectionLabel?: string` when this type is target).
After this, a new node type is added by: (1) implementing a React component, (2) calling `registerNodeType({ id: 'mytype', component: MyNode, ... })` at app init.
---
## Implemented (Alternative 2)
- **`src/lib/nodeRegistry.ts`** — `NodeTypeDescriptor`, `registerNodeType()`, `getRegisteredNodeTypes()`, `getNodeType()`, `getDefaultDataForType()`, `getResetDataForType()`, `getIdPrefix()`, `getDefaultStyle()`, `isConnectionAllowed()`, `getConnectionLabelForTarget()`, `getNodeHelp()`.
- **`src/lib/registerBuiltinNodes.tsx`** — Registers config, render, variable, function; invoked from `main.tsx` before render.
- **`src/lib/flowUtils.ts`** — `getNextNodeId()` uses registry; default/reset data delegate to registry.
- **App, NodeHelpPopover, NodeFooterEdgeIndicators, NodeMenubar, AnimatedEdge** — Use registry for types, validation, help, and UI.
### Adding a new node type
1. Implement a React component that accepts React Flow node props.
2. Call `registerNodeType({ id, component, defaultStyle, defaultData, idPrefix, hasInput, hasOutput, allowedSourceTypes?, allowedTargetTypes?, help, menuLabel, menuIcon, ... })` from a module that runs at startup (e.g. imported and called from `main.tsx`).
3. No edits to `App.tsx` or core flow/utils are required.

26
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,26 @@
# Dependencies and build output
node_modules
dist
# Git and env
.git
.gitignore
.env*
!.env.example
# IDE, OS, logs
.idea
.vscode
*.log
.DS_Store
# Tests (keep image small)
**/__tests__
**/*.test.ts
**/*.test.tsx
**/*.spec.ts
**/*.spec.tsx
coverage
# Dev Dockerfile not needed in prod image
Dockerfile.dev

21
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Multi-stage: build Vite app, then serve with Nginx (Alpine).
# Frontend only; backend is a separate service.
# Stage 1: build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: serve
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://localhost:80/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

12
frontend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,12 @@
# Dev image: run Vite with hot reload. Mount source as volume.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# App code is mounted; no COPY so edits trigger reload
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@@ -0,0 +1,185 @@
# Canvas performance: options and refactoring for scalability
This document lists **concrete options** to improve canvas (React Flow graph editor) performance, with design patterns and refactoring for scalability. Implement in order of impact vs effort; measure before/after where possible.
---
## 1. Stabilize `nodes` passed to React Flow (high impact, low effort)
**Problem:** In `CanvasPage.tsx`, nodes are passed as:
```ts
nodes={nodes.map((n) => ({ ...n, className: [n.className, 'nowheel'].filter(Boolean).join(' ') }))}
```
Every render creates a **new array and new object references** for every node. React Flow may re-render or reconcile more than needed.
**Options:**
- **A. Compute derived props in a `useMemo`**
Memoize the mapped nodes; depend only on `nodes` and a stable `className` suffix so the reference changes only when `nodes` actually changes.
- **B. Avoid mutating node shape in the parent**
Apply the `nowheel` class via `nodeTypes` default or a wrapper so you can pass `nodes={nodes}` directly. Keeps a single source of truth and avoids creating new node objects every render.
**Pattern:** **Single source of truth** — avoid deriving a new structure on every render when the same structure can be expressed at the data or type level.
---
## 2. Split FlowContext to reduce consumer re-renders (high impact, medium effort)
**Problem:** `FlowContext` holds graph state (`nodes`, `edges`, setters), connection path state (trigger/updating/paused/error sets and callbacks), and UI state (renaming, fullscreen, connectionFrom, etc.). Any change to any part creates a **new context value**, so **every node and every edge** that uses `useContext(FlowContext)` re-renders on any graph or path update.
**Options:**
- **A. Split into multiple contexts (recommended)**
- **GraphContext:** `nodes`, `edges`, `setNodes`, `setEdges`, `applyGraph`, etc.
- **ConnectionPathContext:** path node IDs, trigger/updating/paused/error state and callbacks.
- **FlowUIContext:** `renamingNodeId`, `fullscreenNodeId`, `connectionFrom`, `flowActionsRef`, `isValidConnection`.
Nodes that only need graph + UI (e.g. for `updateData`, `setFullscreenNodeId`) dont re-render when only connection path state changes. Edges that only need connection path state dont re-render when only `nodes`/`edges` change (e.g. drag position).
- **B. Context + selectors**
Keep a single store (e.g. Zustand) and have nodes/edges subscribe with selectors (e.g. `useStore(selector)`). Only re-render when the selected slice changes. This is a larger refactor but scales well.
**Pattern:** **Segregation of concerns** — separate contexts (or stores) by update frequency and by consumer type so that high-frequency updates (e.g. connection path ticks) dont force re-renders of all nodes/edges.
---
## 3. Memoize edge component and narrow its context usage (high impact, low effort)
**Problem:** `AnimatedEdge` uses `useContext(FlowContext)` and reads `nodes`, `connectionPathNodeIds`, `connectionPathPausedSegmentNodeIds`, `connectionPathActiveSegmentNodeIds`, `connectionPathErrorNodeIds`. So every edge re-renders whenever the whole context value changes (e.g. any node move or path update).
**Options:**
- **A. Wrap `AnimatedEdge` in `React.memo`**
Use a custom comparison that returns true when `id`, `source`, `target`, positions, and style havent changed. React Flow already passes stable edge props; memo avoids re-renders when parent re-renders with the same props.
- **B. Feed only connection path data into edges**
After splitting context (see §2), edges consume only **ConnectionPathContext**. Then they re-render only when path/trigger/updating/paused/error state changes, not on every `nodes`/`edges` change.
- **C. Pass path state via React Flow edge `data`**
Compute per-edge “connection status” (or relevant path IDs) in the canvas and pass it as `edge.data`. Edges become pure in terms of context: they only need `data` and standard edge props. Pushing the computation to the parent is one place to update when path state changes.
**Pattern:** **Minimize subscriber set** — each component should subscribe only to the minimal state it needs (context split or selectors), and be memoized so parent re-renders dont force unnecessary work.
---
## 4. Reduce history and save cost (medium impact, medium effort)
**Problem:** In `useGraphStateWithHistory`, every `setNodes` / `setEdges` call runs `cloneState()`, which deep-clones all nodes and edges. With many nodes or large `node.data`, this is expensive. In `useCanvasGraph`, the save effect runs on every `nodes`/`edges` change (with a 500ms debounce), so frequent updates (e.g. drag) still schedule many saves.
**Options:**
- **A. Structural sharing / copy-on-write**
Keep history as immutable updates (e.g. only store changed nodes/edges or patches) instead of full clones. On undo/redo, apply patches or merge with previous state. This reduces both memory and CPU for large graphs.
- **B. Throttle or idle-based save**
Besides debouncing, only call `saveGraphToStorage` when the graph hasnt changed for N seconds or when `requestIdleCallback` fires. Reduces work during continuous interaction.
- **C. Limit history size and clone only when necessary**
You already cap history (e.g. `MAX_HISTORY`). Ensure `cloneState` is only called when pushing to history, not on silent updates (e.g. drag). You already use `setNodesSilent` for drag; double-check that no accidental pushes happen during drag.
**Pattern:** **Immutability with minimal copying** — use structural sharing or patch-based history so that only changed parts of the graph are copied and stored.
---
## 5. Contextual zoom and viewport (medium impact, lowmedium effort)
**Problem:** `ContextualZoomNode` uses `useViewport()` from React Flow. Viewport (zoom/pan) updates can trigger re-renders of **every** contextual node when zoom crosses the compact threshold.
**Options:**
- **A. Subscribe only near threshold**
If the library allows, subscribe to viewport only when zoom is near `CONTEXTUAL_ZOOM_THRESHOLD` so small zoom changes dont re-render all nodes. If not, consider throttling viewport updates before passing to context.
- **B. Use a single “display mode” in context**
One component (e.g. canvas container) subscribes to viewport and sets a value like `displayMode: 'compact' | 'full'` in context. Nodes only consume that enum; they dont subscribe to raw zoom. Fewer subscribers and simpler logic.
- **C. Keep current behavior but ensure nodes are memoized**
With `createContextualNode`, the inner node is already wrapped (e.g. via `createAbstractNodeComponent`). Ensure the outer wrapper doesnt break memo (e.g. avoid passing new object/function refs from viewport into the inner node).
**Pattern:** **Facade / single subscriber** — one place turns “viewport” into a coarse decision (e.g. compact vs full); the rest of the tree depends only on that decision.
---
## 6. React Flow configuration (medium impact, low effort)
**Problem:** Defaults may render or update more than needed for large graphs.
**Options:**
- **A. Enable `onlyRenderVisibleElements`**
When supported and stable in your version, enable it so nodes (and optionally edges) outside the viewport are not rendered. Best combined with fixed node dimensions and explicit handle positions so edges still draw correctly when nodes are off-screen.
- **B. Set `nodeOrigin`**
If you use a consistent origin (e.g. top-left) for all nodes, set `nodeOrigin` so React Flow doesnt have to infer it; can help with layout and hit-testing.
- **C. Reduce MiniMap/Background work**
If the minimap or background is expensive, consider making them optional (e.g. only when node count &gt; N) or simplifying their rendering (e.g. simpler background pattern, minimap with lower refresh rate).
**Pattern:** **Use platform features** — lean on React Flows built-in options (visibility culling, nodeOrigin) before adding custom virtualization.
---
## 7. Heavy node components (e.g. RenderingNode) (medium impact, medium effort)
**Problem:** Nodes that run the resolve → render pipeline (e.g. `useRenderingNodeState`) do a lot of work: signatures, config resolution, rendering, connection path lifecycle. They already use `useAbstractNode` and memo via `createAbstractNodeComponent`; the main cost is internal (hooks, effects, possibly re-renders when context changes).
**Options:**
- **A. Ensure they only get connection path updates when relevant**
After splitting context (§2), these nodes should only subscribe to graph + UI (and maybe a minimal connection path slice if they need to report “updating”/“paused”/“error”). They shouldnt re-render on every path tick.
- **B. Lazy or deferred computation**
For heavy useMemos (e.g. `buildSourceSignatures`), consider `useDeferredValue` or moving work off the critical path (e.g. in a worker or requestIdleCallback) if the UI can show a stale state briefly.
- **C. Virtualize or hide when not visible**
Combined with `onlyRenderVisibleElements` or a custom “nodes in viewport” list, heavy nodes that are off-screen dont mount at all, so their hooks and effects dont run.
**Pattern:** **Defer and cull** — avoid running expensive logic for nodes that are not visible or not relevant to the current interaction.
---
## 8. Node types and handlers (lower impact, low effort)
**Problem:** `nodeTypes` and `edgeTypes` are already built with `useMemo` in `CanvasPage`. Handlers are mostly `useCallback`. Remaining issues are usually unstable references or unnecessary dependencies.
**Options:**
- **A. Keep `nodeTypes` / `edgeTypes` stable**
Ensure `getRegisteredNodeTypes()` isnt returning new array/object references every time; if it is, memoize at the registry level or in the hook that builds `nodeTypes`.
- **B. Ensure `isValidConnection` doesnt close over changing `nodes`**
You already use `nodes` in `isValidConnection`; thats correct for validation. If this callback is passed in context and triggers many re-renders, consider moving it to a ref (e.g. `isValidConnectionRef.current`) so the context value doesnt change when `nodes` changes, and the validator always reads the latest nodes from the ref. Then nodes/edges that only need `isValidConnection` dont re-render on every graph change.
- **C. Memoize `defaultEdgeOptions` and `snapGrid`**
You already have `defaultEdgeOptions = useMemo(() => ({ type: 'animated' }), [])` and `SNAP_GRID` constant. Keep that pattern for any other object/array props passed to `<ReactFlow>`.
**Pattern:** **Stable references** — any prop or context value that is an object or function should be memoized or stored in a ref so that consumers dont re-render unnecessarily.
---
## 9. Connection path hook (lower impact, already partially optimized)
**Problem:** `useCanvasConnectionPath` derives `connectionPathNodeIds`, `connectionPathPausedSegmentNodeIds`, and `connectionPathActiveSegmentNodeIds` with `useMemo`; batching with `requestAnimationFrame` is already used for trigger updates. The main cost is that when this state changes, every edge (and possibly nodes using path role) re-renders if they all consume the same context.
**Options:**
- **A. After splitting context (§2), only edges (and path-aware nodes) subscribe to connection path context**
Then path updates no longer force re-renders of nodes that dont care about path.
- **B. Keep derived sets in refs for equality checks**
If the same set of IDs is produced repeatedly, avoid updating state (or context) when the set content is equal (e.g. compare `Array.from(set).sort().join(',')` or use a stable serialization) so that consumers dont see a “new” reference and re-render.
**Pattern:** **Stable outputs** — when feeding context or state, avoid new object/set/array references when the logical value hasnt changed.
---
## Suggested order of implementation
1. **Quick wins:** §1 (stabilize `nodes`), §3A (memoize `AnimatedEdge`), §6 (React Flow options), §8 (stable refs/callbacks).
2. **High leverage:** §2 (split FlowContext), then §3B/3C (edges consume only path state or `data`).
3. **Scalability:** §4 (history/save), §5 (viewport/contextual zoom), §7 (heavy nodes and visibility).
This order keeps design patterns (context split, minimal subscription, stable references) consistent and sets you up for further scalability (e.g. more nodes, more edges, more complex node content) without large rewrites.

View File

@@ -0,0 +1,218 @@
# Canvas state: design pattern for controlled, centralized, debuggable flow
This doc proposes a **store + commands + selectors** pattern so canvas state is:
- **Controlled** every change goes through one place
- **Centralized** one store holds graph, path, and UI slices
- **Predictable** same action → same state transition; easy to reason about
- **Easier to debug** log commands, inspect store, optional time-travel
It complements [CANVAS_PERFORMANCE_OPTIONS.md](./CANVAS_PERFORMANCE_OPTIONS.md) and [state.ts](../src/lib/graph/state.ts).
---
## 1. Core idea: single store + commands + selectors
### 1.1 Single store (one source of truth)
Keep all canvas-related state in **one store** with **slices**:
```
Store
├── graph: { nodes, edges } // current graph (with history if needed)
├── path: ConnectionPathState // trigger/updating/paused/error node IDs
├── ui: FlowUIState // renaming, fullscreen, connectionFrom, etc.
└── (optional) history: HistoryState // undo/redo stack
```
- **No duplicate sources**: nodes/edges live only in the store, not in context + refs.
- **Reads**: components get data via **selectors** (e.g. `useStore(s => s.graph.nodes)` or `useStore(selectPathForEdge, edgeId)`).
- **Writes**: only via **commands** (e.g. `dispatch({ type: 'graph/setNodes', payload: updater })`).
### 1.2 Commands (controlled mutations)
Every mutation is a **command** (action):
- **Graph**: `graph/setNodes`, `graph/setEdges`, `graph/applySilent` (position), `graph/undo`, `graph/redo`
- **Path**: `path/addTrigger`, `path/startUpdate`, `path/endUpdate`, `path/setPaused`, `path/setError`
- **UI**: `ui/setRenaming`, `ui/setFullscreen`, `ui/setConnectionFrom`
Benefits:
- **Predictable**: one command → one reducer → one new state; no scattered `setState` in hooks.
- **Traceable**: log every command (and payload) in dev; replay or inspect.
- **Testable**: test reducers with command + prev state → next state.
- **Time-travel (optional)**: store past states or inverse deltas per command for debug UI.
### 1.3 Selectors (derived state and subscriptions)
**Selectors** are pure functions `(state) => value`. They:
- **Derive** values (e.g. path node IDs from trigger/updating/paused).
- **Scope** data (e.g. “incoming edges for node X”, “connection status for edge Y”).
- **Stabilize** references when the logical value hasnt changed (e.g. same path IDs → same Set reference).
Components **subscribe via selectors**:
- `useStore(selectGraph)` → re-render when `graph` slice changes.
- `useStore(selectPathNodeIds)` → re-render only when path node IDs change.
- `useStore(selectConnectionStatusForEdge, edgeId)` → re-render only when that edges status changes.
So:
- **Centralized**: all reads go through the store.
- **Predictable**: same state in → same selector out.
- **Performance**: only components whose selected value changed re-render (with a store that supports shallow equality, e.g. Zustand).
---
## 2. Data structures
### 2.1 Store shape (TypeScript)
```ts
// Slices match current concepts; easy to migrate from existing state.ts + useCanvasConnectionPath.
interface CanvasStore {
graph: {
nodes: AppNode[]
edges: AppEdge[]
}
path: ConnectionPathState // from state.ts
ui: FlowUIState
// optional, for undo/redo
_history?: {
past: HistoryDelta[]
future: HistoryDelta[]
}
}
```
### 2.2 Commands (discriminated union)
```ts
type CanvasCommand =
| { type: 'graph/setNodes'; payload: AppNode[] | ((prev: AppNode[]) => AppNode[]) }
| { type: 'graph/setEdges'; payload: AppEdge[] | ((prev: AppEdge[]) => AppEdge[]) }
| { type: 'graph/applySilent'; payload: (prev: GraphState) => GraphState }
| { type: 'path/addTrigger'; payload: string }
| { type: 'path/startUpdate'; payload: string }
| { type: 'path/endUpdate'; payload: string }
| { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } }
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
| { type: 'ui/setRenaming'; payload: string | null }
| { type: 'ui/setFullscreen'; payload: string | null }
// ...
```
Single dispatcher:
```ts
function dispatch(cmd: CanvasCommand): void
```
### 2.3 Selectors (examples)
```ts
// Raw slices
const selectGraph = (s: CanvasStore) => s.graph
const selectPath = (s: CanvasStore) => s.path
// Stable derived path sets (same ref if same IDs)
const selectPathNodeIds = (s: CanvasStore) => getPathNodeIds(s.graph.edges, s.path...)
// Per-edge status (for AnimatedEdge) only changes when this edges status changes
const selectConnectionStatusForEdge = (s: CanvasStore, source: string, target: string) =>
getConnectionStatus({ source, target, pathNodeIds: s.path.connectionPathNodeIds, ... })
// Per-node: “am I on path?” (for BaseNode)
const selectPathRoleForNode = (s: CanvasStore, nodeId: string) =>
getConnectionPathRole(nodeId, s.path)
```
Use with a store that supports **selector + equality** so components only re-render when the selected value actually changes (e.g. Zustands `useStore(selector, shallowEqual)` or custom `useSelector`).
---
## 3. Why this helps
| Goal | How the pattern helps |
|------|------------------------|
| **Controlled** | All writes go through `dispatch(cmd)`. No ad-hoc `setState` in hooks or context. |
| **Centralized** | One store; no split between context, refs, and local state for the same concept. |
| **Predictable** | One command → one reducer → one new state. Order of updates is explicit. |
| **Easier to debug** | Log commands; inspect store (e.g. Redux DevTools or a simple `store.getState()` logger); optional time-travel by replaying or reverting commands. |
| **Fewer redraws** | Selectors + equality checks mean components only re-render when their slice or derived value changes. |
| **Clear data flow** | Data flow is “store → selectors → components” and “events → commands → store”; no implicit propagation. |
---
## 4. Implementation options
### Option A: Zustand (recommended for React)
- **Store**: `create<CanvasStore>()` with a `dispatch` that applies commands and updates the store.
- **Selectors**: `useCanvasStore(selectPathNodeIds)` etc.; Zustand re-renders only when the selected value changes (with shallow or custom equality).
- **Commands**: either one `setState` that takes a reducer, or a separate `dispatch` that maps commands to `setState` calls.
- **Debug**: middleware that logs commands and state (or use Redux DevTools with a small adapter).
### Option B: Redux Toolkit
- **Store**: one RTK store; slices: `graph`, `path`, `ui`.
- **Commands**: RTK actions; reducers are pure and easy to test.
- **Selectors**: `createSelector` for derived state; `useSelector` for subscriptions.
- **Debug**: Redux DevTools out of the box (time-travel, action log, state diff).
### Option C: Minimal custom store (no new deps)
- **Store**: a single `useReducer` (or `useState` + reducer) at the top (e.g. CanvasPage or a provider).
- **Commands**: dispatch to the reducer; reducer returns new state by slice.
- **Selectors**: pass store (or state) to a `useSelector(store, selector, equality)` hook that subscribes and only re-renders when the selected value changes (e.g. by comparing with `Object.is` or shallow compare).
- **Debug**: log `dispatch` and state in dev; optional snapshot history in the reducer.
---
## 5. Migration path from current setup
1. **Introduce the store** (e.g. Zustand or RTK) next to existing context; keep feeding React Flow and current consumers from the store so behavior stays the same.
2. **Move graph state** from `useGraphStateWithHistory` into the store (graph slice + history if needed); keep `setNodes`/`setEdges` as commands that update the store.
3. **Move path state** from `useCanvasConnectionPath` into the store (path slice); replace path context with `useStore(selectPath...)` or per-edge/per-node selectors.
4. **Move UI state** from CanvasPage `useState` into the store (ui slice); replace FlowUIContext with store selectors.
5. **Remove redundant context** (GraphContext, ConnectionPathContext, FlowUIContext) once all reads go through selectors and all writes through commands.
6. **Add logging / DevTools** for commands and state; add optional time-travel if desired.
This can be done slice-by-slice (e.g. path first, then graph, then UI) to keep changes small and testable.
---
## 6. Implementation (Zustand)
The store is implemented under `frontend/src/app/canvas/`:
| File | Purpose |
|------|---------|
| `canvasStore.types.ts` | `CanvasStore`, `CanvasCommand`, slice types |
| `canvasStore.reducer.ts` | Pure reducer + `initialCanvasStore` |
| `canvasStore.selectors.ts` | Selectors (graph, path derived sets, per-edge status, per-node role) |
| `canvasStore.ts` | Zustand store, `getCanvasStore()`, `dispatchCanvasCommand()`, `useCanvasStore()`, `useCanvasStoreDispatch()`; dev logging of commands |
| `canvasStore.index.ts` | Re-exports for consumers |
| `canvasStore.test.ts` | Test suite (reducer, selectors, store integration) |
**Run tests:** `npm run test:run` (or `npm run test` for watch) in `frontend/`.
**Usage:** Import from `@/app/canvas/canvasStore` or `@/app/canvas/canvasStore.index`:
- `dispatchCanvasCommand({ type: 'graph/setNodes', payload: nodes })`
- `useCanvasStore(selectPathNodeIds)` or `useCanvasStore(selectConnectionStatusForEdge, ...)` (selectors take state; for per-edge/per-node use a factory selector in the component)
- `useCanvasStoreDispatch()` for stable dispatch in components
Migration from existing context: feed the store from CanvasPage (or sync store ↔ existing hooks) and gradually replace context consumers with `useCanvasStore(selector)` and `dispatchCanvasCommand`. See §5 migration path.
---
## 7. Summary
- **Pattern**: one **store** (graph + path + ui), **commands** for all mutations, **selectors** for reads and derived state.
- **Data structures**: flat slices in the store; commands as a discriminated union; selectors as pure functions (state [, args]) → value.
- **Benefits**: controlled, centralized, predictable, easier to debug, and fewer unnecessary redraws via selector-based subscriptions.
- **Concrete next step**: migrate one consumer (e.g. AnimatedEdge) to `useCanvasStore(selectConnectionStatusForEdge)` with a per-edge selector and `dispatchCanvasCommand` for path updates; then remove its ConnectionPathContext dependency.

View File

@@ -0,0 +1,77 @@
# Codebase simplification and design patterns
This doc summarizes recent improvements and suggested next steps for readability, extension, and consistency.
## Done
### 1. Single place for template/reachability (DRY)
- **Added** `lib/graph/templateRefs.ts`: `isReachable`, `resolveExtendsRef`, `getTemplateRefs`.
- **Refactored** `config/renderingLogic.ts` and `useRenderingNodeState.ts` to use these helpers instead of duplicating the same logic.
- **Pattern:** Extract shared pure helpers into a small lib module; keep call sites thin and consistent.
### 2. Naming and comments
- **Renamed** `outputMenuRegistry.tsx``outputMenuHandlers.tsx` (no registry, only helpers).
- **Updated** `rendering.ts` and `sourceRenderingLogic.ts`: output menu is described as coming from the node descriptor (`getOutputMenuContent`), not a separate registry.
- **Documented** `NodeMenubar`: extra content can come from props or from the descriptor (`getNodeMenuExtraContent`).
- **Documented** `nodeTypes.ts`: clarifies React Flow types vs node type id (nodeRegistry).
### 3. Central pipeline entry
- **rendering.ts** documents the 3-step pipeline, how to add a source/output type, and points to `templateRefs.ts` for shared helpers.
### 4. Rendering signatures (pure module)
- **Added** `lib/graph/renderingSignatures.ts`: `buildConnectedNodeIds`, `buildSourceSignatures` (connectedNodeIds + all five signatures + sourceSignature).
- **Refactored** `useRenderingNodeState` to call `buildSourceSignatures` in a single useMemo; hook is shorter and signature logic is testable in isolation.
### 5. Canvas split
- **CanvasContextMenuContent** (`app/canvas/CanvasContextMenuContent.tsx`): context menu content (Create Node grouped by classification, Paste). CanvasPage passes `onCreateNode` and `onPaste`.
- **useCanvasConnectionPath** (`app/canvas/useCanvasConnectionPath.ts`): all connection-path state and callbacks (updating/trigger/paused/error node ids, path node ids, start/end update, add/remove paused/error). CanvasPage calls the hook with `edges` and passes the result into FlowContext.
### 6. useCanvasGraph and canvas graph utils
- **canvasGraphUtils.ts**: `getExampleGraph()`, `getInitialGraph(projectId)` (load from storage or return example). Example nodes/edges live here.
- **useCanvasGraph(projectId)**: wraps `useGraphStateWithHistory` with initial graph from `getInitialGraph(projectId)` and debounced save to storage when `projectId` is set. CanvasPage uses this instead of inline state + persistence.
### 7. Config types registry
- **configTypes.ts**: `configTypeRegistry` (Map), `registerConfigType(type)`, `getConfigTypes()`, `getConfigTypeIds()`, `registerBuiltinConfigTypes()`. Built-in types are in `BUILTIN_CONFIG_TYPES` and registered at app init. `getConfigType(id)` looks up in the registry; `getConfigTypeId(data)` uses registered ids. ConfigNode uses `getConfigTypes()` instead of `CONFIG_TYPES`. New output types can call `registerConfigType()` without editing the core array.
### 8. Consistent NodeLike / EdgeLike
- **templateRefs.ts**: `NodeLike` includes `type?: string`; both types documented. Single source of truth for minimal node/edge shape in the graph lib.
- **sourceRenderingLogic**: context uses `NodeLike[]` and `EdgeLike[]` from templateRefs.
- **renderingSignatures**: imports and re-exports `NodeLike` / `EdgeLike` from templateRefs; no local duplicate types.
- **config/renderingLogic**: uses context nodes/edges directly (no casts); `setVarInContext(src: NodeLike)`.
- **useRenderingNodeState**: casts to `NodeLike[]` / `EdgeLike[]` when calling `buildSourceSignatures` (types from renderingSignatures).
### 9. Graph state, connection state, and node display state
- **lib/graph/state.ts**: Central state contracts and flow doc.
- **GraphState** in-memory nodes + edges (useGraphStateWithHistory, persisted by useCanvasGraph).
- **ConnectionPathState** type describing the slice that drives edge status and path animation (trigger/updating/paused/error sets).
- **NodeDisplayStatus** `'initial' | 'loading' | 'success' | 'error'` for node UI; **getNodeDisplayStatus({ loading, error, hasContent })**.
- **StoredGraphState** shape for save/load (version + nodes + edges).
- **FlowContext**: Module doc splits value into (1) Graph state, (2) Connection path state, (3) UI state. Same flat props, clearer sections.
- **useGraphStateWithHistory**: JSDoc explains history (past/future), setNodes vs setNodesSilent, setStateImmediate.
- **recollectionGraphStorage**: Uses **StoredGraphState** from state.ts; re-exports type. Doc references state flow.
- **useRenderingNodeState**: Returns **displayStatus** (for NodeStatusIndicator/empty/error UI) and **lifecycle** (updating/error/paused for useSyncConnectionStatus). Hook calls useSyncConnectionStatus(id, state.lifecycle). Type includes RenderingNodeLifecycle.
- **RenderingNode**: Uses **state.displayStatus** for NodeStatusIndicator instead of computing status locally.
- **nodeLifecycle** and **connectionStatus**: Docs reference state.ts for overall state flow.
## Design patterns in use
| Pattern | Where |
|----------------|--------------------------------------------|
| **Registry** | nodeRegistry, sourceRenderingLogic |
| **Builder** | nodeTypeBuilder (descriptor per node type) |
| **Pipeline** | Resolve → Render → Display (rendering.ts) |
| **Strategy** | Source logic per node type; output menu per descriptor |
| **Shared helpers** | templateRefs, outputMenuHandlers, renderingUtils |
## Suggested next steps
- **CanvasPage**: Optional further split (e.g. move connection validation or node/edge change handlers into a hook) if the file grows again.

View File

@@ -0,0 +1,99 @@
# Junior Developer Quickstart
## 1. Overview
This guide explains how to set up the development environment, run the application, and make your first contribution.
## 2. Prerequisites
- **Node.js** (v18 or later)
- **pnpm** (package manager)
- **Docker** (for backend services, optional)
- **Git** (version control)
## 3. Repository Setup
```bash
# Clone the repository
git clone https://github.com/your-org/zui.git
cd zui
# Install dependencies
pnpm install
```
## 4. Running the Application
### Frontend
```bash
pnpm dev
```
Open <http://localhost:3000> to see the app.
### Backend
```bash
pnpm start
```
The backend API will be available at <http://localhost:5000>.
## 5. Making Your First Contribution
1. **Create a feature branch**:
```bash
git checkout -b feat/your-first-contribution
```
2. **Make a small change**:
- Improve a README typo.
- Add a missing JSDoc comment.
- Fix a minor bug.
3. **Run the appropriate tests** to ensure your change doesn't break anything:
```bash
pnpm test
```
4. **Commit your changes** with a clear message:
```bash
git commit -am "feat: brief description of change"
```
5. **Push the branch** to GitHub:
```bash
git push origin feat/your-first-contribution
```
6. **Open a Pull Request** on GitHub, linking the relevant issue.
## 6. Coding Standards
- Follow the patterns in **ARCHITECTURE.md** and **CONTRIBUTING.md**.
- Use TypeScript with strict mode.
- Add JSDoc for all public APIs.
- Keep changes small and focused.
## 7. Helpful Links
- **ARCHITECTURE.md** - High-level architecture overview.
- **PERFORMANCE_IMPROVEMENTS.md** - Performance improvement plan.
- **CODE_REVIEW_CHECKLIST.md** - Checklist for reviewers.
## 8. FAQ
**Q:** Do I need to write tests?
**A:** For bug fixes and new features, yes. Unit tests and integration tests are located under `frontend/src/app/**/*.test.tsx`.
**Q:** How do I run the linter?
**A:** `pnpm lint`.
**Q:** Where can I find more documentation?
**A:** See the `docs/` directory and the repository wiki.
---\n*Happy coding!*

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
<title>ZOË | Kosmos</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

19
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
# Serve SPA and proxy /api to backend (Docker Compose).
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}

File diff suppressed because it is too large Load Diff

69
frontend/package.json Normal file
View File

@@ -0,0 +1,69 @@
{
"name": "reactflow-shadcn-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@blocknote/core": "0.36.1",
"@blocknote/react": "0.36.1",
"@blocknote/shadcn": "0.36.1",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@types/prismjs": "^1.26.6",
"@wireweave/core": "^2.6.0",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"graphology": "^0.26.0",
"graphology-traversal": "^0.3.1",
"lucide-react": "^0.577.0",
"markdown-to-jsx": "^9.7.9",
"marked": "^17.0.4",
"next-themes": "^0.4.6",
"nunjucks": "^3.2.4",
"prism-react-renderer": "^2.4.1",
"prismjs": "^1.30.0",
"react": "18.2.0",
"react-arborist": "^3.4.3",
"react-dom": "18.2.0",
"react-router-dom": "^6.28.0",
"react-simple-code-editor": "^0.14.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.2"
},
"devDependencies": {
"@testing-library/react": "^16.0.0",
"@types/node": "^25.3.3",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"shadcn": "^4.0.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0",
"vite": "^7.3.1",
"vitest": "^2.1.6"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1,21 @@
/**
* 404 page for unknown routes. Shown when no route matches.
*/
import React from 'react'
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
export function NotFoundPage() {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-background p-8">
<h1 className="text-2xl font-semibold text-foreground">Page not found</h1>
<p className="text-sm text-muted-foreground">
The page youre looking for doesnt exist or has been moved.
</p>
<Button asChild variant="default">
<Link to="/recollections">Back to Recollections</Link>
</Button>
</div>
)
}

View File

@@ -0,0 +1,62 @@
/**
* Context menu content for the canvas: Create Node (grouped by classification) and Paste.
* Used inside CanvasPage so the menu structure lives in one place.
*/
import React from 'react'
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuGroup,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from '@/components/ui/context-menu'
import { ClipboardPaste } from 'lucide-react'
import { getRegisteredNodeTypesGroupedByClassification } from '@/lib/graph/nodeRegistry'
export type CanvasContextMenuContentProps = {
onCreateNode: (type: string) => void
onPaste: () => void
}
export function CanvasContextMenuContent({ onCreateNode, onPaste }: CanvasContextMenuContentProps) {
const groups = getRegisteredNodeTypesGroupedByClassification()
return (
<ContextMenuContent className="w-48" aria-label="Canvas menu: create node or paste">
<ContextMenuGroup>
<ContextMenuSub>
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-44">
{groups.map(
(group, groupIndex) =>
group.types.length > 0 && (
<React.Fragment key={group.classification}>
{groupIndex > 0 && <ContextMenuSeparator />}
<ContextMenuGroup>
<ContextMenuLabel className="text-muted-foreground">
{group.label}
</ContextMenuLabel>
{group.types.map((desc) => (
<ContextMenuItem key={desc.id} onSelect={() => onCreateNode(desc.id)}>
{desc.menuIcon}
{desc.menuLabel}
</ContextMenuItem>
))}
</ContextMenuGroup>
</React.Fragment>
)
)}
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onPaste}>
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
)
}

View File

@@ -0,0 +1,813 @@
/**
* Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export.
* Rendered inside the platform when a recollection is selected.
*/
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import {
ReactFlow,
ReactFlowProvider,
Controls,
Background,
MiniMap,
addEdge,
applyNodeChanges,
applyEdgeChanges,
useNodesInitialized,
useReactFlow,
type Node,
type Edge,
type Connection,
type ColorMode,
type NodeChange,
type EdgeChange,
} from '@xyflow/react'
import { AnimatedEdge } from '@/components/graph/AnimatedEdge'
import {
GraphContext,
ConnectionPathContext,
FlowUIContext,
} from '@/lib/graph/flowContext'
import { useTheme } from '@/lib/themeContext'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { useRecollectionActions } from '@/app/recollections/layout/RecollectionActionsContext'
import type { StoredGraphState } from '@/app/recollections/state/recollectionStore'
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog'
import { FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
import {
getRegisteredNodeTypes,
getRegisteredNodeTypeIds,
getDefaultStyle,
getNodeType,
getConnectionLabelForTarget,
isConnectionAllowed,
} from '@/lib/graph/nodeRegistry'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { toast } from 'sonner'
import { RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
const SNAP_GRID: [number, number] = [15, 15]
const DUPLICATE_OFFSET = { x: 30, y: 30 }
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0],
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
})
function FlowFitViewOnLoad({ disabled }: { disabled?: boolean }) {
const nodesInitialized = useNodesInitialized()
const { fitView } = useReactFlow()
React.useEffect(() => {
if (disabled || !nodesInitialized) return
fitView?.({ duration: 200 })
}, [disabled, nodesInitialized, fitView])
return null
}
function FocusNodeOnLoad({ focusNodeId }: { focusNodeId?: string }) {
const nodesInitialized = useNodesInitialized()
const { getNodes, setCenter, screenToFlowPosition, project } = useReactFlow()
React.useEffect(() => {
if (!focusNodeId || !nodesInitialized) return
// Small delay so node DOM and layout are fully ready before centering/zooming.
const timer = setTimeout(() => {
const nodes = getNodes()
const node = nodes.find((n) => n.id === focusNodeId)
if (!node) return
const el = document.querySelector(
`.react-flow__node[data-id="${focusNodeId}"]`
) as HTMLElement | null
if (el) {
const rect = el.getBoundingClientRect()
const screenCenter = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
const toFlow = screenToFlowPosition ?? project
if (toFlow) {
const flowCenter = toFlow(screenCenter)
setCenter(flowCenter.x, flowCenter.y, { duration: 400, zoom: 1.8 })
return
}
}
// Fallback: center using node position + dimensions from React Flow state.
const anyNode = node as Node & {
positionAbsolute?: { x: number; y: number }
width?: number
height?: number
}
const basePos = anyNode.positionAbsolute ?? anyNode.position ?? { x: 0, y: 0 }
const width = anyNode.width ?? 0
const height = anyNode.height ?? 0
const centerX = basePos.x + width / 2
const centerY = basePos.y + height / 2
setCenter(centerX, centerY, { duration: 400, zoom: 1.8 })
}, 150)
return () => clearTimeout(timer)
}, [focusNodeId, nodesInitialized, getNodes, setCenter, screenToFlowPosition, project])
return null
}
type FullscreenNodeOverlayProps = {
nodeId: string
nodes: AppNode[]
onClose: () => void
}
function FullscreenNodeOverlay({ nodeId, nodes, onClose }: FullscreenNodeOverlayProps) {
const open = nodeId != null
const node = nodes.find((n) => n.id === nodeId)
if (!nodeId) return null
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent
className="max-w-[94vw] w-[94vw] max-h-[90vh] h-[90vh] p-0 gap-0 overflow-hidden flex flex-col border rounded-lg shadow-xl bg-card"
aria-describedby={undefined}
hideCloseButton
>
<DialogTitle className="sr-only">Node: {node?.id ?? nodeId}</DialogTitle>
{node && node.type && (
<FullscreenNodeContent node={node} />
)}
</DialogContent>
</Dialog>
)
}
function FullscreenNodeContent({ node }: { node: AppNode }) {
const descriptor = getNodeType(node.type ?? '')
const NodeComponent = descriptor?.component as React.ComponentType<{
id: string
data: Record<string, unknown>
type?: string
selected?: boolean
width?: number
height?: number
}> | undefined
if (!NodeComponent) return null
const w = typeof window !== 'undefined' ? Math.round(window.innerWidth * 0.94) : 1200
const h = typeof window !== 'undefined' ? Math.round(window.innerHeight * 0.9) : 800
const fullscreenNodeForStore: AppNode = {
...node,
position: node.position ?? { x: 0, y: 0 },
style: { ...(node.style as object), width: w, height: h },
}
return (
<div className="flex-1 min-h-0 overflow-hidden flex">
<ReactFlowProvider initialNodes={[fullscreenNodeForStore]} initialEdges={[]}>
<NodeComponent
id={node.id}
data={(node.data as Record<string, unknown>) ?? {}}
type={node.type}
selected={false}
width={w}
height={h}
/>
</ReactFlowProvider>
</div>
)
}
export type CanvasPageProps = {
/** Optional recollection id for per-recollection graph loading */
recollectionId?: string
/** Optional node id to focus when the canvas loads (e.g. from Katalogos artifacts). */
focusNodeId?: string
}
export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
const { theme } = useTheme()
const { showMinimap } = usePlatform()
const {
nodes,
edges,
setNodes,
setEdges,
setNodesSilent,
applyGraph,
saveForDragEnd,
commitDragEnd,
undo,
redo,
canUndo,
canRedo,
setStateImmediate,
save,
saveStatus,
} = useCanvasGraph(recollectionId)
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
const wrapperRef = useRef<HTMLDivElement | null>(null)
const canvasWrapperRef = useRef<HTMLDivElement | null>(null)
const lastClickRef = useRef<{ clientX: number; clientY: number } | null>(null)
const flowActionsRef = useRef<{ pasteAtViewportCenter: () => void; fitView: () => void } | null>(null)
const [contextTarget, setContextTarget] = React.useState<null | { type: 'canvas'; clientX: number; clientY: number }>(null)
const [lastCreatedNodeId, setLastCreatedNodeId] = React.useState<string | null>(null)
const [isPanning, setIsPanning] = React.useState(false)
const [isSelecting, setIsSelecting] = React.useState(false)
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
const graphApplyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const GRAPH_APPLY_DEBOUNCE_MS = 300
useEffect(() => {
if (graphApplyTimeoutRef.current) clearTimeout(graphApplyTimeoutRef.current)
graphApplyTimeoutRef.current = setTimeout(() => {
graphApplyTimeoutRef.current = null
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
}, GRAPH_APPLY_DEBOUNCE_MS)
return () => {
if (graphApplyTimeoutRef.current) {
clearTimeout(graphApplyTimeoutRef.current)
graphApplyTimeoutRef.current = null
}
}
}, [nodes, edges])
const connectionPath = useCanvasConnectionPathFromStore()
const nodesRef = useRef(nodes)
nodesRef.current = nodes
const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
graphRef.current.nodes = nodes
graphRef.current.edges = edges
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
const rafRef = useRef<number | null>(null)
const onNodesChange = useCallback(
(changes: NodeChange<Node>[]) => {
if (changes.length === 0) return
const pending = pendingChangesRef.current
for (const c of changes) {
const id = (c as { id?: string }).id
if (id != null) {
const i = pending.findIndex((p) => (p as { id?: string }).id === id)
if (i >= 0) pending[i] = c
else pending.push(c)
} else pending.push(c)
}
if (rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
if (toApply.length === 0) return
setNodesSilent((nds) => {
// Drop dimension-only changes that don't change the node (e.g. React Flow re-reporting on visibility).
// Avoids graph/apply and store churn when nodes become visible with onlyRenderVisibleElements.
const filtered = toApply.filter((c) => {
const ch = c as NodeChange<Node> & { type?: string; dimensions?: { width?: number; height?: number } }
if (ch.type !== 'dimensions' || ch.dimensions == null) return true
const node = nds.find((n) => n.id === (ch as { id?: string }).id)
if (!node) return true
const nw = (node as Node & { width?: number }).width
const nh = (node as Node & { height?: number }).height
return nw !== ch.dimensions.width || nh !== ch.dimensions.height
})
if (filtered.length === 0) return nds
return applyNodeChanges(filtered, nds)
})
})
}
},
[setNodesSilent]
)
const nodeTypes = useMemo(
() =>
Object.fromEntries(
getRegisteredNodeTypes().map((r) => [r.id, createContextualNode(r.component)])
),
[]
)
const edgeTypes = useMemo(() => ({ animated: AnimatedEdge }), [])
const defaultEdgeOptions = useMemo(() => ({ type: 'animated' as const }), [])
const onEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
if (changes.length === 0) return
setEdges((eds) => applyEdgeChanges(changes, eds))
},
[setEdges]
)
const onConnect = useCallback(
(params: Connection) => {
const targetType =
nodesRef.current.find((n) => n.id === params.target)?.type ?? ''
const conn = { ...params, data: { targetType } as Record<string, unknown> }
setEdges((eds) => addEdge(conn, eds))
},
[setEdges]
)
const isValidConnection = useCallback((connection: Connection | AppEdge) => {
const src = 'source' in connection ? connection.source : undefined
const tgt = 'target' in connection ? connection.target : undefined
if (typeof src !== 'string' || typeof tgt !== 'string') return false
const currentNodes = nodesRef.current
const sourceNode = currentNodes.find((n) => n.id === src)
const targetNode = currentNodes.find((n) => n.id === tgt)
const sourceType = sourceNode?.type
const targetType = targetNode?.type
if (!sourceType || !targetType) return false
if (sourceType === 'render' && targetType === 'config') {
const targetData = targetNode?.data as { configType?: string } | undefined
if (targetData?.configType !== 'markdown') return false
}
return isConnectionAllowed(sourceType, targetType, src, tgt)
}, [])
const onConnectStart = useCallback(
(
_: React.MouseEvent<Element> | React.TouchEvent<Element> | MouseEvent | TouchEvent,
params: { nodeId?: string | null; handleId?: string | null; handleType?: string | null }
) => {
if (params.handleType !== 'source' || !params.nodeId) {
setConnectionFrom(null)
return
}
setConnectionFrom({ nodeId: params.nodeId, sourceHandle: params.handleId ?? undefined })
},
[]
)
const onConnectEnd = useCallback(() => setConnectionFrom(null), [])
const onInit = useCallback((instance: unknown) => setRfInstance(instance), [])
const onMoveStart = useCallback(() => setIsPanning(true), [])
const onMoveEnd = useCallback(() => setIsPanning(false), [])
const onSelectionDragStart = useCallback(() => setIsSelecting(true), [])
const onSelectionDragStop = useCallback(() => setIsSelecting(false), [])
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
const handleLoadExample = useCallback(() => {
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
toast.success('Example loaded')
}, [setStateImmediate])
const selectedNodes = useMemo(
() => nodes.filter((n) => (n as Node & { selected?: boolean }).selected),
[nodes]
)
const handleDuplicate = useCallback(() => {
if (selectedNodes.length === 0 || !setNodes) return
setNodes((nds: Node[]) => {
const existingIds = nds.map((n) => n.id)
const toAdd: Node[] = []
for (const node of selectedNodes) {
const n = node as Node & { selected?: boolean }
const pos = n.position ?? { x: 0, y: 0 }
const newId = getNextNodeId(String(n.type), [...existingIds, ...toAdd.map((x) => x.id)])
existingIds.push(newId)
const newNode: Node = {
id: newId,
type: n.type,
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
data: typeof n.data === 'object' && n.data !== null ? { ...(n.data as object) } : n.data,
style: getDefaultStyle(String(n.type)),
}
if (
newNode.data &&
typeof newNode.data === 'object' &&
'title' in newNode.data &&
String(n.type) === 'config'
) {
; (newNode.data as Record<string, unknown>).title = `${newId}`
}
toAdd.push(newNode)
}
return nds.concat(toAdd)
})
}, [selectedNodes, setNodes])
const handleCopy = useCallback(() => {
if (selectedNodes.length !== 1) return
const node = selectedNodes[0] as Node & { selected?: boolean }
const copy = {
id: node.id,
type: node.type,
data: node.data,
position: node.position,
style: node.style,
}
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => { })
}, [selectedNodes])
const handlePaste = useCallback(() => {
flowActionsRef.current?.pasteAtViewportCenter?.()
}, [])
const { setFluxSlot, onImport } = useRecollectionActions()
const onRefreshFromStore = useCallback(
(graph: StoredGraphState) => {
const nodes = graph.nodes as AppNode[]
const edges = backfillEdgeTargetTypes(nodes, graph.edges as AppEdge[])
setStateImmediate({ nodes, edges })
},
[setStateImmediate]
)
useEffect(() => {
const slot = {
saveStatus,
onSave: recollectionId
? () => {
save()
toast.success('Saved')
}
: () => {},
canSave: Boolean(recollectionId),
undo,
redo,
canUndo,
canRedo,
onRefreshFromStore,
onDuplicate: handleDuplicate,
onCopy: handleCopy,
onPaste: handlePaste,
canDuplicate: selectedNodes.length > 0,
canCopy: selectedNodes.length === 1,
onFitView: () => flowActionsRef.current?.fitView?.(),
}
setFluxSlot(slot)
return () => setFluxSlot(null)
}, [
setFluxSlot,
saveStatus,
recollectionId,
save,
undo,
redo,
canUndo,
canRedo,
onRefreshFromStore,
handleDuplicate,
handleCopy,
handlePaste,
selectedNodes.length,
])
const graphContextValue = useMemo(
() => ({ setNodes, setEdges, graphRef, edges }),
[setNodes, setEdges, edges]
)
const connectionPathContextValue = useMemo(
() => ({
connectionPathUpdatingNodeIds: connectionPath.connectionPathUpdatingNodeIds,
connectionPathTriggerNodeIds: connectionPath.connectionPathTriggerNodeIds,
addConnectionPathTrigger: connectionPath.addConnectionPathTrigger,
connectionPathNodeIds: connectionPath.connectionPathNodeIds,
connectionPathPausedSegmentNodeIds: connectionPath.connectionPathPausedSegmentNodeIds,
connectionPathActiveSegmentNodeIds: connectionPath.connectionPathActiveSegmentNodeIds,
connectionPathPausedNodeIds: connectionPath.connectionPathPausedNodeIds,
addConnectionPathPausedNode: connectionPath.addConnectionPathPausedNode,
removeConnectionPathPausedNode: connectionPath.removeConnectionPathPausedNode,
connectionPathErrorNodeIds: connectionPath.connectionPathErrorNodeIds,
addConnectionPathError: connectionPath.addConnectionPathError,
removeConnectionPathError: connectionPath.removeConnectionPathError,
startConnectionPathUpdate: connectionPath.startConnectionPathUpdate,
endConnectionPathUpdate: connectionPath.endConnectionPathUpdate,
}),
[connectionPath]
)
const flowUIContextValue = useMemo(
() => ({
renamingNodeId,
setRenamingNodeId,
connectionFrom,
setConnectionFrom,
isValidConnection,
flowActionsRef,
fullscreenNodeId,
setFullscreenNodeId,
}),
[
renamingNodeId,
setRenamingNodeId,
connectionFrom,
setConnectionFrom,
isValidConnection,
flowActionsRef,
fullscreenNodeId,
setFullscreenNodeId,
]
)
const prevNodesRef = useRef<AppNode[]>([])
const prevNodesForFlowRef = useRef<Node[]>([])
const nodesForFlow = useMemo(() => {
const prev = prevNodesRef.current
if (nodes === prev) return prevNodesForFlowRef.current
const prevById = new Map(prev.map((n) => [n.id, n]))
const prevWrappedById = new Map(
prevNodesForFlowRef.current.map((w, i) => [prev[i]?.id, w])
)
const result = nodes.map((n) => {
const prevNode = prevById.get(n.id)
if (prevNode === n && prevWrappedById.has(n.id)) {
return prevWrappedById.get(n.id)!
}
return {
...n,
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
}
})
prevNodesRef.current = nodes
prevNodesForFlowRef.current = result
return result
}, [nodes])
const edgesForFlow = useMemo(
() =>
edges.map((e) => {
const targetType =
(typeof e.data === 'object' && e.data !== null && (e.data as Record<string, unknown>).targetType != null
? (e.data as Record<string, unknown>).targetType
: '') as string
const connectionLabel = getConnectionLabelForTarget(targetType)
const baseData =
typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
return {
...e,
data: { ...baseData, connectionLabel },
}
}),
[edges]
)
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
const target = ev.target as HTMLElement
if (target.closest('.react-flow__node')) {
ev.preventDefault()
ev.stopPropagation()
}
}, [])
const onCanvasContextMenu = useCallback((ev: React.MouseEvent) => {
ev.preventDefault()
lastClickRef.current = { clientX: ev.clientX, clientY: ev.clientY }
setContextTarget({ type: 'canvas', clientX: ev.clientX, clientY: ev.clientY })
}, [])
const getMenuPosition = useCallback(() => {
if (!rfInstance) return null
const click = contextTarget ?? lastClickRef.current
const clientX = click?.clientX ?? window.innerWidth / 2
const clientY = click?.clientY ?? window.innerHeight / 2
try {
type ScreenToFlow = (p: { x: number; y: number }) => { x: number; y: number }
const inst = rfInstance as { screenToFlowPosition?: ScreenToFlow; [k: string]: unknown }
const screenToFlow = inst.screenToFlowPosition ?? (inst['project'] as ScreenToFlow | undefined)
const p = screenToFlow?.call(rfInstance, { x: clientX, y: clientY })
return p ? snapToGrid(p.x, p.y) : null
} catch {
return snapToGrid(clientX, clientY)
}
}, [rfInstance, contextTarget])
const createNode = useCallback(
(type: string) => {
const position = getMenuPosition()
if (position == null || typeof type !== 'string') return
const existingIds = nodesRef.current.map((n) => n.id).filter((id): id is string => id != null)
const newId = getNextNodeId(type, existingIds)
const dataMap = getDefaultDataForType(type, newId)
const style = getDefaultStyle(type)
const newNode: Node = {
id: newId,
type: type as Node['type'],
position: { x: position.x, y: position.y },
data: dataMap,
style,
}
setNodes((nds) => nds.concat(newNode))
setLastCreatedNodeId(newId)
lastClickRef.current = null
setContextTarget(null)
},
[getMenuPosition, setNodes]
)
React.useEffect(() => {
if (lastCreatedNodeId == null) return
const id = lastCreatedNodeId
const raf = requestAnimationFrame(() => {
const nodeEl = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null
if (nodeEl) {
setAriaAnnouncement('Node created')
nodeEl.setAttribute('tabindex', '-1')
nodeEl.focus({ preventScroll: false })
setTimeout(() => setAriaAnnouncement(null), 1000)
}
setLastCreatedNodeId(null)
})
return () => cancelAnimationFrame(raf)
}, [lastCreatedNodeId, nodes])
const pasteNode = useCallback(async () => {
const position = getMenuPosition()
if (position == null) return
try {
const text = await navigator.clipboard?.readText()
if (!text) return
const raw = JSON.parse(text) as { id?: string; type?: string; data?: Record<string, unknown>; position?: { x: number; y: number }; style?: unknown }
const validIds = getRegisteredNodeTypeIds()
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
const nodeType = raw.type
setNodes((nds) => {
const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null)
const newId = getNextNodeId(nodeType, existingIds)
const data = raw.data != null && typeof raw.data === 'object' ? { ...raw.data } : {}
if (nodeType === 'config' && data && 'title' in data) data.title = `config-${newId}`
const style = getDefaultStyle(nodeType)
const newNode: Node = {
id: newId,
type: nodeType as Node['type'],
position: { x: position.x, y: position.y },
data,
style,
}
return nds.concat(newNode)
})
lastClickRef.current = null
setContextTarget(null)
} catch {
/* ignore */
}
}, [getMenuPosition, setNodes])
const deleteNode = useCallback(
(id: string | undefined) => {
if (!id) return
applyGraph(({ nodes: nds, edges: eds }) => ({
nodes: nds.filter((n) => n.id !== id),
edges: eds.filter((e) => e.source !== id && e.target !== id),
}))
setContextTarget(null)
},
[applyGraph]
)
return (
<div
className="reactflow-wrapper flex flex-col h-full"
ref={wrapperRef}
onContextMenuCapture={onContextMenuCapture}
onContextMenu={onCanvasContextMenu}
style={{ position: 'relative' }}
>
<div role="status" aria-live="polite" aria-atomic className="sr-only">
{ariaAnnouncement}
</div>
<div className="flex-1 min-h-0 relative flex flex-col">
<div className="flex-1 min-h-0 flex flex-col">
<GraphContext.Provider value={graphContextValue}>
<ConnectionPathContext.Provider value={connectionPathContextValue}>
<FlowUIContext.Provider value={flowUIContextValue}>
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={canvasWrapperRef}
className="flex-1 min-h-0 w-full relative"
role="application"
aria-label="Graph canvas"
tabIndex={0}
>
{nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="pointer-events-auto rounded-lg border border-border bg-background/95 dark:bg-background/90 shadow-sm">
<Empty className="p-6 md:p-8">
<EmptyHeader>
<EmptyMedia variant="icon">
<CircleDotDashed className="size-6" />
</EmptyMedia>
<EmptyTitle>Start adding a new node!</EmptyTitle>
<EmptyDescription>
Rightclick to add nodes. <br />
Import a recollection or paste a node.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
<Button onClick={onImport} variant="outline" size="sm">
<FolderOpen className="size-4" />
Import
</Button>
<Button onClick={handleLoadExample} variant="outline" size="sm">
<FileStack className="size-4" />
Load example
</Button>
</EmptyContent>
</Empty>
</div>
</div>
)}
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
<ViewportDisplayProvider>
<FlowFitViewOnLoad disabled={Boolean(focusNodeId)} />
<FocusNodeOnLoad focusNodeId={focusNodeId} />
<FlowKeyboardShortcuts />
<ReactFlow
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
nodes={nodesForFlow}
edges={edgesForFlow}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
onSelectionDragStart={onSelectionDragStart}
onSelectionDragStop={onSelectionDragStop}
isValidConnection={isValidConnection}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={defaultEdgeOptions}
colorMode={theme as ColorMode}
minZoom={0.1}
maxZoom={2}
snapToGrid
snapGrid={SNAP_GRID}
fitView
onInit={onInit}
nodeDragThreshold={1}
onlyRenderVisibleElements
nodeOrigin={[0, 0]}
nodesDraggable
nodesConnectable
elementsSelectable
panOnScroll
zoomOnScroll
zoomActivationKeyCode="Meta"
panOnDrag={[1]}
selectionOnDrag
>
{/* BackgroundVariant from @xyflow/system expects enum; 'dots' is valid at runtime */}
<Background variant={'dots' as React.ComponentProps<typeof Background>['variant']} gap={20} />
<div role="group" aria-label="Canvas controls: zoom and fit view">
<Controls />
</div>
{showMinimap && nodes.length > 5 && (
<div role="region" aria-label="Minimap: overview of the graph">
<MiniMap />
</div>
)}
</ReactFlow>
</ViewportDisplayProvider>
</ReactFlowProvider>
</div>
</ContextMenuTrigger>
<CanvasContextMenuContent onCreateNode={createNode} onPaste={pasteNode} />
</ContextMenu>
{fullscreenNodeId && (
<FullscreenNodeOverlay
nodeId={fullscreenNodeId}
nodes={nodes}
onClose={() => setFullscreenNodeId(null)}
/>
)}
</FlowUIContext.Provider>
</ConnectionPathContext.Provider>
</GraphContext.Provider>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,124 @@
/**
* Contextual zoom: when zoomed out past a threshold, nodes render as compact
* icon-only views using each type's menuIcon from the node registry.
* @see https://reactflow.dev/examples/interaction/contextual-zoom
*/
import React, { useContext } from 'react'
import { getNodeType, getDefaultStyle } from '@/lib/graph/nodeRegistry'
import { ViewportDisplayContext } from '@/app/canvas/ViewportDisplayContext'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import { cn } from '@/lib/utils'
const MIN_ZOOM = 0.1
const MAX_ZOOM = 2
/** When zoom <= this value, show compact icon view (bottom ~30% of zoom range). */
export const CONTEXTUAL_ZOOM_THRESHOLD = MIN_ZOOM + 0.1 * (MAX_ZOOM - MIN_ZOOM)
/** Handle id per type for compact view (input, output). */
const COMPACT_HANDLES: Record<string, { input?: string; output?: string }> = {
config: { input: 'ain', output: 'out' },
render: { input: 'ain' },
variable: { output: 'out' },
function: { input: 'in', output: 'out' },
data: { output: 'out' },
}
type NodeProps = {
id: string
type?: string
data?: Record<string, unknown>
selected?: boolean
width?: number
height?: number
[key: string]: unknown
}
function CompactNodeView({ id, type, selected, width, height }: NodeProps) {
const descriptor = type ? getNodeType(type) : undefined
const icon = descriptor?.menuIcon ?? null
const handles = type ? COMPACT_HANDLES[type] ?? { output: 'out' } : {}
const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 }
const w = width ?? defaultStyle.width
const h = height ?? defaultStyle.height
return (
<div
className={cn(
'flex flex-col rounded-md border bg-card text-card-foreground transition-[border-color,box-shadow] duration-200',
'border-input hover:ring-1',
selected &&
'border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]'
)}
style={{
width: w,
height: h,
minWidth: w,
minHeight: h,
}}
data-selected={selected}
>
{handles.input && (
<InputHandle id={handles.input} nodeId={id} />
)}
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-3 pb-3 pt-2">
<span className="flex shrink-0 items-center justify-center [&>svg]:h-14 [&>svg]:w-14" aria-hidden>
{icon}
</span>
<span className="truncate text-center text-lg font-medium text-foreground" title={id}>
{id}
</span>
</div>
{handles.output && (
<OutputHandle id={handles.output} />
)}
</div>
)
}
/**
* Wraps a node component so that when the viewport zoom is at or below
* CONTEXTUAL_ZOOM_THRESHOLD, the node renders as a compact icon-only view.
* Inner is always mounted (hidden when compact) so switching zoom does not
* remount and re-trigger effects (e.g. RenderingNode fetch).
*/
export function createContextualNode<P extends NodeProps>(
Inner: React.ComponentType<P>
): React.ComponentType<P> {
function ContextualZoomNode(props: P) {
const displayMode = useContext(ViewportDisplayContext)
const showCompact = displayMode === 'compact'
const p = props as NodeProps
const type = p.type
const defaultStyle = type ? getDefaultStyle(type) : { width: 320, height: 320 }
const w = p.width ?? defaultStyle.width
const h = p.height ?? defaultStyle.height
return (
<div
style={
showCompact
? { width: w, height: h, minWidth: w, minHeight: h, position: 'relative' }
: { display: 'contents' }
}
>
{showCompact && (
<CompactNodeView
id={p.id}
type={type}
data={p.data}
selected={p.selected}
width={p.width}
height={p.height}
/>
)}
<div style={{ display: showCompact ? 'none' : undefined }} aria-hidden={showCompact}>
<Inner {...props} />
</div>
</div>
)
}
ContextualZoomNode.displayName = `ContextualZoom(${Inner.displayName ?? Inner.name ?? 'Node'})`
return ContextualZoomNode as React.ComponentType<P>
}

View File

@@ -0,0 +1,55 @@
/**
* Single subscriber for viewport zoom: one component (ViewportDisplayProvider)
* subscribes to useViewport() and provides displayMode so contextual nodes
* don't each subscribe to viewport and re-render on every pan/zoom.
*/
import React, { useLayoutEffect, useRef, useState } from 'react'
import { useViewport } from '@xyflow/react'
import { CONTEXTUAL_ZOOM_THRESHOLD } from '@/app/canvas/ContextualZoomNode'
export type ViewportDisplayMode = 'compact' | 'full'
const HYSTERESIS = 0.02
const ViewportDisplayContext = React.createContext<ViewportDisplayMode>('full')
export { ViewportDisplayContext }
/**
* Must be rendered inside ReactFlowProvider. Subscribes to viewport once,
* maps zoom to displayMode with hysteresis. Throttles updates via rAF to avoid
* re-rendering all contextual nodes on every zoom tick.
*/
export function ViewportDisplayProvider({ children }: { children: React.ReactNode }) {
const { zoom } = useViewport()
const [displayMode, setDisplayMode] = useState<ViewportDisplayMode>(() =>
zoom <= CONTEXTUAL_ZOOM_THRESHOLD ? 'compact' : 'full'
)
const lastModeRef = useRef(displayMode)
const zoomRef = useRef(zoom)
const rafRef = useRef<number | null>(null)
zoomRef.current = zoom
useLayoutEffect(() => {
if (rafRef.current !== null) return
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
const z = zoomRef.current
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
let next: ViewportDisplayMode = lastModeRef.current
if (z <= low) next = 'compact'
else if (z >= high) next = 'full'
if (next !== lastModeRef.current) {
lastModeRef.current = next
setDisplayMode(next)
}
})
}, [zoom])
return (
<ViewportDisplayContext.Provider value={displayMode}>
{children}
</ViewportDisplayContext.Provider>
)
}

View File

@@ -0,0 +1,84 @@
/**
* Canvas graph: example graph and initial graph from storage.
* Used by useCanvasGraph and CanvasPage (e.g. Load example).
*/
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
import { loadGraphFromStorage } from '@/app/recollections/state/recollectionGraphStorage'
/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */
export function backfillEdgeTargetTypes(
nodes: AppNode[],
edges: AppEdge[]
): AppEdge[] {
const typeById = new Map(nodes.map((n) => [n.id, n.type ?? '']))
return edges.map((e) => {
const targetType = typeById.get(e.target) ?? (e.data as Record<string, unknown>)?.targetType ?? ''
const data = typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
if (data.targetType === targetType) return e
return { ...e, data: { ...data, targetType } }
})
}
const NODE_GAP = 150
const EXAMPLE_NODES: AppNode[] = [
{
id: 'var_001',
position: { x: 50, y: 100 },
data: { value: 'Zoe', valueType: 'string' as const },
type: 'variable',
style: DEFAULT_NODE_STYLE.variable,
},
{
id: 'cfg_001',
position: { x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP, y: 50 },
data: {
plantuml: '@startuml\nactor User\nparticipant "{{ var_001 }}" as R\nUser -> R : loves\n@enduml\n',
title: 'config-cfg_001',
},
type: 'config',
style: DEFAULT_NODE_STYLE.config,
},
{
id: 'rnd_001',
position: {
x: 50 + DEFAULT_NODE_STYLE.variable.width + NODE_GAP + DEFAULT_NODE_STYLE.config.width + NODE_GAP,
y: 50,
},
data: {},
type: 'render',
style: DEFAULT_NODE_STYLE.render,
},
]
const EXAMPLE_EDGES: AppEdge[] = [
{ id: 'e-var_001-cfg_001', source: 'var_001', target: 'cfg_001', type: 'animated' },
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001', type: 'animated' },
]
export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
const nodes = EXAMPLE_NODES.map((n) => ({
...n,
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
}))
const edges = backfillEdgeTargetTypes(
nodes,
EXAMPLE_EDGES.map((e) => ({ ...e }))
)
return { nodes, edges }
}
export function getInitialGraph(recollectionId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
if (recollectionId) {
const stored = 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[])
return { nodes, edges }
}
return { nodes: [], edges: [] }
}
return getExampleGraph()
}

View File

@@ -0,0 +1,44 @@
/**
* Canvas store: centralized state + commands + selectors.
* Entry point for store usage.
*/
export {
getCanvasStore,
dispatchCanvasCommand,
useCanvasStore,
useCanvasStoreDispatch,
canvasStore,
} from './canvasStore'
export type { CanvasStore, CanvasCommand } from './canvasStore'
export { initialCanvasStore, canvasStoreReducer } from './canvasStore.reducer'
export type { CanvasStore as CanvasStoreState, PathSlice, UISlice, GraphSlice, ConnectionFrom } from './canvasStore.types'
export {
selectGraph,
selectPath,
selectUI,
selectNodes,
selectEdges,
selectPathNodeIds,
selectPathActiveSegmentNodeIds,
selectConnectionStatusForEdge,
selectPathRoleForNode,
selectRenamingNodeId,
selectFullscreenNodeId,
selectConnectionFrom,
// Fine-grained selectors
selectNodeById,
selectNodesById,
selectNodesByType,
selectNodeIdsByType,
selectEdgesBySource,
selectEdgesByTarget,
selectEdgesForNode,
selectNodeCount,
selectEdgeCount,
selectHasNode,
selectHasEdge,
// SVG detection
isSvgContent,
} from './canvasStore.selectors'
export type { ConnectionPathRole } from './canvasStore.selectors'

View File

@@ -0,0 +1,122 @@
/**
* Pure reducer for the canvas store. One command → one state transition.
*/
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import type { CanvasStore, CanvasCommand, GraphSlice, PathSlice, UISlice } from './canvasStore.types'
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
const initialGraph: GraphSlice = {
nodes: [],
edges: [],
}
const PULSE_MS = 1000
const initialPath: PathSlice = {
triggerNodeIds: [],
pulseEndsAt: null,
errorNodeIds: [],
}
const initialUI: UISlice = {
renamingNodeId: null,
fullscreenNodeId: null,
connectionFrom: null,
}
export const initialCanvasStore: CanvasStore = {
graph: initialGraph,
path: initialPath,
ui: initialUI,
}
// ---------------------------------------------------------------------------
// Reducer
// ---------------------------------------------------------------------------
function reduceGraph(prev: GraphSlice, cmd: CanvasCommand): GraphSlice {
switch (cmd.type) {
case 'graph/setNodes': {
const next =
typeof cmd.payload === 'function' ? cmd.payload(prev.nodes) : cmd.payload
return { ...prev, nodes: next }
}
case 'graph/setEdges': {
const next =
typeof cmd.payload === 'function' ? cmd.payload(prev.edges) : cmd.payload
return { ...prev, edges: next }
}
case 'graph/apply': {
return {
nodes: cmd.payload.nodes ?? prev.nodes,
edges: cmd.payload.edges ?? prev.edges,
}
}
default:
return prev
}
}
function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice {
switch (cmd.type) {
case 'path/addTrigger': {
const id = cmd.payload
if (prev.triggerNodeIds.includes(id)) return prev
return {
...prev,
triggerNodeIds: [...prev.triggerNodeIds, id],
pulseEndsAt: Date.now() + PULSE_MS,
}
}
case 'path/clearTriggers':
return { ...prev, triggerNodeIds: [], pulseEndsAt: null }
case 'path/setError': {
const { nodeId, error } = cmd.payload
const has = prev.errorNodeIds.includes(nodeId)
if (error === has) return prev
return {
...prev,
errorNodeIds: error
? [...prev.errorNodeIds, nodeId]
: prev.errorNodeIds.filter((x) => x !== nodeId),
}
}
case 'path/clearPathSession':
return { ...initialPath, errorNodeIds: prev.errorNodeIds }
case 'path/clearErrors':
return { ...prev, errorNodeIds: [] }
default:
return prev
}
}
function reduceUI(prev: UISlice, cmd: CanvasCommand): UISlice {
switch (cmd.type) {
case 'ui/setRenaming':
return { ...prev, renamingNodeId: cmd.payload }
case 'ui/setFullscreen':
return { ...prev, fullscreenNodeId: cmd.payload }
case 'ui/setConnectionFrom':
return { ...prev, connectionFrom: cmd.payload }
default:
return prev
}
}
export function canvasStoreReducer(state: CanvasStore, command: CanvasCommand): CanvasStore {
const graph = reduceGraph(state.graph, command)
const path = reducePath(state.path, command)
const ui = reduceUI(state.ui, command)
if (
graph === state.graph &&
path === state.path &&
ui === state.ui
) {
return state
}
return { graph, path, ui }
}

View File

@@ -0,0 +1,205 @@
/**
* Selectors for the canvas store. Pure (state) => value.
* Derived path sets (pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds) are computed here.
*/
import {
getPathNodeIds,
getPathToUpdatingSegmentNodeIds,
type NodeAttributesMap,
} from '@/lib/graph/graphologyPath'
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
import type { CanvasStore } from './canvasStore.types'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
export type ConnectionPathRole = 'trigger' | 'on-path' | null
// ---------------------------------------------------------------------------
// SVG Detection - Centralized logic for detecting SVG content
// ---------------------------------------------------------------------------
/** Check if a string contains SVG content. */
export function isSvgContent(content: string | null | undefined): boolean {
return Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
}
// ---------------------------------------------------------------------------
// Raw slices
// ---------------------------------------------------------------------------
export function selectGraph(state: CanvasStore) {
return state.graph
}
export function selectPath(state: CanvasStore) {
return state.path
}
export function selectUI(state: CanvasStore) {
return state.ui
}
export function selectNodes(state: CanvasStore) {
return state.graph.nodes
}
export function selectEdges(state: CanvasStore) {
return state.graph.edges
}
// ---------------------------------------------------------------------------
// Fine-grained selectors for optimal re-rendering
// ---------------------------------------------------------------------------
/** Get a specific node by ID. */
export function selectNodeById(state: CanvasStore, nodeId: string): AppNode | undefined {
return state.graph.nodes.find((n) => n.id === nodeId)
}
/** Get multiple nodes by IDs. */
export function selectNodesById(state: CanvasStore, nodeIds: string[]): AppNode[] {
return state.graph.nodes.filter((n) => nodeIds.includes(n.id))
}
/** Get nodes by type. */
export function selectNodesByType(state: CanvasStore, nodeType: string): AppNode[] {
return state.graph.nodes.filter((n) => n.type === nodeType)
}
/** Get node IDs by type. */
export function selectNodeIdsByType(state: CanvasStore, nodeType: string): string[] {
return state.graph.nodes.filter((n) => n.type === nodeType).map((n) => n.id)
}
/** Get edges by source node ID. */
export function selectEdgesBySource(state: CanvasStore, sourceId: string): AppEdge[] {
return state.graph.edges.filter((e) => e.source === sourceId)
}
/** Get edges by target node ID. */
export function selectEdgesByTarget(state: CanvasStore, targetId: string): AppEdge[] {
return state.graph.edges.filter((e) => e.target === targetId)
}
/** Get all edges connected to a node (source or target). */
export function selectEdgesForNode(state: CanvasStore, nodeId: string): AppEdge[] {
return state.graph.edges.filter((e) => e.source === nodeId || e.target === nodeId)
}
/** Get node count. */
export function selectNodeCount(state: CanvasStore): number {
return state.graph.nodes.length
}
/** Get edge count. */
export function selectEdgeCount(state: CanvasStore): number {
return state.graph.edges.length
}
/** Check if a node exists. */
export function selectHasNode(state: CanvasStore, nodeId: string): boolean {
return state.graph.nodes.some((n) => n.id === nodeId)
}
/** Check if an edge exists. */
export function selectHasEdge(state: CanvasStore, source: string, target: string): boolean {
return state.graph.edges.some((e) => e.source === source && e.target === target)
}
// ---------------------------------------------------------------------------
// Derived path (Sets) depend on graph.edges + path primitive arrays
// ---------------------------------------------------------------------------
const emptySet = new Set<string>()
function edgesAsGraphEdges(edges: CanvasStore['graph']['edges']) {
return edges.map((e) => ({ source: e.source, target: e.target, id: e.id }))
}
/** Build node attributes for graphology (nodeType, updateMode) from canvas nodes. */
function buildNodeAttributesMap(nodes: AppNode[]): NodeAttributesMap {
const map: NodeAttributesMap = {}
for (const n of nodes) {
const data = n.data as { updateMode?: 'auto' | 'manual' } | undefined
map[n.id] = {
nodeType: n.type ?? undefined,
updateMode: data?.updateMode,
}
}
return map
}
export function selectPathNodeIds(state: CanvasStore): Set<string> {
const { nodes, edges } = state.graph
const { triggerNodeIds } = state.path
return getPathNodeIds(
edgesAsGraphEdges(edges),
[],
triggerNodeIds,
undefined,
buildNodeAttributesMap(nodes)
)
}
export function selectPathActiveSegmentNodeIds(state: CanvasStore): Set<string> {
const { nodes, edges } = state.graph
const { triggerNodeIds, pulseEndsAt } = state.path
const pulseActive = pulseEndsAt != null
return getPathToUpdatingSegmentNodeIds(
edgesAsGraphEdges(edges),
triggerNodeIds,
pulseActive,
buildNodeAttributesMap(nodes)
)
}
// ---------------------------------------------------------------------------
// Per-edge connection status (for AnimatedEdge)
// ---------------------------------------------------------------------------
export function selectConnectionStatusForEdge(
state: CanvasStore,
source: string,
target: string
): ConnectionStatus {
const pathNodeIds = selectPathNodeIds(state)
const activeSegmentNodeIds = selectPathActiveSegmentNodeIds(state)
const errorTargetNodeIds = new Set(state.path.errorNodeIds)
return getConnectionStatus({
source,
target,
pathNodeIds,
activeSegmentNodeIds,
errorTargetNodeIds,
})
}
// ---------------------------------------------------------------------------
// Per-node path role (for BaseNode styling)
// ---------------------------------------------------------------------------
export function selectPathRoleForNode(
state: CanvasStore,
nodeId: string
): ConnectionPathRole {
const pathNodeIds = selectPathNodeIds(state)
if (!pathNodeIds.has(nodeId)) return null
if (state.path.triggerNodeIds.includes(nodeId)) return 'trigger'
return 'on-path'
}
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
export function selectRenamingNodeId(state: CanvasStore): string | null {
return state.ui.renamingNodeId
}
export function selectFullscreenNodeId(state: CanvasStore): string | null {
return state.ui.fullscreenNodeId
}
export function selectConnectionFrom(state: CanvasStore) {
return state.ui.connectionFrom
}

View File

@@ -0,0 +1,504 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
initialCanvasStore,
canvasStoreReducer,
getCanvasStore,
dispatchCanvasCommand,
} from './canvasStore'
import {
selectPathNodeIds,
selectPathActiveSegmentNodeIds,
selectConnectionStatusForEdge,
selectPathRoleForNode,
selectNodes,
selectEdges,
selectRenamingNodeId,
selectFullscreenNodeId,
selectConnectionFrom,
} from './canvasStore.selectors'
import type { CanvasStore, CanvasCommand } from './canvasStore.types'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function makeNode(id: string, type = 'config'): AppNode {
return {
id,
type,
position: { x: 0, y: 0 },
data: {},
}
}
function makeEdge(id: string, source: string, target: string): AppEdge {
return { id, source, target }
}
// ---------------------------------------------------------------------------
// Reducer: graph commands
// ---------------------------------------------------------------------------
describe('canvasStoreReducer', () => {
describe('graph commands', () => {
it('graph/setNodes replaces nodes', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a')],
edges: [],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/setNodes',
payload: [makeNode('b'), makeNode('c')],
})
expect(next.graph.nodes).toHaveLength(2)
expect(next.graph.nodes.map((n) => n.id)).toEqual(['b', 'c'])
expect(next.graph.edges).toEqual(state.graph.edges)
})
it('graph/setNodes with updater function', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a'), makeNode('b')],
edges: [],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/setNodes',
payload: (prev) => prev.filter((n) => n.id !== 'a'),
})
expect(next.graph.nodes).toHaveLength(1)
expect(next.graph.nodes[0].id).toBe('b')
})
it('graph/setEdges replaces edges', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/setEdges',
payload: [makeEdge('e2', 'b', 'c')],
})
expect(next.graph.edges).toHaveLength(1)
expect(next.graph.edges[0].id).toBe('e2')
})
it('graph/apply updates nodes and edges', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a')],
edges: [makeEdge('e1', 'a', 'b')],
},
}
const next = canvasStoreReducer(state, {
type: 'graph/apply',
payload: { nodes: [makeNode('x')] },
})
expect(next.graph.nodes).toHaveLength(1)
expect(next.graph.nodes[0].id).toBe('x')
expect(next.graph.edges).toEqual(state.graph.edges)
})
})
describe('path commands', () => {
it('path/addTrigger adds node id', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'path/addTrigger',
payload: 'n1',
})
expect(next.path.triggerNodeIds).toEqual(['n1'])
})
it('path/addTrigger is idempotent', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: { ...initialCanvasStore.path, triggerNodeIds: ['n1'] },
}
const next = canvasStoreReducer(state, {
type: 'path/addTrigger',
payload: 'n1',
})
expect(next).toBe(state)
})
it('path/clearTriggers empties triggerNodeIds and pulseEndsAt', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: {
triggerNodeIds: ['a', 'b'],
pulseEndsAt: Date.now() + 1000,
errorNodeIds: [],
},
}
const next = canvasStoreReducer(state, { type: 'path/clearTriggers' })
expect(next.path.triggerNodeIds).toEqual([])
expect(next.path.pulseEndsAt).toBeNull()
})
it('path/addTrigger sets pulseEndsAt', () => {
const state: CanvasStore = { ...initialCanvasStore }
const before = Date.now()
const next = canvasStoreReducer(state, {
type: 'path/addTrigger',
payload: 'n1',
})
expect(next.path.triggerNodeIds).toEqual(['n1'])
expect(next.path.pulseEndsAt).toBeGreaterThanOrEqual(before + 1000)
})
it('path/setError adds and removes error node', () => {
const state: CanvasStore = { ...initialCanvasStore }
let next = canvasStoreReducer(state, {
type: 'path/setError',
payload: { nodeId: 'n1', error: true },
})
expect(next.path.errorNodeIds).toEqual(['n1'])
next = canvasStoreReducer(next, {
type: 'path/setError',
payload: { nodeId: 'n1', error: false },
})
expect(next.path.errorNodeIds).toEqual([])
})
it('path/clearPathSession resets trigger and pulse; keeps error', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: {
triggerNodeIds: ['t1'],
pulseEndsAt: Date.now() + 1000,
errorNodeIds: ['e1'],
},
}
const next = canvasStoreReducer(state, { type: 'path/clearPathSession' })
expect(next.path.triggerNodeIds).toEqual([])
expect(next.path.pulseEndsAt).toBeNull()
expect(next.path.errorNodeIds).toEqual(['e1'])
})
})
describe('ui commands', () => {
it('ui/setRenaming updates renamingNodeId', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'ui/setRenaming',
payload: 'node-1',
})
expect(next.ui.renamingNodeId).toBe('node-1')
})
it('ui/setFullscreen updates fullscreenNodeId', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'ui/setFullscreen',
payload: 'node-2',
})
expect(next.ui.fullscreenNodeId).toBe('node-2')
})
it('ui/setConnectionFrom updates connectionFrom', () => {
const state: CanvasStore = { ...initialCanvasStore }
const next = canvasStoreReducer(state, {
type: 'ui/setConnectionFrom',
payload: { nodeId: 'n1', sourceHandle: 'out' },
})
expect(next.ui.connectionFrom).toEqual({ nodeId: 'n1', sourceHandle: 'out' })
})
})
describe('no-op returns same reference', () => {
it('path/addTrigger with existing id returns state', () => {
const state: CanvasStore = {
...initialCanvasStore,
path: { ...initialCanvasStore.path, triggerNodeIds: ['a'] },
}
const next = canvasStoreReducer(state, { type: 'path/addTrigger', payload: 'a' })
expect(next).toBe(state)
})
})
describe('initial path shape', () => {
it('path has triggerNodeIds, pulseEndsAt, errorNodeIds; no updatingNodeIds', () => {
const path = initialCanvasStore.path
expect(path).toHaveProperty('triggerNodeIds')
expect(path).toHaveProperty('pulseEndsAt')
expect(path).toHaveProperty('errorNodeIds')
expect(path.triggerNodeIds).toEqual([])
expect(path.pulseEndsAt).toBeNull()
expect(path.errorNodeIds).toEqual([])
expect(path).not.toHaveProperty('updatingNodeIds')
})
})
})
// ---------------------------------------------------------------------------
// Selectors
// ---------------------------------------------------------------------------
describe('canvasStore selectors', () => {
it('selectNodes and selectEdges return graph slice', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a')],
edges: [makeEdge('e1', 'a', 'b')],
},
}
expect(selectNodes(state)).toHaveLength(1)
expect(selectEdges(state)).toHaveLength(1)
})
it('selectPathNodeIds derives path from edges and path arrays', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [makeNode('a'), makeNode('b'), makeNode('c')],
edges: [
makeEdge('e1', 'a', 'b'),
makeEdge('e2', 'b', 'c'),
],
},
path: {
triggerNodeIds: ['a'],
pulseEndsAt: null,
errorNodeIds: [],
},
}
const pathIds = selectPathNodeIds(state)
expect(pathIds.has('a')).toBe(true)
expect(pathIds.has('b')).toBe(true)
expect(pathIds.has('c')).toBe(true)
})
it('selectConnectionStatusForEdge returns default when edge not on path', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: { nodes: [], edges: [makeEdge('e1', 'a', 'b')] },
path: initialCanvasStore.path,
}
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('default')
})
it('selectConnectionStatusForEdge returns default when on path but pulse inactive', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
path: {
triggerNodeIds: ['a'],
pulseEndsAt: null,
errorNodeIds: [],
},
}
expect(selectPathNodeIds(state).has('a')).toBe(true)
expect(selectPathNodeIds(state).has('b')).toBe(true)
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('default')
})
it('selectConnectionStatusForEdge returns updating when on path and pulse active', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
path: {
triggerNodeIds: ['a'],
pulseEndsAt: Date.now() + 2000,
errorNodeIds: [],
},
}
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('updating')
})
it('selectConnectionStatusForEdge returns error over updating when target has error', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
path: {
triggerNodeIds: ['a'],
pulseEndsAt: Date.now() + 2000,
errorNodeIds: ['b'],
},
}
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error')
})
it('selectPathActiveSegmentNodeIds is empty when pulse inactive', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
path: {
triggerNodeIds: ['a'],
pulseEndsAt: null,
errorNodeIds: [],
},
}
const active = selectPathActiveSegmentNodeIds(state)
expect(active.size).toBe(0)
})
it('selectPathActiveSegmentNodeIds returns downstream of trigger when pulse active', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [
makeEdge('e1', 'a', 'b'),
makeEdge('e2', 'b', 'c'),
],
},
path: {
triggerNodeIds: ['a'],
pulseEndsAt: Date.now() + 2000,
errorNodeIds: [],
},
}
const active = selectPathActiveSegmentNodeIds(state)
expect(active.has('a')).toBe(true)
expect(active.has('b')).toBe(true)
expect(active.has('c')).toBe(true)
})
it('selectPathRoleForNode returns trigger or on-path', () => {
const state: CanvasStore = {
...initialCanvasStore,
graph: {
nodes: [],
edges: [makeEdge('e1', 'a', 'b')],
},
path: {
...initialCanvasStore.path,
triggerNodeIds: ['a'],
pulseEndsAt: null,
errorNodeIds: [],
},
}
expect(selectPathRoleForNode(state, 'a')).toBe('trigger')
expect(selectPathRoleForNode(state, 'b')).toBe('on-path')
expect(selectPathRoleForNode(state, 'x')).toBe(null)
})
it('selectRenamingNodeId, selectFullscreenNodeId, selectConnectionFrom return ui slice', () => {
const state: CanvasStore = {
...initialCanvasStore,
ui: {
renamingNodeId: 'r1',
fullscreenNodeId: 'f1',
connectionFrom: { nodeId: 'c1' },
},
}
expect(selectRenamingNodeId(state)).toBe('r1')
expect(selectFullscreenNodeId(state)).toBe('f1')
expect(selectConnectionFrom(state)).toEqual({ nodeId: 'c1' })
})
})
// ---------------------------------------------------------------------------
// Store integration (dispatch + getState)
// ---------------------------------------------------------------------------
describe('canvas store integration', () => {
beforeEach(() => {
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [] })
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [] })
dispatchCanvasCommand({ type: 'path/clearPathSession' })
dispatchCanvasCommand({ type: 'path/clearTriggers' })
dispatchCanvasCommand({ type: 'path/clearErrors' })
dispatchCanvasCommand({ type: 'ui/setRenaming', payload: null })
dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: null })
dispatchCanvasCommand({ type: 'ui/setConnectionFrom', payload: null })
})
it('dispatch graph/setNodes updates getCanvasStore().graph.nodes', () => {
const node = makeNode('test-1')
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [node] })
const state = getCanvasStore()
expect(state.graph.nodes).toHaveLength(1)
expect(state.graph.nodes[0].id).toBe('test-1')
})
it('dispatch path/addTrigger updates path and starts pulse', () => {
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] })
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] })
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' })
const state = getCanvasStore()
expect(state.path.triggerNodeIds).toContain('a')
expect(state.path.pulseEndsAt).not.toBeNull()
const pathIds = selectPathNodeIds(state)
expect(pathIds.has('a')).toBe(true)
expect(pathIds.has('b')).toBe(true)
})
it('dispatch ui/setFullscreen updates getCanvasStore().ui', () => {
dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: 'full-node' })
const state = getCanvasStore()
expect(state.ui.fullscreenNodeId).toBe('full-node')
})
// Pulse: addTrigger starts a short "updating" pulse along downstream path.
it('path-per-sink: addTrigger starts pulse, all downstream edges show updating', () => {
const nodes: AppNode[] = [
{
id: 'var_001',
type: 'variable',
position: { x: -270, y: 210 },
data: { value: 'sss', valueType: 'string' },
},
{
id: 'cfg_001',
type: 'config',
position: { x: 180, y: 330 },
data: { configType: 'plantuml', content: '@startuml\nactor Mulis\n@enduml\n', title: 'cfg_001' },
},
{
id: 'rnd_001',
type: 'render',
position: { x: 780, y: 540 },
data: { updateMode: 'manual', runTrigger: 1, lastRunSourceSignature: 'sig1' },
},
{
id: 'rnd_002',
type: 'render',
position: { x: 780, y: 180 },
data: { updateMode: 'auto', runTrigger: 2, lastRunSourceSignature: 'sig2' },
},
]
const edges: AppEdge[] = [
{ id: 'xy-edge__var_001out-cfg_001ain', source: 'var_001', target: 'cfg_001', data: { targetType: 'config' } },
{ id: 'xy-edge__cfg_001out-rnd_001ain', source: 'cfg_001', target: 'rnd_001', data: { targetType: 'render' } },
{ id: 'xy-edge__cfg_001out-rnd_002ain', source: 'cfg_001', target: 'rnd_002', data: { targetType: 'render' } },
]
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'var_001' })
const state = getCanvasStore()
const pathIds = selectPathNodeIds(state)
expect(pathIds.has('var_001')).toBe(true)
expect(pathIds.has('cfg_001')).toBe(true)
expect(pathIds.has('rnd_001')).toBe(true)
expect(pathIds.has('rnd_002')).toBe(true)
// During pulse, all path edges show "updating".
expect(selectConnectionStatusForEdge(state, 'var_001', 'cfg_001')).toBe('updating')
expect(selectConnectionStatusForEdge(state, 'cfg_001', 'rnd_001')).toBe('updating')
expect(selectConnectionStatusForEdge(state, 'cfg_001', 'rnd_002')).toBe('updating')
})
})

View File

@@ -0,0 +1,74 @@
/**
* Centralized canvas store (Zustand). Single source of truth for graph, path, and UI.
* Mutate only via dispatch(command); read via useCanvasStore(selector) or getCanvasStore().
*/
import { createStore, useStore } from 'zustand'
import type { CanvasStore as CanvasStoreState, CanvasCommand } from './canvasStore.types'
import { initialCanvasStore, canvasStoreReducer } from './canvasStore.reducer'
// ---------------------------------------------------------------------------
// Store type (state + dispatch)
// ---------------------------------------------------------------------------
export type CanvasStoreWithDispatch = CanvasStoreState & {
dispatch: (command: CanvasCommand) => void
}
// ---------------------------------------------------------------------------
// Create store
// ---------------------------------------------------------------------------
function createCanvasStore() {
return createStore<CanvasStoreWithDispatch>((set, get) => {
const applyCommand = (command: CanvasCommand) => {
const prev = get()
const next = canvasStoreReducer(prev, command)
if (next === prev) return
set({ ...next, dispatch: prev.dispatch })
}
const dispatch: (command: CanvasCommand) => void =
typeof import.meta !== 'undefined' && import.meta.env?.DEV
? (command) => {
// eslint-disable-next-line no-console
console.log('[canvas]', command.type, command.payload)
applyCommand(command)
}
: applyCommand
return { ...initialCanvasStore, dispatch }
})
}
const canvasStore = createCanvasStore()
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Get current state (for use outside React or in selectors). Excludes dispatch. */
export function getCanvasStore(): CanvasStoreState {
const s = canvasStore.getState()
return { graph: s.graph, path: s.path, ui: s.ui }
}
/** Dispatch a command. Use for all mutations. */
export function dispatchCanvasCommand(command: CanvasCommand): void {
canvasStore.getState().dispatch(command)
}
/**
* Subscribe to the store. Pass a selector to re-render only when the selected value changes.
* For object/array selectors consider useShallow from 'zustand/react/shallow' to avoid unnecessary re-renders.
*/
export function useCanvasStore<T>(selector: (state: CanvasStoreState) => T): T {
return useStore(canvasStore, selector)
}
/** Hook that returns dispatch (stable reference). */
export function useCanvasStoreDispatch(): (command: CanvasCommand) => void {
return useStore(canvasStore, (s) => s.dispatch, Object.is)
}
export { canvasStore, initialCanvasStore, canvasStoreReducer }
export type { CanvasStoreState as CanvasStore }
export type { CanvasCommand }

View File

@@ -0,0 +1,66 @@
/**
* Canvas store: types for the centralized store (graph + path + ui).
* All mutations go through commands; reads go through selectors.
*/
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
// ---------------------------------------------------------------------------
// Graph slice
// ---------------------------------------------------------------------------
export type GraphSlice = {
nodes: AppNode[]
edges: AppEdge[]
}
// ---------------------------------------------------------------------------
// Path slice (primitive arrays; derived Sets are in selectors)
// ---------------------------------------------------------------------------
/** Pulse: "updating" shows along path for a short time after a trigger; no node start/end. */
export type PathSlice = {
triggerNodeIds: string[]
/** When non-null, path from triggers shows "updating" until this time (ms). Cleared by timer. */
pulseEndsAt: number | null
errorNodeIds: string[]
}
// ---------------------------------------------------------------------------
// UI slice
// ---------------------------------------------------------------------------
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
export type UISlice = {
renamingNodeId: string | null
fullscreenNodeId: string | null
connectionFrom: ConnectionFrom
}
// ---------------------------------------------------------------------------
// Full store
// ---------------------------------------------------------------------------
export type CanvasStore = {
graph: GraphSlice
path: PathSlice
ui: UISlice
}
// ---------------------------------------------------------------------------
// Commands (discriminated union)
// ---------------------------------------------------------------------------
export type CanvasCommand =
| { type: 'graph/setNodes'; payload: AppNode[] | ((prev: AppNode[]) => AppNode[]) }
| { type: 'graph/setEdges'; payload: AppEdge[] | ((prev: AppEdge[]) => AppEdge[]) }
| { type: 'graph/apply'; payload: { nodes?: AppNode[]; edges?: AppEdge[] } }
| { type: 'path/addTrigger'; payload: string }
| { type: 'path/clearTriggers' }
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
| { type: 'path/clearPathSession' }
| { type: 'path/clearErrors' }
| { type: 'ui/setRenaming'; payload: string | null }
| { type: 'ui/setFullscreen'; payload: string | null }
| { type: 'ui/setConnectionFrom'; payload: ConnectionFrom }

View File

@@ -0,0 +1,155 @@
/**
* Hook that holds all connection-path state and callbacks for the canvas.
* Used to show the "ant trail" along the path of an update (e.g. config → agent → render).
* Extracted from CanvasPage to keep the page focused on composition.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphPath'
/** Serialize set to a stable string for equality. */
function setToStableKey(s: Set<string>): string {
return Array.from(s).sort().join(',')
}
export type EdgeLike = { source: string; target: string }
const PULSE_MS = 1500
export type UseCanvasConnectionPathResult = {
connectionPathUpdatingNodeIds: string[]
connectionPathTriggerNodeIds: string[]
connectionPathPausedNodeIds: string[]
connectionPathErrorNodeIds: string[]
connectionPathNodeIds: Set<string>
connectionPathPausedSegmentNodeIds: Set<string>
connectionPathActiveSegmentNodeIds: Set<string>
startConnectionPathUpdate: (nodeId: string) => void
endConnectionPathUpdate: (nodeId: string) => void
addConnectionPathTrigger: (nodeId: string) => void
addConnectionPathPausedNode: (nodeId: string) => void
removeConnectionPathPausedNode: (nodeId: string) => void
addConnectionPathError: (nodeId: string) => void
removeConnectionPathError: (nodeId: string) => void
}
export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult {
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState<string[]>([])
const [pulseEndsAt, setPulseEndsAt] = useState<number | null>(null)
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
const pathTriggerScheduledRef = useRef(false)
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const addConnectionPathTrigger = useCallback((nodeId: string) => {
pathTriggerBatchRef.current.add(nodeId)
if (pathTriggerScheduledRef.current) return
pathTriggerScheduledRef.current = true
requestAnimationFrame(() => {
pathTriggerScheduledRef.current = false
const batch = new Set(pathTriggerBatchRef.current)
pathTriggerBatchRef.current = new Set()
if (batch.size === 0) return
setConnectionPathTriggerNodeIds((prev) => {
const next = new Set(prev)
batch.forEach((id) => next.add(id))
return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next)
})
setPulseEndsAt(Date.now() + PULSE_MS)
})
}, [])
useEffect(() => {
if (pulseEndsAt == null) return
const delay = Math.max(0, pulseEndsAt - Date.now())
if (pulseTimerRef.current != null) clearTimeout(pulseTimerRef.current)
pulseTimerRef.current = setTimeout(() => {
pulseTimerRef.current = null
setPulseEndsAt(null)
setConnectionPathTriggerNodeIds([])
}, delay)
return () => {
if (pulseTimerRef.current != null) {
clearTimeout(pulseTimerRef.current)
pulseTimerRef.current = null
}
}
}, [pulseEndsAt])
const connectionPathNodeIdsRaw = useMemo(
() =>
getPathNodeIds(edges, [], connectionPathTriggerNodeIds, undefined),
[edges, connectionPathTriggerNodeIds]
)
const connectionPathNodeIdsRef = useRef<Set<string>>(connectionPathNodeIdsRaw)
const connectionPathNodeIdsKeyRef = useRef<string>('')
const connectionPathNodeIds =
setToStableKey(connectionPathNodeIdsRaw) === connectionPathNodeIdsKeyRef.current
? connectionPathNodeIdsRef.current
: (() => {
connectionPathNodeIdsKeyRef.current = setToStableKey(connectionPathNodeIdsRaw)
connectionPathNodeIdsRef.current = connectionPathNodeIdsRaw
return connectionPathNodeIdsRaw
})()
const EMPTY_PAUSED_SEGMENT = useMemo(() => new Set<string>(), [])
const connectionPathPausedSegmentNodeIds = EMPTY_PAUSED_SEGMENT
const pulseActive = pulseEndsAt != null
const connectionPathActiveSegmentNodeIdsRaw = useMemo(
() =>
getPathToUpdatingSegmentNodeIds(
edges,
connectionPathTriggerNodeIds,
pulseActive
),
[edges, connectionPathTriggerNodeIds, pulseActive]
)
const connectionPathActiveSegmentNodeIdsRef = useRef<Set<string>>(
connectionPathActiveSegmentNodeIdsRaw
)
const connectionPathActiveSegmentNodeIdsKeyRef = useRef<string>('')
const connectionPathActiveSegmentNodeIds =
setToStableKey(connectionPathActiveSegmentNodeIdsRaw) ===
connectionPathActiveSegmentNodeIdsKeyRef.current
? connectionPathActiveSegmentNodeIdsRef.current
: (() => {
connectionPathActiveSegmentNodeIdsKeyRef.current = setToStableKey(
connectionPathActiveSegmentNodeIdsRaw
)
connectionPathActiveSegmentNodeIdsRef.current = connectionPathActiveSegmentNodeIdsRaw
return connectionPathActiveSegmentNodeIdsRaw
})()
const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
const startConnectionPathUpdate = useCallback((_nodeId: string) => {}, [])
const endConnectionPathUpdate = useCallback((_nodeId: string) => {}, [])
const addConnectionPathError = useCallback((nodeId: string) => {
setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
}, [])
const removeConnectionPathError = useCallback((nodeId: string) => {
setConnectionPathErrorNodeIds((prev) => prev.filter((id) => id !== nodeId))
}, [])
return {
connectionPathUpdatingNodeIds: [],
connectionPathTriggerNodeIds,
connectionPathPausedNodeIds: [],
connectionPathErrorNodeIds,
connectionPathNodeIds,
connectionPathPausedSegmentNodeIds,
connectionPathActiveSegmentNodeIds,
startConnectionPathUpdate,
endConnectionPathUpdate,
addConnectionPathTrigger,
addConnectionPathPausedNode,
removeConnectionPathPausedNode,
addConnectionPathError,
removeConnectionPathError,
}
}

View File

@@ -0,0 +1,128 @@
/**
* Connection-path state and callbacks backed by the canvas store.
* "Updating" is time-bound: when a trigger is added, the path pulses for a short time
* and then clears. No node start/end reporting.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
dispatchCanvasCommand,
useCanvasStore,
} from '@/app/canvas/canvasStore'
import {
selectPathRoleForNode,
type ConnectionPathRole,
} from '@/app/canvas/canvasStore.selectors'
import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphologyPath'
import type { UseCanvasConnectionPathResult } from './useCanvasConnectionPath'
const EMPTY_PAUSED_SEGMENT = new Set<string>()
function edgesAsGraphEdges(
edges: Array<{ source: string; target: string }>
): Array<{ source: string; target: string }> {
return edges.map((e) => ({ source: e.source, target: e.target }))
}
export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResult {
const path = useCanvasStore((s) => s.path)
const edges = useCanvasStore((s) => s.graph.edges)
const pulseEndsAtRef = useRef<number | null>(null)
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pathNodeIds = useMemo(
() =>
getPathNodeIds(
edgesAsGraphEdges(edges),
[],
path.triggerNodeIds,
undefined
),
[edges, path.triggerNodeIds]
)
const pulseActive = path.pulseEndsAt != null
const connectionPathActiveSegmentNodeIds = useMemo(
() =>
getPathToUpdatingSegmentNodeIds(
edgesAsGraphEdges(edges),
path.triggerNodeIds,
pulseActive
),
[edges, path.triggerNodeIds, pulseActive]
)
useEffect(() => {
const endsAt = path.pulseEndsAt
if (endsAt == null) {
if (pulseTimerRef.current != null) {
clearTimeout(pulseTimerRef.current)
pulseTimerRef.current = null
}
pulseEndsAtRef.current = null
return
}
if (endsAt === pulseEndsAtRef.current) return
pulseEndsAtRef.current = endsAt
const delay = Math.max(0, endsAt - Date.now())
if (pulseTimerRef.current != null) clearTimeout(pulseTimerRef.current)
pulseTimerRef.current = setTimeout(() => {
pulseTimerRef.current = null
pulseEndsAtRef.current = null
dispatchCanvasCommand({ type: 'path/clearPathSession' })
}, delay)
return () => {
if (pulseTimerRef.current != null) {
clearTimeout(pulseTimerRef.current)
pulseTimerRef.current = null
}
}
}, [path.pulseEndsAt])
const addConnectionPathTrigger = useCallback((nodeId: string) => {
dispatchCanvasCommand({ type: 'path/addTrigger', payload: nodeId })
}, [])
const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
const addConnectionPathError = useCallback((nodeId: string) => {
dispatchCanvasCommand({
type: 'path/setError',
payload: { nodeId, error: true },
})
}, [])
const removeConnectionPathError = useCallback((nodeId: string) => {
dispatchCanvasCommand({
type: 'path/setError',
payload: { nodeId, error: false },
})
}, [])
return {
connectionPathUpdatingNodeIds: [],
connectionPathTriggerNodeIds: path.triggerNodeIds,
connectionPathPausedNodeIds: [],
connectionPathErrorNodeIds: path.errorNodeIds,
connectionPathNodeIds: pathNodeIds,
connectionPathPausedSegmentNodeIds: EMPTY_PAUSED_SEGMENT,
connectionPathActiveSegmentNodeIds: connectionPathActiveSegmentNodeIds,
startConnectionPathUpdate: () => {},
endConnectionPathUpdate: () => {},
addConnectionPathTrigger,
addConnectionPathPausedNode,
removeConnectionPathPausedNode,
addConnectionPathError,
removeConnectionPathError,
}
}
/**
* Path role for a node (trigger / updating / on-path) from the store.
* Use in node components so they only re-render when their path role changes.
*/
export function useConnectionPathRoleFromStore(nodeId: string | undefined): ConnectionPathRole {
return useCanvasStore((s) =>
nodeId != null ? selectPathRoleForNode(s, nodeId) : null
)
}

View File

@@ -0,0 +1,74 @@
/**
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
* with initial graph from recollection storage (or example). Save is explicit via save().
*/
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 type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
/** Persist current nodes/edges to storage. No-op when recollectionId is missing. */
save: () => void
/** For menubar: show "Unsaved changes" | "Saving…" | "All changes saved". */
saveStatus: SaveStatus
}
export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGraphResult {
const initialGraph = useMemo(() => getInitialGraph(recollectionId), [recollectionId])
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
const { nodes, edges } = result
const nodesRef = useRef(nodes)
const edgesRef = useRef(edges)
nodesRef.current = nodes
edgesRef.current = edges
const [isSaving, setIsSaving] = useState(false)
const [lastSavedSerialized, setLastSavedSerialized] = useState(() =>
JSON.stringify({ nodes: initialGraph.nodes, edges: initialGraph.edges })
)
const currentSerialized = useMemo(
() => JSON.stringify({ nodes, edges }),
[nodes, edges]
)
const isDirty = currentSerialized !== lastSavedSerialized
const saveStatus: SaveStatus = isSaving ? 'saving' : isDirty ? 'unsaved' : 'saved'
const save = useCallback(() => {
if (!recollectionId) return
const snapshot = JSON.stringify({
nodes: nodesRef.current,
edges: edgesRef.current,
})
setIsSaving(true)
saveGraphToStorage(recollectionId, {
version: RECOLLECTION_VERSION,
nodes: nodesRef.current,
edges: edgesRef.current,
})
const SAVING_DISPLAY_MS = 360
setTimeout(() => {
setLastSavedSerialized(snapshot)
setIsSaving(false)
}, SAVING_DISPLAY_MS)
}, [recollectionId])
// 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.
const AUTO_SAVE_DELAY_MS = 5000
const saveRef = useRef(save)
saveRef.current = save
useEffect(() => {
if (!isDirty || !recollectionId) return
const timer = setTimeout(() => saveRef.current(), AUTO_SAVE_DELAY_MS)
return () => clearTimeout(timer)
}, [isDirty, recollectionId, currentSerialized])
return { ...result, save, saveStatus }
}

View File

@@ -0,0 +1,354 @@
/**
* Platform context: recollections list and handlers for create/delete/rename/updateLastEdited/reorder/restore.
* Used by AppSidebar, RecollectionsPage, and canvas route.
*/
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
import type { Recollection } from './types'
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
const STORAGE_KEY = 'zui_platform_recollections'
const ORDER_STORAGE_KEY = 'zui_platform_recollection_order'
const RECENT_STORAGE_KEY = 'zui_platform_recent_recollection_ids'
const LEGACY_STORAGE_KEY = 'zui_platform_emanations'
const LEGACY_ORDER_STORAGE_KEY = 'zui_platform_emanation_order'
const LEGACY_RECENT_STORAGE_KEY = 'zui_platform_recent_emanation_ids'
const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap'
const AI_CONNECTION_KEY = 'zui_ai_connection'
const RECENT_MAX = 5
export type AiConnectionProvider = 'openai' | 'local'
export type AiConnection = {
provider: AiConnectionProvider
baseURL: string
model: string
apiKey: string
}
const DEFAULT_AI_CONNECTION: AiConnection = {
provider: 'local',
baseURL: 'http://localhost:1234/v1',
model: 'local-model',
apiKey: '',
}
function loadAiConnection(): AiConnection {
try {
const raw = localStorage.getItem(AI_CONNECTION_KEY)
if (!raw) return DEFAULT_AI_CONNECTION
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') return DEFAULT_AI_CONNECTION
const p = parsed as Record<string, unknown>
return {
provider: p.provider === 'openai' ? 'openai' : 'local',
baseURL: typeof p.baseURL === 'string' ? p.baseURL : DEFAULT_AI_CONNECTION.baseURL,
model: typeof p.model === 'string' ? p.model : DEFAULT_AI_CONNECTION.model,
apiKey: typeof p.apiKey === 'string' ? p.apiKey : '',
}
} catch {
return DEFAULT_AI_CONNECTION
}
}
function saveAiConnection(value: AiConnection) {
try {
localStorage.setItem(AI_CONNECTION_KEY, JSON.stringify(value))
} catch {}
}
function loadShowMinimap(): boolean {
try {
const raw = localStorage.getItem(CANVAS_MINIMAP_KEY)
if (raw === 'true') return true
if (raw === 'false') return false
} catch {}
return false
}
function saveShowMinimap(value: boolean) {
try {
localStorage.setItem(CANVAS_MINIMAP_KEY, JSON.stringify(value))
} catch {}
}
function loadOrder(): string[] {
try {
let raw = localStorage.getItem(ORDER_STORAGE_KEY)
if (!raw) {
const legacy = localStorage.getItem(LEGACY_ORDER_STORAGE_KEY)
if (legacy) {
localStorage.setItem(ORDER_STORAGE_KEY, legacy)
localStorage.removeItem(LEGACY_ORDER_STORAGE_KEY)
raw = legacy
} else return []
}
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
} catch {
return []
}
}
function saveOrder(ids: string[]) {
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids))
}
function loadRecentIds(): string[] {
try {
let raw = localStorage.getItem(RECENT_STORAGE_KEY)
if (!raw) {
const legacy = localStorage.getItem(LEGACY_RECENT_STORAGE_KEY)
if (legacy) {
localStorage.setItem(RECENT_STORAGE_KEY, legacy)
localStorage.removeItem(LEGACY_RECENT_STORAGE_KEY)
raw = legacy
} else return []
}
const parsed = JSON.parse(raw) as unknown
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MAX) : []
} catch {
return []
}
}
function saveRecentIds(ids: string[]) {
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids))
}
function loadRecollections(): Recollection[] {
try {
let raw = localStorage.getItem(STORAGE_KEY)
if (!raw) {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
if (legacy) {
localStorage.setItem(STORAGE_KEY, legacy)
localStorage.removeItem(LEGACY_STORAGE_KEY)
raw = legacy
} else return []
}
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) return []
return parsed
.filter(
(p): p is Recollection =>
p &&
typeof p === 'object' &&
typeof (p as Recollection).id === 'string' &&
typeof (p as Recollection).name === 'string' &&
typeof (p as Recollection).iconId === 'string' &&
typeof (p as Recollection).createdAt === 'number'
)
.map((p) => ({
...p,
lastEditedAt: typeof (p as Recollection).lastEditedAt === 'number' ? (p as Recollection).lastEditedAt : p.createdAt,
}))
} catch {
return []
}
}
function saveRecollections(recollections: Recollection[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(recollections))
}
export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] }
/** Recollections sorted by recollectionOrder, then by lastEditedAt desc for any not in order */
export function sortRecollectionsByOrder(recollections: Recollection[], order: string[]): Recollection[] {
const byId = new Map(recollections.map((p) => [p.id, p]))
const ordered: Recollection[] = []
const seen = new Set<string>()
for (const id of order) {
const p = byId.get(id)
if (p) {
ordered.push(p)
seen.add(id)
}
}
const rest = recollections
.filter((p) => !seen.has(p.id))
.sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
return [...ordered, ...rest]
}
export type KosmosContextValue = {
recollections: Recollection[]
recollectionOrder: string[]
/** Recollections in display order (sidebar order, then lastEditedAt) */
orderedRecollections: Recollection[]
/** Last RECENT_MAX accessed recollection IDs (most recent first) */
recentRecollectionIds: string[]
recordRecollectionAccess: (id: string) => void
persist: (next: Recollection[]) => void
createRecollection: (recollection: Recollection) => void
deleteRecollection: (id: string) => void
renameRecollection: (id: string, name: string) => void
updateLastEdited: (id: string) => void
reorderRecollections: (orderedIds: string[]) => void
restoreRecollection: (recollection: Recollection, graphSnapshot: GraphSnapshot | null) => void
/** Canvas: show React Flow minimap (persisted) */
showMinimap: boolean
setShowMinimap: (value: boolean) => void
/** Agent node: AI connection (persisted). Sent to backend when running agent. */
aiConnection: AiConnection
setAiConnection: (value: AiConnection) => void
}
const KosmosContext = createContext<KosmosContextValue | null>(null)
export function KosmosProvider({ children }: { children: React.ReactNode }) {
const [recollections, setRecollections] = useState<Recollection[]>(loadRecollections)
const [recollectionOrder, setRecollectionOrder] = useState<string[]>(loadOrder)
const [recentRecollectionIds, setRecentRecollectionIds] = useState<string[]>(loadRecentIds)
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection)
const setShowMinimap = useCallback((value: boolean) => {
setShowMinimapState(value)
saveShowMinimap(value)
}, [])
const setAiConnection = useCallback((value: AiConnection) => {
setAiConnectionState(value)
saveAiConnection(value)
}, [])
const persist = useCallback((next: Recollection[]) => {
setRecollections(next)
saveRecollections(next)
}, [])
const createRecollection = useCallback(
(recollection: Recollection) => {
const withEdited = { ...recollection, lastEditedAt: recollection.createdAt }
persist([...recollections, withEdited])
setRecollectionOrder((prev) => {
const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev]
saveOrder(next)
return next
})
},
[recollections, persist]
)
const deleteRecollection = useCallback(
(id: string) => {
const next = recollections.filter((p) => p.id !== id)
persist(next)
setRecollectionOrder((prev) => {
const nextOrder = prev.filter((oid) => oid !== id)
saveOrder(nextOrder)
return nextOrder
})
setRecentRecollectionIds((prev) => {
const next = prev.filter((oid) => oid !== id)
saveRecentIds(next)
return next
})
},
[recollections, persist]
)
const renameRecollection = useCallback(
(id: string, name: string) => {
const next = recollections.map((p) => (p.id === id ? { ...p, name } : p))
persist(next)
},
[recollections, persist]
)
const updateLastEdited = useCallback(
(id: string) => {
const now = Date.now()
const next = recollections.map((p) => (p.id === id ? { ...p, lastEditedAt: now } : p))
persist(next)
},
[recollections, persist]
)
const reorderRecollections = useCallback((orderedIds: string[]) => {
setRecollectionOrder(orderedIds)
saveOrder(orderedIds)
}, [])
const recordRecollectionAccess = useCallback((id: string) => {
setRecentRecollectionIds((prev) => {
const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX)
saveRecentIds(next)
return next
})
}, [])
const restoreRecollection = useCallback(
(recollection: Recollection, graphSnapshot: GraphSnapshot | null) => {
persist([...recollections, recollection])
setRecollectionOrder((prev) => {
const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev]
saveOrder(next)
return next
})
if (graphSnapshot) {
saveGraphToStorage(recollection.id, {
version: RECOLLECTION_VERSION,
nodes: graphSnapshot.nodes,
edges: graphSnapshot.edges,
})
}
},
[recollections, persist]
)
const orderedRecollections = useMemo(
() => sortRecollectionsByOrder(recollections, recollectionOrder),
[recollections, recollectionOrder]
)
const value: KosmosContextValue = useMemo(
() => ({
recollections,
recollectionOrder,
orderedRecollections,
recentRecollectionIds,
recordRecollectionAccess,
persist,
createRecollection,
deleteRecollection,
renameRecollection,
updateLastEdited,
reorderRecollections,
restoreRecollection,
showMinimap,
setShowMinimap,
aiConnection,
setAiConnection,
}),
[
recollections,
recollectionOrder,
orderedRecollections,
recentRecollectionIds,
recordRecollectionAccess,
persist,
createRecollection,
deleteRecollection,
renameRecollection,
updateLastEdited,
reorderRecollections,
restoreRecollection,
showMinimap,
setShowMinimap,
aiConnection,
setAiConnection,
]
)
return <KosmosContext.Provider value={value}>{children}</KosmosContext.Provider>
}
export function usePlatform() {
const ctx = useContext(KosmosContext)
if (!ctx) throw new Error('usePlatform must be used within PlatformProvider')
return ctx
}

View File

@@ -0,0 +1,40 @@
/**
* Platform: main layout with sidebar and header. Renders child routes (recollections list or canvas) via Outlet.
*/
import React, { useEffect, useState } from 'react'
import { Outlet, useParams } from 'react-router-dom'
import { SidebarProvider } from '@/components/ui/sidebar'
import { KosmosProvider, usePlatform } from './KosmosContext'
import { KosmosSidebar } from './KosmosSidebar'
function KosmosLayoutInner() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recordRecollectionAccess } = usePlatform()
const [sidebarOpen, setSidebarOpen] = useState(true)
useEffect(() => {
if (recollectionId) recordRecollectionAccess(recollectionId)
}, [recollectionId, recordRecollectionAccess])
const showSidebar = !recollectionId
return (
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
{showSidebar && <KosmosSidebar />}
<div className="relative flex h-dvh min-h-0 w-full flex-1 flex-col overflow-hidden bg-background">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Outlet />
</div>
</div>
</SidebarProvider>
)
}
export function KosmosPage() {
return (
<KosmosProvider>
<KosmosLayoutInner />
</KosmosProvider>
)
}

View File

@@ -0,0 +1,198 @@
/**
* Platform sidebar: Recollections, Recently used, New recollection.
*/
import React, { useCallback, useMemo } from 'react'
import { Link, useNavigate, useParams, useLocation } from 'react-router-dom'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarTrigger,
} from '@/components/ui/sidebar'
import { Plus, Settings } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSidebar } from '@/components/ui/sidebar'
import { usePlatform } from './KosmosContext'
import { getRecollectionIcon } from '@/lib/iconMap'
import { RecollectionsIcon } from '@/lib/icons'
import { NewRecollectionDialog } from './NewRecollectionDialog'
import { SettingsDialog } from './SettingsDialog'
import type { Recollection } from './types'
export function KosmosSidebar() {
const { state, setOpen } = useSidebar()
const isCollapsed = state === 'collapsed'
const { orderedRecollections, createRecollection, recentRecollectionIds } = usePlatform()
const recentRecollections = useMemo(() => {
const byId = new Map(orderedRecollections.map((p) => [p.id, p]))
return recentRecollectionIds.map((id) => byId.get(id)).filter((p): p is Recollection => p != null)
}, [orderedRecollections, recentRecollectionIds])
const navigate = useNavigate()
const location = useLocation()
const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
const handleCreateRecollection = useCallback(
(recollection: Parameters<typeof createRecollection>[0]) => {
createRecollection(recollection)
navigate(`/recollections/${recollection.id}`)
},
[createRecollection, navigate]
)
return (
<>
<Sidebar collapsible="icon" className="relative">
{isCollapsed && (
<button
type="button"
className="absolute inset-0 z-0 cursor-pointer border-0 bg-transparent p-0 outline-none"
onClick={() => setOpen(true)}
aria-label="Expand sidebar"
/>
)}
<SidebarHeader className="relative z-10 flex flex-row items-center gap-2">
<SidebarMenu className="flex-1 min-w-0">
<SidebarMenuItem>
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
size="lg"
className="font-semibold font-serif"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
setOpen(true)
}}
>
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground dark:bg-white dark:text-black">
<span
className="size-6 shrink-0 rounded-[2px] opacity-90"
style={{
maskImage: 'url(/app-icon.svg)',
maskRepeat: 'no-repeat',
maskPosition: 'center',
maskSize: 'contain',
WebkitMaskImage: 'url(/app-icon.svg)',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
WebkitMaskSize: 'contain',
backgroundColor: 'currentColor',
}}
aria-hidden
/>
</span>
<span className="font-serif group-data-[collapsible=icon]:hidden uppercase tracking-wide">ZOË</span>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">Click to expand sidebar</TooltipContent>
</Tooltip>
) : (
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
<Link to="/recollections">
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg dark:bg-white dark:text-black">
<span
className="size-6 shrink-0 rounded-[2px] opacity-90"
style={{
maskImage: 'url(/app-icon.svg)',
maskRepeat: 'no-repeat',
maskPosition: 'center',
maskSize: 'contain',
WebkitMaskImage: 'url(/app-icon.svg)',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
WebkitMaskSize: 'contain',
backgroundColor: 'currentColor',
}}
aria-hidden
/>
</span>
<span className="font-serif group-data-[collapsible=icon]:hidden uppercase tracking-wide">ZOË</span>
</Link>
</SidebarMenuButton>
)}
</SidebarMenuItem>
</SidebarMenu>
{!isCollapsed && <SidebarTrigger className="ml-auto shrink-0" />}
</SidebarHeader>
<SidebarContent
className="relative z-10"
onClick={isCollapsed ? (e) => { if ((e.target as HTMLElement).closest('button') || (e.target as HTMLElement).closest('a')) return; setOpen(true) } : undefined}
>
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Recollections" isActive={location.pathname === '/recollections' && !selectedRecollectionId}>
<Link to="/recollections">
<RecollectionsIcon className="size-4 shrink-0" />
<span>Recollections</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
{recentRecollections.length > 0 && (
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
<SidebarMenu>
{recentRecollections.map((recollection) => {
const Icon = getRecollectionIcon(recollection.iconId)
const isActive = selectedRecollectionId === recollection.id
return (
<SidebarMenuItem key={recollection.id}>
<SidebarMenuButton asChild tooltip={recollection.name} isActive={isActive}>
<Link to={`/recollections/${recollection.id}`}>
<Icon className="size-4" />
<span>{recollection.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroup>
)}
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<NewRecollectionDialog
onCreate={handleCreateRecollection}
existingNames={orderedRecollections.map((p) => p.name)}
trigger={
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
<Plus className="size-4" />
<span>{orderedRecollections.length === 0 ? 'Create your first recollection' : 'New recollection'}</span>
</SidebarMenuButton>
}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="relative z-10">
<SidebarMenu>
<SidebarMenuItem>
<SettingsDialog
trigger={
<SidebarMenuButton tooltip="Settings" className="w-full">
<Settings className="size-4" />
<span className="group-data-[collapsible=icon]:hidden">Settings</span>
</SidebarMenuButton>
}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail className="relative z-10" />
</Sidebar>
</>
)
}

View File

@@ -0,0 +1,118 @@
/**
* Dialog to create a new recollection: name + icon.
*/
import React, { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Plus } from 'lucide-react'
import { RECOLLECTION_ICON_IDS, type Recollection, type RecollectionIconId } from './types'
import { getRecollectionIcon } from '@/lib/iconMap'
type NewRecollectionDialogProps = {
onCreate: (recollection: Recollection) => void
trigger?: React.ReactNode
/** Other recollection names to check for duplicates (case-insensitive warning only) */
existingNames?: string[]
}
export function NewRecollectionDialog({ onCreate, trigger, existingNames = [] }: NewRecollectionDialogProps) {
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [iconId, setIconId] = useState<RecollectionIconId>('cat')
const trimmed = name.trim()
const isDuplicate = trimmed.length > 0 && existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!trimmed) return
const recollection: Recollection = {
id: `recollection_${Date.now()}`,
name: trimmed,
iconId,
createdAt: Date.now(),
}
onCreate(recollection)
setName('')
setIconId('cat')
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
<Plus className="size-4" />
New recollection
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>New recollection</DialogTitle>
<DialogDescription>Create a recollection to start editing a graph canvas.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<label htmlFor="recollection-name" className="text-sm font-medium">
Name
</label>
<Input
id="recollection-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My recollection"
autoFocus
/>
{isDuplicate && (
<p className="text-xs text-amber-600 dark:text-amber-500">A recollection with this name already exists.</p>
)}
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Icon</label>
<Select value={iconId} onValueChange={(v) => setIconId(v as RecollectionIconId)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RECOLLECTION_ICON_IDS.map((id) => {
const Icon = getRecollectionIcon(id)
return (
<SelectItem key={id} value={id}>
<span className="flex items-center gap-2">
<Icon className="size-4" />
{id}
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={!name.trim()}>
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,194 @@
/**
* Settings dialog with sidebar-style nav (Appearance, AI). Reference: shadcn sidebar-13.
*/
import React, { useCallback, useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { useTheme } from '@/lib/themeContext'
import type { Theme } from '@/lib/themeContext'
import { usePlatform } from './KosmosContext'
import type { AiConnection, AiConnectionProvider } from './KosmosContext'
import { Sun, Sparkles } from 'lucide-react'
type SettingsSection = 'appearance' | 'ai'
export function SettingsDialog({
trigger,
}: {
trigger: React.ReactNode
}) {
const [open, setOpen] = useState(false)
const [section, setSection] = useState<SettingsSection>('appearance')
const { theme, setTheme } = useTheme()
const { showMinimap, setShowMinimap, aiConnection, setAiConnection } = usePlatform()
const updateAiConnection = useCallback(
(partial: Partial<AiConnection>) => {
setAiConnection({ ...aiConnection, ...partial })
},
[aiConnection, setAiConnection]
)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent
className="flex h-[min(85vh,28rem)] max-w-2xl p-0 gap-0 overflow-hidden"
aria-describedby={undefined}
>
<DialogHeader className="sr-only">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<div className="flex flex-1 min-h-0 w-full">
<nav
className="flex w-44 shrink-0 flex-col gap-1 border-r bg-muted/30 p-2"
aria-label="Settings sections"
>
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Settings
</div>
<Button
variant={section === 'appearance' ? 'secondary' : 'ghost'}
size="sm"
className="justify-start gap-2"
onClick={() => setSection('appearance')}
>
<Sun className="size-4 shrink-0 opacity-70" />
Appearance
</Button>
<Button
variant={section === 'ai' ? 'secondary' : 'ghost'}
size="sm"
className="justify-start gap-2"
onClick={() => setSection('ai')}
>
<Sparkles className="size-4 shrink-0 opacity-70" />
AI
</Button>
</nav>
<div className="flex-1 min-h-0 overflow-auto p-4">
{section === 'appearance' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-muted-foreground">Appearance</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label htmlFor="settings-theme" className="text-sm font-medium">
Theme
</label>
<Select value={theme} onValueChange={(v) => setTheme(v as Theme)}>
<SelectTrigger id="settings-theme" className="w-full min-w-[8rem] sm:w-40">
<SelectValue placeholder="Theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3 pt-2 border-t">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Canvas
</h4>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label htmlFor="settings-minimap" className="text-sm font-medium">
Minimap
</label>
<Switch
id="settings-minimap"
checked={showMinimap}
onCheckedChange={setShowMinimap}
/>
</div>
</div>
</div>
)}
{section === 'ai' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-muted-foreground">AI connection</h3>
<p className="text-xs text-muted-foreground">
Used by the Agent node. Choose OpenAI or a local server (e.g. LM Studio).
</p>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label htmlFor="settings-ai-provider" className="text-sm font-medium">
Provider
</label>
<Select
value={aiConnection.provider}
onValueChange={(v) => updateAiConnection({ provider: v as AiConnectionProvider })}
>
<SelectTrigger id="settings-ai-provider" className="w-full min-w-[10rem] sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local (LM Studio, Ollama, etc.)</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
{aiConnection.provider === 'local' && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-baseurl" className="text-sm font-medium">
Base URL
</label>
<Input
id="settings-ai-baseurl"
type="url"
placeholder="http://localhost:1234/v1"
value={aiConnection.baseURL}
onChange={(e) => updateAiConnection({ baseURL: e.target.value })}
className="font-mono text-xs"
/>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-model" className="text-sm font-medium">
Model
</label>
<Input
id="settings-ai-model"
type="text"
placeholder={aiConnection.provider === 'local' ? 'local-model' : 'gpt-4o-mini'}
value={aiConnection.model}
onChange={(e) => updateAiConnection({ model: e.target.value })}
className="font-mono text-xs"
/>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-apikey" className="text-sm font-medium">
API key
</label>
<Input
id="settings-ai-apikey"
type="password"
autoComplete="off"
placeholder={aiConnection.provider === 'openai' ? 'sk-...' : 'Optional for local'}
value={aiConnection.apiKey}
onChange={(e) => updateAiConnection({ apiKey: e.target.value })}
className="font-mono text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,33 @@
/**
* Platform types: recollections and sidebar state.
*/
import type { LucideIcon } from 'lucide-react'
export type Recollection = {
id: string
name: string
/** Icon identifier: key of RECOLLECTION_ICONS map */
iconId: string
createdAt: number
/** Last time the recollection was opened/edited; used for sorting. Defaults to createdAt if missing. */
lastEditedAt?: number
}
/** Recollection icon ids: Lucide "Animals" category only */
export const RECOLLECTION_ICON_IDS = [
'bird',
'bug',
'cat',
'dog',
'fish',
'rat',
'rabbit',
'squirrel',
'turtle',
'snail',
'shrimp',
'egg'
] as const
export type RecollectionIconId = (typeof RECOLLECTION_ICON_IDS)[number]

View File

@@ -0,0 +1,65 @@
/**
* Layout for a single recollection: shared menubar and nested routes for Logos / Katalogos / Flux.
*/
import React, { useEffect } from 'react'
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 { RecollectionMenubar } from './layout/RecollectionMenubar'
import { RecollectionSidebar } from './layout/RecollectionSidebar'
export function RecollectionLayout() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const navigate = useNavigate()
const { recollections, recordRecollectionAccess } = usePlatform()
useEffect(() => {
if (recollectionId) recordRecollectionAccess(recollectionId)
}, [recollectionId, recordRecollectionAccess])
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
useEffect(() => {
if (!recollectionId) return
if (recollection) return
toast.error('Recollection not found')
navigate('/recollections', { replace: true })
}, [recollectionId, recollection, navigate])
// Keyboard shortcut: Escape to go back to recollections list
useEffect(() => {
if (!recollection) return
const onKeyDown = (ev: KeyboardEvent) => {
if (ev.key !== 'Escape') return
if ((ev.target as HTMLElement)?.closest('input, textarea, [contenteditable="true"]')) return
ev.preventDefault()
navigate('/recollections')
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [recollection, navigate])
if (!recollectionId) return null
if (!recollection) {
return null
}
return (
<RecollectionActionsProvider>
<RecollectionSidebarProvider>
<div className="flex min-h-0 flex-1 flex-col">
<RecollectionMenubar />
<div className="flex min-h-0 flex-1 flex-row overflow-hidden">
<RecollectionSidebar />
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Outlet />
</div>
</div>
</div>
</RecollectionSidebarProvider>
</RecollectionActionsProvider>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
/**
* Flux route: renders the graph canvas (CanvasPage) for the current recollection.
* Supports optional focusNode query param to center on a specific node.
*/
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'
export function FluxRoute() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const [searchParams] = useSearchParams()
const { updateLastEdited } = usePlatform()
const updateLastEditedRef = useRef(updateLastEdited)
updateLastEditedRef.current = updateLastEdited
useEffect(() => {
if (recollectionId) updateLastEditedRef.current(recollectionId)
}, [recollectionId])
const focusNodeId = searchParams.get('focusNode') ?? undefined
if (!recollectionId) return null
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
<CanvasPage key={recollectionId} recollectionId={recollectionId} focusNodeId={focusNodeId} />
</div>
)
}

View File

@@ -0,0 +1,127 @@
/**
* Katalogos page: third view for a recollection.
* Shows live \"Artifacts\" from Flux rendering nodes in a card grid.
*/
import React, { useMemo } 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'
export function KatalogosPage() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recollections } = usePlatform()
const navigate = useNavigate()
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
const title = recollection?.name ?? 'Untitled'
const artifacts = useMemo(
() => (recollectionId ? getRenderOutputCache(recollectionId) : []),
[recollectionId]
)
const handleViewInFlux = (nodeId: string) => {
if (!recollectionId) return
navigate(`/recollections/${recollectionId}/flux?focusNode=${encodeURIComponent(nodeId)}`)
}
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
<div className="mx-auto w-full max-w-5xl p-4">
<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="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.
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{artifacts.map((artifact) => {
const label = artifact.label || artifact.nodeId
const isImage =
artifact.type === 'image' ||
Boolean(artifact.content?.trim() && /<svg[\\s>]/i.test(artifact.content.trim()))
const imageSrc =
isImage && artifact.content
? artifact.content.startsWith('data:')
? artifact.content
: `data:image/svg+xml;utf8,${encodeURIComponent(artifact.content)}`
: null
return (
<div
key={artifact.nodeId}
className="flex h-full flex-col rounded-lg border border-border bg-card text-card-foreground shadow-sm"
>
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{label}</p>
<p className="truncate text-xs text-muted-foreground">Node: {artifact.nodeId}</p>
</div>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-emerald-500">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Live
</span>
</div>
<div className="flex flex-1 flex-col gap-2 px-3 py-3">
{isImage && imageSrc ? (
<div className="flex min-h-[120px] items-center justify-center overflow-hidden rounded-md border bg-muted">
<img
src={imageSrc}
alt={label || 'Artifact image'}
className="max-h-48 w-full max-w-full object-contain"
/>
</div>
) : artifact.content ? (
<div className="min-h-[120px] overflow-hidden rounded-md border border-border bg-background">
<iframe
title={label || 'Artifact HTML output'}
srcDoc={artifact.content}
className="h-40 w-full"
sandbox="allow-same-origin"
/>
</div>
) : (
<div className="flex min-h-[80px] items-center justify-center rounded-md border border-dashed border-muted-foreground/40 bg-muted/40 px-3 text-xs text-muted-foreground">
No preview available.
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-card px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-xs text-muted-foreground">
Type: <span className="font-medium">{artifact.type}</span>
</p>
{artifact.updatedAt != null && (
<span className="text-[11px] text-muted-foreground">
· {formatTimeSinceLastUpdate(artifact.updatedAt)}
</span>
)}
</div>
<Button
type="button"
variant="outline"
size="xs"
className="h-7 px-2 text-xs"
onClick={() => handleViewInFlux(artifact.nodeId)}
>
View in Flux
</Button>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,259 @@
/**
* Context for shared recollection actions: two slots (flux and logos) and layout-level import/export.
* Consumers use pathname to pick the active slot for title (save status, Save) and Edit/View menus (undo, redo, etc.).
*/
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { useLocation, useParams } from 'react-router-dom'
import type { SaveStatus } from '@/app/canvas/useCanvasGraph'
import {
getGraph,
setGraph,
getLogosContent,
setLogosContent,
upsertRenderOutputEntry,
RECOLLECTION_FILE_EXT,
RECOLLECTION_VERSION,
type StoredGraphState,
type StoredLogosContent,
type RenderOutputCacheEntry,
} from '../state/recollectionStore'
export type { RenderOutputCacheEntry }
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import { backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
import { toast } from 'sonner'
export type { SaveStatus }
/** Parsed recollection file format (import/export). */
export type RecollectionFilePayload = {
version?: number
graph?: { nodes: unknown[]; edges: unknown[] }
logos?: StoredLogosContent
}
export type FluxSlot = {
saveStatus: SaveStatus
onSave: () => void
canSave: boolean
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
onRefreshFromStore: (graph: StoredGraphState) => void
onDuplicate?: () => void
onCopy?: () => void
onPaste?: () => void
canDuplicate?: boolean
canCopy?: boolean
onFitView?: () => void
}
export type LogosSlot = {
saveStatus: SaveStatus
onSave: () => void
canSave: boolean
undo: () => void
redo: () => void
canUndo: boolean
canRedo: boolean
onRefreshFromStore: () => void
}
export type RecollectionActionsContextValue = {
flux: FluxSlot | null
logos: LogosSlot | null
setFluxSlot: (slot: FluxSlot | null) => void
setLogosSlot: (slot: LogosSlot | null) => void
/** Whether the Flux view is active (pathname ends with /flux). */
isFluxActive: boolean
/** Whether the Logos view is active. */
isLogosActive: boolean
/** Active slot (flux or logos by pathname). */
activeSlot: FluxSlot | LogosSlot | null
onImport: () => void
onExport: () => void
/** Upsert a rendering node's output into the cache so Logos "Insert from Flux" block can show it. */
upsertRenderOutputToLogos: (entry: RenderOutputCacheEntry) => void
}
const RecollectionActionsContext = createContext<RecollectionActionsContextValue | null>(null)
function validateAndWritePayload(
recollectionId: string,
payload: RecollectionFilePayload,
backfillEdges: (nodes: AppNode[], edges: AppEdge[]) => AppEdge[]
): { graph?: StoredGraphState; logos?: StoredLogosContent } {
const result: { graph?: StoredGraphState; logos?: StoredLogosContent } = {}
if (payload.graph && Array.isArray(payload.graph.nodes) && Array.isArray(payload.graph.edges)) {
const nodes = payload.graph.nodes as AppNode[]
const edges = backfillEdges(nodes, payload.graph.edges as AppEdge[])
const graphState: StoredGraphState = {
version: payload.version ?? RECOLLECTION_VERSION,
nodes,
edges,
}
setGraph(recollectionId, graphState)
result.graph = graphState
}
if (payload.logos != null && Array.isArray(payload.logos)) {
if (payload.logos.every((item) => item != null && typeof item === 'object')) {
setLogosContent(recollectionId, payload.logos as StoredLogosContent)
result.logos = payload.logos as StoredLogosContent
}
}
return result
}
export function RecollectionActionsProvider({ children }: { children: React.ReactNode }) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { pathname } = useLocation()
const [flux, setFluxSlotState] = useState<FluxSlot | null>(null)
const [logos, setLogosSlotState] = useState<LogosSlot | null>(null)
const importInputRef = useRef<HTMLInputElement>(null)
const fluxRef = useRef<FluxSlot | null>(null)
const logosRef = useRef<LogosSlot | null>(null)
const pathnameRef = useRef(pathname)
fluxRef.current = flux
logosRef.current = logos
pathnameRef.current = pathname
const isFluxActive = pathname.endsWith('/flux')
const isLogosActive = pathname.includes('/logos') || /\/recollections\/[^/]+\/?$/.test(pathname)
const activeSlot = isFluxActive ? flux : isLogosActive ? logos : null
const setFluxSlot = useCallback((slot: FluxSlot | null) => {
setFluxSlotState(() => slot)
}, [])
const setLogosSlot = useCallback((slot: LogosSlot | null) => {
setLogosSlotState(() => slot)
}, [])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
const mod = ev.ctrlKey || ev.metaKey
if (mod && ev.key.toLowerCase() === 's') {
const slot = activeSlot
if (slot?.onSave && slot?.canSave) {
ev.preventDefault()
ev.stopPropagation()
slot.onSave()
}
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [activeSlot])
const onExport = useCallback(() => {
if (!recollectionId) return
const graph = getGraph(recollectionId)
const logosContent = getLogosContent(recollectionId)
const payload: RecollectionFilePayload = {
version: RECOLLECTION_VERSION,
...(graph && { graph: { nodes: graph.nodes, edges: graph.edges } }),
...(logosContent && { logos: logosContent }),
}
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `recollection${RECOLLECTION_FILE_EXT}`
a.click()
URL.revokeObjectURL(url)
toast.success('Recollection exported')
}, [recollectionId])
const onImport = useCallback(() => {
importInputRef.current?.click()
}, [])
const upsertRenderOutputToLogos = useCallback(
(entry: RenderOutputCacheEntry) => {
if (recollectionId) upsertRenderOutputEntry(recollectionId, entry)
},
[recollectionId]
)
const onImportFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || !recollectionId) return
const reader = new FileReader()
reader.onload = () => {
try {
const text = reader.result as string
const payload = JSON.parse(text) as RecollectionFilePayload
if (!payload || typeof payload !== 'object') {
toast.error('Invalid file: not valid JSON')
return
}
const written = validateAndWritePayload(recollectionId, payload, (nodes, edges) =>
backfillEdgeTargetTypes(nodes, edges)
)
const currentPath = pathnameRef.current
const fluxActive = currentPath.endsWith('/flux')
const logosActive = currentPath.includes('/logos') || /\/recollections\/[^/]+\/?$/.test(currentPath)
if (written.graph && fluxActive && fluxRef.current?.onRefreshFromStore) {
fluxRef.current.onRefreshFromStore(written.graph)
}
if ((written.graph != null || written.logos != null) && logosActive && logosRef.current?.onRefreshFromStore) {
logosRef.current.onRefreshFromStore()
}
if (payload.version != null && payload.version > RECOLLECTION_VERSION) {
toast.error('Recollection was created with a newer app version')
} else {
toast.success('Recollection loaded')
}
} catch {
toast.error('Invalid file: not valid JSON')
}
}
reader.readAsText(file)
},
[recollectionId]
)
const value: RecollectionActionsContextValue = React.useMemo(
() => ({
flux,
logos,
setFluxSlot,
setLogosSlot,
isFluxActive,
isLogosActive,
activeSlot,
onImport,
onExport,
upsertRenderOutputToLogos,
}),
[flux, logos, setFluxSlot, setLogosSlot, isFluxActive, isLogosActive, activeSlot, onImport, onExport, upsertRenderOutputToLogos]
)
return (
<RecollectionActionsContext.Provider value={value}>
{children}
<input
ref={importInputRef}
type="file"
accept=".json,application/json"
className="hidden"
aria-hidden
onChange={onImportFileChange}
/>
</RecollectionActionsContext.Provider>
)
}
export function useRecollectionActions(): RecollectionActionsContextValue {
const ctx = useContext(RecollectionActionsContext)
if (!ctx) throw new Error('useRecollectionActions must be used within RecollectionActionsProvider')
return ctx
}
/** Returns the context value or null when outside RecollectionActionsProvider. Use when the consumer may render outside recollection layout. */
export function useOptionalRecollectionActions(): RecollectionActionsContextValue | null {
return useContext(RecollectionActionsContext)
}

View File

@@ -0,0 +1,154 @@
/**
* Shared Edit and View menus for recollections. Always rendered in the menubar;
* uses the active slot (flux or logos by pathname) for undo, redo, and Flux-only actions.
*/
import React, { useEffect, useMemo } from 'react'
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger,
} from '@/components/ui/menubar'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { useRecollectionActions } from './RecollectionActionsContext'
import { ClipboardPaste, Copy, CopyPlus, Redo2, Undo2 } from 'lucide-react'
const UNDO_KEYS = { key: 'z', shiftKey: false }
const REDO_KEYS = { key: 'z', shiftKey: true }
function matchKey(ev: KeyboardEvent, want: { key: string; shiftKey: boolean }) {
const mod = ev.ctrlKey || ev.metaKey
return ev.key.toLowerCase() === want.key && !!mod && !!ev.shiftKey === want.shiftKey
}
export function RecollectionEditViewMenus() {
const { activeSlot, flux, isFluxActive } = useRecollectionActions()
const fluxSlot = isFluxActive ? flux : null
useEffect(() => {
if (!activeSlot) return
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, UNDO_KEYS) && activeSlot.canUndo) {
ev.preventDefault()
ev.stopPropagation()
activeSlot.undo()
} else if (matchKey(ev, REDO_KEYS) && activeSlot.canRedo) {
ev.preventDefault()
ev.stopPropagation()
activeSlot.redo()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [activeSlot])
const hasFluxOnly =
fluxSlot &&
(fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null)
const menus = useMemo(
() => (
<Menubar className="h-9 shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
<MenubarMenu>
<MenubarTrigger className="h-9 font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem
onClick={() => activeSlot?.undo()}
disabled={!activeSlot?.canUndo}
className="gap-2"
>
<Undo2 className="h-4 w-4" />
Undo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
<MenubarItem
onClick={() => activeSlot?.redo()}
disabled={!activeSlot?.canRedo}
className="gap-2"
>
<Redo2 className="h-4 w-4" />
Redo
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd> + + Z</Kbd>
</KbdGroup>
</span>
</MenubarItem>
{hasFluxOnly && fluxSlot && (
<>
<MenubarSeparator />
{fluxSlot.onDuplicate != null && (
<MenubarItem
onClick={fluxSlot.onDuplicate}
disabled={!fluxSlot.canDuplicate}
className="gap-2"
>
<CopyPlus className="h-4 w-4" />
Duplicate
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>D</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{fluxSlot.onCopy != null && (
<MenubarItem
onClick={fluxSlot.onCopy}
disabled={!fluxSlot.canCopy}
className="gap-2"
>
<Copy className="h-4 w-4" />
Copy
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>C</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
{fluxSlot.onPaste != null && (
<MenubarItem onClick={fluxSlot.onPaste} className="gap-2">
<ClipboardPaste className="h-4 w-4" />
Paste
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>V</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
</>
)}
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="h-9 font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarContent>
{fluxSlot?.onFitView && (
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
Fit View
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>0</Kbd>
</KbdGroup>
</span>
</MenubarItem>
)}
</MenubarContent>
</MenubarMenu>
</Menubar>
),
[activeSlot, fluxSlot, hasFluxOnly]
)
return menus
}

View File

@@ -0,0 +1,102 @@
/**
* File menu for a recollection (emanation).
* Hosts Save, Rename, Import, Export that used to live under the title dropdown.
*/
import React, { useMemo, useState } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import { useRecollectionActions } from './RecollectionActionsContext'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { RenameRecollectionDialog } from './RenameRecollectionDialog'
import { Download, FolderOpen, Pencil, Save } from 'lucide-react'
import { useParams } from 'react-router-dom'
export function RecollectionFileMenu() {
const { activeSlot, onImport, onExport } = useRecollectionActions()
const { recollections, renameRecollection } = usePlatform()
const { recollectionId } = useParams<{ recollectionId: string }>()
const [renameModalOpen, setRenameModalOpen] = useState(false)
const onSave = activeSlot?.onSave
const canSave = activeSlot?.canSave ?? false
const recollectionName = useMemo(
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? '' : ''),
[recollectionId, recollections]
)
const menuItems = useMemo(
() => (
<>
{recollectionId && onSave != null && (
<>
<DropdownMenuItem onClick={onSave} disabled={!canSave} className="gap-2">
<Save className="h-4 w-4" />
Save
<span className="ml-auto pl-4">
<KbdGroup>
<Kbd>S</Kbd>
</KbdGroup>
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{recollectionId && (
<>
<DropdownMenuItem onClick={() => setRenameModalOpen(true)} className="gap-2">
<Pencil className="h-4 w-4" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={onImport} className="gap-2">
<FolderOpen className="h-4 w-4" />
Import
</DropdownMenuItem>
<DropdownMenuItem onClick={onExport} className="gap-2">
<Download className="h-4 w-4" />
Export
</DropdownMenuItem>
</>
),
[recollectionId, onSave, canSave, onImport, onExport]
)
return (
<>
<DropdownMenu>
<DropdownMenuTrigger className="flex h-9 shrink-0 items-center rounded-sm px-2 py-0 text-sm font-normal text-muted-foreground outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent">
File
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="start" className="z-[200] min-w-[12rem]">
{menuItems}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
<RenameRecollectionDialog
open={renameModalOpen}
onOpenChange={setRenameModalOpen}
recollectionId={recollectionId ?? ''}
initialName={recollectionName}
recollections={recollections}
onRename={(id, newName) => {
renameRecollection(id, newName)
setRenameModalOpen(false)
}}
/>
</>
)
}

View File

@@ -0,0 +1,114 @@
/**
* Recollection menubar: Back | breadcrumbs (left) | view switcher + File + Edit + save status (center).
*/
import React from 'react'
import { Link, useLocation, useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { CheckCircle2, CircleDot, Loader2 } from 'lucide-react'
import { RecollectionEditViewMenus } from './RecollectionEditViewMenus'
import { RecollectionViewSwitcher } from './RecollectionViewSwitcher'
import { RecollectionFileMenu } from './RecollectionFileMenu'
import { useRecollectionActions } from './RecollectionActionsContext'
import { getMode, viewLabel } from './recollectionNav'
export function RecollectionMenubar() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { pathname } = useLocation()
const { recollections } = usePlatform()
const { activeSlot } = useRecollectionActions()
const saveStatus = activeSlot?.saveStatus ?? null
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
const mode = getMode(pathname)
const base = recollectionId ? `/recollections/${recollectionId}` : ''
return (
<div className="relative flex h-9 w-full shrink-0 items-center gap-2 overflow-visible border-b border-border/40 bg-background px-2">
<div className="flex items-center gap-2">
{base && (
<Breadcrumb>
<BreadcrumbList className="text-xs">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
to="/recollections"
aria-label="Back to recollections"
className="flex shrink-0 items-center rounded-sm px-2 py-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
>
<span
className="size-4 shrink-0 rounded-[2px] opacity-90"
style={{
maskImage: 'url(/app-icon.svg)',
maskRepeat: 'no-repeat',
maskPosition: 'center',
maskSize: 'contain',
WebkitMaskImage: 'url(/app-icon.svg)',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskPosition: 'center',
WebkitMaskSize: 'contain',
backgroundColor: 'currentColor',
}}
aria-hidden
/>
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={base}>{recollection?.name ?? 'Recollection'}</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{viewLabel(mode)}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
)}
</div>
<div className="pointer-events-none absolute left-1/2 top-1/2 flex min-w-0 -translate-x-1/2 -translate-y-1/2 items-center justify-center">
<div className="pointer-events-auto flex min-w-0 items-center gap-2">
<RecollectionViewSwitcher />
<RecollectionFileMenu />
<RecollectionEditViewMenus />
{/* Fixed-width slot so layout does not jump when saveStatus is null (e.g. on Katalogos) */}
<span
className="flex h-9 w-9 shrink-0 items-center justify-center text-muted-foreground"
aria-live="polite"
aria-label={
saveStatus === 'saving' ? 'Saving' : saveStatus === 'unsaved' ? 'Unsaved changes' : saveStatus === 'saved' ? 'All changes saved' : undefined
}
>
{saveStatus != null && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex size-5 items-center justify-center">
{saveStatus === 'saving' && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
{saveStatus === 'unsaved' && <CircleDot className="size-3.5" aria-hidden />}
{saveStatus === 'saved' && <CheckCircle2 className="size-3.5 text-muted-foreground/70" aria-hidden />}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
/**
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
*/
import React, { useCallback } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Plus, FileText } from 'lucide-react'
import { FluxIcon } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useRecollectionSidebar } from './RecollectionSidebarContext'
import { TreeBrowser } from './TreeBrowser'
import {
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
sidebarMenuButtonVariants,
} from '@/components/ui/sidebar'
export function RecollectionSidebar() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { pathname } = useLocation()
const navigate = useNavigate()
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
const base = recollectionId ? `/recollections/${recollectionId}` : ''
const baseLogos = `${base}/logos`
const isKatalogosView = pathname.endsWith('/logos/katalogos')
const isFluxView = pathname.endsWith('/flux')
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])
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}
>
<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>
</div>
)
}

View File

@@ -0,0 +1,114 @@
/**
* Context for the recollection sidebar: Logos page tree and active page, shared by
* RecollectionSidebar and LogosPage. Load/persist and URL sync live here.
*/
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import {
getLogosContent,
getLogosPageTree,
setLogosPageTree,
setLogosContentForPage,
removeLogosPageContent,
type LogosPageMeta,
type LogosPageId,
} from '../state/recollectionStore'
const MAIN_PAGE_ID: LogosPageId = 'main'
export type RecollectionSidebarContextValue = {
tree: LogosPageMeta[]
activePageId: LogosPageId | null
setTree: React.Dispatch<React.SetStateAction<LogosPageMeta[]>>
handleSelectPage: (id: LogosPageId) => void
handleTreeChange: (tree: LogosPageMeta[]) => void
handleDeletePage: (pageId: LogosPageId) => void
}
const RecollectionSidebarContext = createContext<RecollectionSidebarContextValue | null>(null)
export function useRecollectionSidebar(): RecollectionSidebarContextValue {
const ctx = useContext(RecollectionSidebarContext)
if (!ctx) throw new Error('useRecollectionSidebar must be used within RecollectionSidebarProvider')
return ctx
}
export function RecollectionSidebarProvider({ children }: { children: React.ReactNode }) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const [tree, setTree] = useState<LogosPageMeta[]>([])
const [activePageId, setActivePageId] = useState<LogosPageId | null>(null)
const baseLogosPath = recollectionId ? `/recollections/${recollectionId}/logos` : ''
const handleSelectPage = useCallback(
(id: LogosPageId) => {
setActivePageId(id)
if (recollectionId) {
setSearchParams({ page: id }, { replace: true })
}
},
[recollectionId, setSearchParams]
)
const handleTreeChange = useCallback((newTree: LogosPageMeta[]) => {
setTree(newTree)
}, [])
const handleDeletePage = useCallback(
(pageId: LogosPageId) => {
if (recollectionId) removeLogosPageContent(recollectionId, pageId)
},
[recollectionId]
)
// Load page tree from storage only when recollection changes (not on every searchParams change).
// Otherwise adding a new page would trigger searchParams change and this would overwrite tree before persist.
useEffect(() => {
if (!recollectionId) return
let t = getLogosPageTree(recollectionId)
if (t.length === 0) {
const legacy = getLogosContent(recollectionId)
const main: LogosPageMeta = { id: MAIN_PAGE_ID, title: 'Main', parentId: null, position: 0 }
setLogosContentForPage(recollectionId, MAIN_PAGE_ID, legacy ?? [])
setLogosPageTree(recollectionId, [main])
t = [main]
}
setTree(t)
}, [recollectionId])
// Sync activePageId from URL when searchParams or tree changes (e.g. after adding a page).
useEffect(() => {
if (!recollectionId) return
const pageFromUrl = searchParams.get('page')
setActivePageId((prev) => {
const validIdFromUrl = pageFromUrl && tree.some((p) => p.id === pageFromUrl) ? pageFromUrl : null
if (validIdFromUrl) return validIdFromUrl
const firstId = tree[0]?.id ?? null
if (prev != null && tree.some((p) => p.id === prev)) return prev
return firstId
})
}, [recollectionId, searchParams, tree])
// Persist tree when sidebar changes it.
useEffect(() => {
if (!recollectionId || tree.length === 0) return
setLogosPageTree(recollectionId, tree)
}, [recollectionId, tree])
const value: RecollectionSidebarContextValue = {
tree,
activePageId,
setTree,
handleSelectPage,
handleTreeChange,
handleDeletePage,
}
return (
<RecollectionSidebarContext.Provider value={value}>
{children}
</RecollectionSidebarContext.Provider>
)
}

View File

@@ -0,0 +1,94 @@
/**
* View switcher: circle with current view icon; click cycles Logos → Katalogos → Flux.
* Keyboard shortcut: ⌘⇧↑ / ⌘⇧↓ to cycle view.
*/
import React, { useCallback, useEffect, useMemo } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { FluxIcon } from '@/lib/icons'
import { FileText, LayoutGrid } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { getMode, pathForMode, RECOLLECTION_VIEW_MODES, viewLabel } from './recollectionNav'
const ICON_SLOT_SIZE_REM = 2 // size-8 = 2rem
function shortcutLabel() {
const isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform)
return isMac ? '⌘⇧↑ / ⌘⇧↓' : 'Ctrl+Shift+↑ / Ctrl+Shift+↓'
}
export function RecollectionViewSwitcher() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { pathname } = useLocation()
const navigate = useNavigate()
const base = recollectionId ? `/recollections/${recollectionId}` : ''
const mode = useMemo(() => getMode(pathname), [pathname])
const modeIndex = RECOLLECTION_VIEW_MODES.indexOf(mode)
const goRelative = useCallback(
(delta: 1 | -1) => {
if (!base) return
const nextIndex = (modeIndex + delta + RECOLLECTION_VIEW_MODES.length) % RECOLLECTION_VIEW_MODES.length
navigate(pathForMode(base, RECOLLECTION_VIEW_MODES[nextIndex]))
},
[base, modeIndex, navigate]
)
const cycle = useCallback(() => {
goRelative(1)
}, [goRelative])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
const isMod = ev.ctrlKey || ev.metaKey
const isShortcut = isMod && ev.shiftKey && (ev.key === 'ArrowDown' || ev.key === 'ArrowUp')
if (!isShortcut) return
ev.preventDefault()
if (ev.key === 'ArrowDown') goRelative(1)
else if (ev.key === 'ArrowUp') goRelative(-1)
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [goRelative])
if (!base) return null
const tooltipText = `${viewLabel(mode)} (${shortcutLabel()})`
const translateY = -modeIndex * ICON_SLOT_SIZE_REM
return (
<div className="flex h-9 shrink-0 items-center">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={cycle}
aria-label={tooltipText}
className="flex size-8 shrink-0 items-start justify-center overflow-hidden rounded-full transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
>
<div
className="flex flex-col transition-transform duration-200 ease-out"
style={{ transform: `translateY(${translateY}rem)` }}
>
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
<FileText className="size-3.5 text-muted-foreground" />
</div>
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
<LayoutGrid className="size-3.5 text-muted-foreground" />
</div>
<div className="flex size-8 flex-shrink-0 items-center justify-center [&_svg]:shrink-0">
<span className="inline-flex size-3.5 shrink-0 items-center justify-center">
<FluxIcon className="size-full text-muted-foreground" />
</span>
</div>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{tooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)
}

View File

@@ -0,0 +1,99 @@
/**
* Subtle animated dot grid background for RecollectionsPage.
* Matches canvas grid (20px gap), with gentle wave movement.
*/
import React, { useCallback, useEffect, useRef } from 'react'
const GAP = 20
const SPEED = 0.010
const AMPLITUDE = 4
const DOT_SIZE = 1.2
const PULSE_AMOUNT = 0.25
function getDotColor(): string {
if (typeof document === 'undefined') return 'rgba(128,128,128,0.25)'
const isDark = document.documentElement.classList.contains('dark')
return isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)'
}
export function RecollectionsPageBackground({ className }: { className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const timeRef = useRef(0)
const rafRef = useRef<number>(0)
const draw = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const { width, height } = canvas
const cols = Math.floor(width / GAP) + 2
const rows = Math.floor(height / GAP) + 2
const t = timeRef.current
ctx.fillStyle = 'transparent'
ctx.clearRect(0, 0, width, height)
const dotColor = getDotColor()
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const baseX = col * GAP
const baseY = row * GAP
const w1 = Math.sin(t * SPEED + baseX * 0.008 + baseY * 0.006) * AMPLITUDE
const w2 = Math.sin(t * SPEED * 0.7 - baseX * 0.012 + baseY * 0.01) * AMPLITUDE * 0.5
const w3 = Math.sin(t * SPEED * 1.2 + baseY * 0.015) * AMPLITUDE * 0.3
const x = baseX + w1 + w2 + w3
const y = baseY + w1 * 0.6 + w3 * 0.4
const size = DOT_SIZE + Math.sin(t * SPEED * 2 + baseX * 0.02) * PULSE_AMOUNT
ctx.beginPath()
ctx.arc(x, y, size, 0, Math.PI * 2)
ctx.fillStyle = dotColor
ctx.fill()
}
}
timeRef.current += 1
rafRef.current = requestAnimationFrame(draw)
}, [])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const resize = () => {
const dpr = Math.min(window.devicePixelRatio ?? 1, 2)
const rect = canvas.getBoundingClientRect()
canvas.width = Math.round(rect.width * dpr)
canvas.height = Math.round(rect.height * dpr)
canvas.style.width = `${rect.width}px`
canvas.style.height = `${rect.height}px`
const ctx = canvas.getContext('2d')
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
}
resize()
window.addEventListener('resize', resize)
rafRef.current = requestAnimationFrame(draw)
return () => {
window.removeEventListener('resize', resize)
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [draw])
return (
<canvas
ref={canvasRef}
className={className}
aria-hidden
style={{ display: 'block', width: '100%', height: '100%' }}
/>
)
}

View File

@@ -0,0 +1,94 @@
/**
* Shared modal for renaming a recollection. Used by RecollectionsPage and RecollectionFileMenu.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
export type RenameRecollectionDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
recollectionId: string
initialName: string
recollections: { id: string; name: string }[]
onRename: (id: string, newName: string) => void
}
export function RenameRecollectionDialog({
open,
onOpenChange,
recollectionId,
initialName,
recollections,
onRename,
}: RenameRecollectionDialogProps) {
const [value, setValue] = useState(initialName)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setValue(initialName)
const t = setTimeout(() => inputRef.current?.focus(), 0)
return () => clearTimeout(t)
}
}, [open, initialName])
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange])
const handleSubmit = useCallback(() => {
const trimmed = value.trim()
if (!trimmed) return
onRename(recollectionId, trimmed)
onOpenChange(false)
}, [recollectionId, value, onRename, onOpenChange])
const isDuplicateName =
!!value.trim() &&
recollections.some(
(p) => p.id !== recollectionId && p.name.toLowerCase() === value.trim().toLowerCase()
)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Rename recollection</DialogTitle>
<DialogDescription>Enter a new name for this recollection.</DialogDescription>
</DialogHeader>
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit()
if (e.key === 'Escape') handleClose()
}}
placeholder="Recollection name"
aria-label="Recollection name"
/>
{isDuplicateName && (
<p className="text-xs text-amber-600 dark:text-amber-500">
A recollection with this name already exists.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!value.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,609 @@
/**
* 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
*/
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import {
Tree,
NodeApi,
NodeRendererProps,
RowRendererProps,
TreeApi,
type CursorProps,
} from 'react-arborist'
import { cn } from '@/lib/utils'
import {
FileText,
ChevronRight,
ChevronDown,
Plus,
MoreHorizontal,
Trash2,
Pencil,
Search,
ChevronsDownUp,
ChevronsUpDown,
X,
GripVertical,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
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'
// Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object).
type TreeNode = LogosPageMeta & {
children?: TreeNode[]
isFolder: boolean
}
// Convert flat tree to hierarchical structure
function buildTree(pages: LogosPageMeta[]): TreeNode[] {
const pageMap = new Map<string, TreeNode>()
const rootNodes: TreeNode[] = []
// First pass: create all nodes
pages.forEach((page) => {
pageMap.set(page.id, {
...page,
children: [],
isFolder: false,
})
})
// Second pass: build parent-child relationships
pages.forEach((page) => {
const node = pageMap.get(page.id)!
if (page.parentId === null) {
rootNodes.push(node)
} else {
const parent = pageMap.get(page.parentId)
if (parent) {
parent.children?.push(node)
parent.isFolder = true
}
}
})
// Sort children by position
function sortNodes(nodes: TreeNode[]) {
nodes.sort((a, b) => a.position - b.position)
nodes.forEach((node) => sortNodes(node.children || []))
}
sortNodes(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()
}
}
return (
<input
ref={inputRef}
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"
onClick={(e) => e.stopPropagation()}
/>
)
}
const ROW_INDENT_PX = 20
const ROW_GUTTER_PX = 8
/**
* 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) {
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),
}}
>
<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>
</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()
const base = recollectionId ? `/recollections/${recollectionId}` : ''
const baseLogos = `${base}/logos`
const handleToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
node.toggle()
},
[node]
)
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]
)
const handleRename = useCallback(() => {
node.edit()
}, [node])
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'
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 ? (
<button
type="button"
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={handleToggle}
>
{node.isOpen ? (
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
) : (
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
)}
</button>
) : (
<div className="w-4 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'
)}
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"
>
<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>
</div>
)
}
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */
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
return (
<div
ref={innerRef}
{...attrs}
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))]'
)}
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
>
{level > 0 && (
<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` }}
/>
)}
{children}
</div>
)
}
export function TreeBrowser() {
const { tree, handleTreeChange } = useRecollectionSidebar()
const [searchQuery, setSearchQuery] = useState('')
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600)
// Build tree from flat structure
const treeNodes = useTreeNodes(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,
})
}
}
return result
}
return filterNodes(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
}, [treeNodes])
// Handle drag and drop reordering (react-arborist onMove)
const handleMove = useCallback(
({
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 (!dragged) return
const oldParentId = dragged.parentId
const moved: LogosPageMeta = { ...dragged, parentId: newParentId }
const newSiblings = tree
.filter((p) => p.parentId === newParentId && p.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 })
})
if (oldParentId !== newParentId) {
tree
.filter((p) => p.parentId === oldParentId && p.id !== dragId)
.sort((a, b) => a.position - b.position)
.forEach((p, i) => {
updates.set(p.id, { ...p, position: i })
})
}
handleTreeChange(tree.map((p) => updates.get(p.id) ?? p))
},
[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,
idAccessor: 'id' as const,
childrenAccessor: 'children' as const,
width: '100%' as const,
height: Math.max(treeContainerHeight, 100),
rowHeight: 32,
indent: ROW_INDENT_PX,
renderRow: TreeRow,
initialOpenState,
onMove: handleMove,
disableDrag: Boolean(searchQuery),
disableDrop: Boolean(searchQuery),
className: 'react-arborist-tree',
renderCursor: LogosDropCursor,
children: LogosTreeNode,
}),
[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>
)
}

View File

@@ -0,0 +1,25 @@
/**
* Recollection navigation helpers: current view mode from pathname, labels for breadcrumbs.
*/
export type RecollectionViewMode = 'logos' | 'katalogos' | 'flux'
export function getMode(pathname: string): RecollectionViewMode {
if (pathname.endsWith('/flux')) return 'flux'
if (pathname.endsWith('/logos/katalogos')) return 'katalogos'
return 'logos'
}
export function viewLabel(mode: RecollectionViewMode): string {
if (mode === 'katalogos') return 'Katalogos'
if (mode === 'flux') return 'Flux'
return 'Logos'
}
export const RECOLLECTION_VIEW_MODES: RecollectionViewMode[] = ['logos', 'katalogos', 'flux']
export function pathForMode(base: string, mode: RecollectionViewMode): string {
if (mode === 'flux') return `${base}/flux`
if (mode === 'katalogos') return `${base}/logos/katalogos`
return `${base}/logos`
}

View File

@@ -0,0 +1,214 @@
/**
* Logos page: BlockNote editor for the recollection. Content only; sidebar is at layout level.
* Content persisted in recollection store.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState, forwardRef } from 'react'
import { useParams, useLocation, useSearchParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { FluxIcon } from '@/lib/icons'
import { useCreateBlockNote, getDefaultReactSlashMenuItems, SuggestionMenuController } from '@blocknote/react'
import { BlockNoteView } from '@blocknote/shadcn'
import { useTheme } from '@/lib/themeContext'
import { useRecollectionActions } from '../layout/RecollectionActionsContext'
import { useRecollectionSidebar } from '../layout/RecollectionSidebarContext'
import {
getLogosContentForPage,
setLogosContentForPage,
type StoredLogosContent,
} from '../state/recollectionStore'
import { logosSchema } from './logosSchema'
import { KatalogosPage } from '../katalogos/KatalogosPage'
import { toast } from 'sonner'
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
const BlockNoteViewWrapper = forwardRef<HTMLDivElement, React.ComponentProps<typeof BlockNoteView>>(
function BlockNoteViewWrapper(props, ref) {
const { className, ref: _ref, ...rest } = props as React.ComponentProps<typeof BlockNoteView> & { ref?: unknown }
return (
<div ref={ref} className={`logos-blocknote ${className ?? ''}`} style={{ minHeight: '100%', width: '100%' }}>
<BlockNoteView {...rest} />
</div>
)
}
)
const SAVE_DEBOUNCE_MS = 400
/** Known React ref warning from BlockNote/Radix internals; we can't fix it in our code. Suppress once at load so it's active before first BlockNote render. */
function isBlockNoteRefWarning(args: unknown[]): boolean {
const s = args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ')
return (
s.includes('Function components cannot be given refs') &&
s.includes('ForwardRef') &&
s.includes('blocknote')
)
}
let blockNoteRefWarningPatched = false
function patchBlockNoteRefWarning() {
if (blockNoteRefWarningPatched) return
blockNoteRefWarningPatched = true
const orig = console.error
console.error = (...args: unknown[]) => {
if (isBlockNoteRefWarning(args)) return
orig.apply(console, args)
}
}
export function LogosPage() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { pathname } = useLocation()
const [searchParams, setSearchParams] = useSearchParams()
const { recollections } = usePlatform()
const { theme } = useTheme()
const { setLogosSlot } = useRecollectionActions()
const { tree, activePageId } = useRecollectionSidebar()
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
const [reloadKey, setReloadKey] = useState(0)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
const isKatalogosView = pathname.endsWith('/logos/katalogos')
// When on Logos (not Katalogos) with a selected page but no page param, sync URL so refresh keeps the page.
useEffect(() => {
if (isKatalogosView || !activePageId || searchParams.get('page') === activePageId) return
setSearchParams({ page: activePageId }, { replace: true })
}, [isKatalogosView, activePageId, searchParams, setSearchParams])
const initialContent = useMemo(() => {
if (!recollectionId || !activePageId) return undefined
const content = getLogosContentForPage(recollectionId, activePageId)
if (!content || !Array.isArray(content) || content.length === 0) return undefined
return content
}, [recollectionId, activePageId, reloadKey])
const editor = useCreateBlockNote(
{ schema: logosSchema, initialContent },
[recollectionId, activePageId, reloadKey]
)
const persistContent = useCallback(() => {
if (!recollectionId || !activePageId || !editor) return
setSaveStatus('saving')
try {
const doc = editor.document
const serialized = JSON.parse(JSON.stringify(doc)) as StoredLogosContent
setLogosContentForPage(recollectionId, activePageId, serialized)
setSaveStatus('saved')
} catch {
setSaveStatus('unsaved')
}
}, [recollectionId, activePageId, editor])
const onSave = useCallback(() => {
persistContent()
toast.success('Saved')
}, [persistContent])
useEffect(() => {
if (!editor || !recollectionId || !activePageId) return
const handleChange = () => {
setSaveStatus('unsaved')
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = setTimeout(() => {
saveTimeoutRef.current = null
persistContent()
}, SAVE_DEBOUNCE_MS)
}
editor.onChange(handleChange)
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
saveTimeoutRef.current = null
}
}
}, [editor, recollectionId, activePageId, persistContent])
useEffect(() => {
if (isKatalogosView || !editor || !recollectionId || !activePageId) {
if (isKatalogosView) setLogosSlot(null)
return
}
const slot = {
saveStatus,
onSave,
canSave: true,
undo: () => (editor as { undo?: () => void }).undo?.(),
redo: () => (editor as { redo?: () => void }).redo?.(),
canUndo: true,
canRedo: true,
onRefreshFromStore: () => setReloadKey((k) => k + 1),
}
setLogosSlot(slot)
return () => setLogosSlot(null)
}, [isKatalogosView, setLogosSlot, saveStatus, onSave, editor, recollectionId, activePageId])
const activePage = tree.find((p) => p.id === activePageId)
patchBlockNoteRefWarning()
const getSlashMenuItems = useCallback(
async (query: string) => {
const defaultItems = getDefaultReactSlashMenuItems(editor)
const fluxItem = {
title: 'Insert Artifact',
subtext: 'Insert an Artifact produced by a Flux rendering node',
icon: <FluxIcon className="size-4" />,
onItemClick: () => {
const pos = editor.getTextCursorPosition()
editor.replaceBlocks([pos.block.id], [{ type: 'fluxOutput', props: {} }])
},
aliases: ['artifact', 'flux', 'output', 'render'] as const,
group: 'Artifacts',
}
const all = [...defaultItems, fluxItem]
const q = query.trim().toLowerCase()
if (!q) return all
return all.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
(item.aliases && item.aliases.some((a: string) => a.toLowerCase().includes(q)))
)
},
[editor]
)
if (!recollectionId) {
return (
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
<p className="text-sm">No recollection selected.</p>
</div>
)
}
if (isKatalogosView) {
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
<KatalogosPage />
</div>
)
}
if (activePageId == null || !editor) {
return (
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center bg-background text-sm text-muted-foreground">
{tree.length === 0 ? 'Loading…' : 'Select a page'}
</div>
)
}
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
<div className="mx-auto w-full max-w-3xl p-4">
<BlockNoteViewWrapper
editor={editor as any}
theme={theme}
className="min-h-full w-full"
slashMenu={false}
>
<SuggestionMenuController triggerCharacter="/" getItems={getSlashMenuItems} />
</BlockNoteViewWrapper>
</div>
</div>
)
}

View File

@@ -0,0 +1,200 @@
/**
* BlockNote block "Flux output": insert Artifacts produced by Flux rendering nodes into Logos.
* Empty state: placeholder + picker over getRenderOutputCache(recollectionId).
* Filled state: when nodeId is set, display is live from the cache (updates when the rendering node changes in Flux);
* otherwise or when cache entry is missing, show stored content (static).
* Shows live/static status and time since last update.
*/
import React, { useCallback, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { createReactBlockSpec } from '@blocknote/react'
import type { ReactCustomBlockRenderProps } from '@blocknote/react'
import {
getRenderOutputCache,
formatTimeSinceLastUpdate,
type RenderOutputCacheEntry,
} from '../../state/recollectionStore'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
const fluxOutputBlockConfig = {
type: 'fluxOutput' as const,
propSchema: {
nodeId: {
default: '',
},
contentType: {
default: 'image' as const,
values: ['image', 'html'] as const,
},
content: {
default: '',
},
label: {
default: '',
},
},
content: 'none' as const,
}
function FluxOutputBlockContent({
block,
editor,
contentRef,
}: ReactCustomBlockRenderProps<'fluxOutput', typeof fluxOutputBlockConfig.propSchema, 'none'>) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const navigate = useNavigate()
const [pickerOpen, setPickerOpen] = useState(false)
const nodeId = block.props.nodeId ?? ''
const storedContent = block.props.content ?? ''
const storedContentType = block.props.contentType ?? 'image'
const storedLabel = block.props.label ?? ''
const handleSelect = useCallback(
(entry: RenderOutputCacheEntry) => {
editor.updateBlock(block.id, {
props: {
nodeId: entry.nodeId,
contentType: entry.type,
content: entry.content,
label: entry.label,
},
})
setPickerOpen(false)
},
[editor, block.id]
)
const artifacts = recollectionId ? getRenderOutputCache(recollectionId) : []
// Resolve display from cache when block is tied to a node (live); otherwise use stored props (static).
const liveEntry = nodeId && recollectionId ? artifacts.find((e) => e.nodeId === nodeId) : null
const content = liveEntry ? liveEntry.content : storedContent
const rawContentType = liveEntry ? liveEntry.type : storedContentType
const label = liveEntry ? liveEntry.label : storedLabel
const isLive = Boolean(liveEntry)
const timeSince = liveEntry?.updatedAt != null ? formatTimeSinceLastUpdate(liveEntry.updatedAt) : ''
// If content is SVG but type was stored as html, show as image (fixes incorrect cache or legacy data).
const isSvgContent = Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
const contentType = rawContentType === 'image' || isSvgContent ? 'image' : 'html'
const handleViewInFlux = useCallback(() => {
if (recollectionId && nodeId) {
navigate(`/recollections/${recollectionId}/flux?focusNode=${encodeURIComponent(nodeId)}`)
}
}, [recollectionId, nodeId, navigate])
// Empty: show placeholder + picker when nothing inserted yet
if (!content) {
return (
<div
ref={contentRef}
className="min-h-[80px] rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 p-4"
>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
Insert Artifact
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
{artifacts.length === 0 ? (
<div className="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 and in Katalogos automatically.
</div>
) : (
<ul className="max-h-64 overflow-auto py-2">
{artifacts.map((entry) => (
<li key={entry.nodeId}>
<button
type="button"
className="w-full px-4 py-2 text-left text-sm hover:bg-muted"
onClick={() => handleSelect(entry)}
>
{entry.label || entry.nodeId}
<span className="ml-2 text-muted-foreground">({entry.type})</span>
</button>
</li>
))}
</ul>
)}
</PopoverContent>
</Popover>
</div>
)
}
// Filled: card layout with header (label + live/static status + time) and content
const header = (
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/60 bg-muted/30 px-3 py-1.5 rounded-t-lg">
<div className="flex min-w-0 items-center gap-2">
{label ? <span className="truncate text-xs font-medium text-foreground">{label}</span> : null}
<span
className={
isLive
? 'inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400'
: 'inline-flex items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground'
}
>
{isLive && <span className="h-1 w-1 shrink-0 rounded-full bg-emerald-500" aria-hidden />}
{isLive ? 'Live' : 'Static'}
</span>
{timeSince ? (
<span className="text-[10px] text-muted-foreground">{timeSince}</span>
) : null}
</div>
{nodeId && recollectionId ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground"
onClick={handleViewInFlux}
>
View in Flux
</Button>
) : null}
</div>
)
if (contentType === 'image') {
const src =
content.startsWith('data:') ? content : `data:image/svg+xml;utf8,${encodeURIComponent(content)}`
return (
<div
ref={contentRef}
className="min-h-[40px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm"
>
{header}
<div className="p-2">
<img src={src} alt={label || 'Artifact'} className="max-w-full rounded-md border border-border/60 object-contain" />
</div>
</div>
)
}
// HTML: render in iframe to limit script execution (XSS safety)
return (
<div
ref={contentRef}
className="min-h-[40px] overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm"
>
{header}
<div className="p-2">
<iframe
title={label || 'Artifact HTML'}
srcDoc={content}
className="min-h-[120px] w-full rounded-md border border-border/60 bg-background"
sandbox="allow-same-origin"
/>
</div>
</div>
)
}
export const createFluxOutputBlock = () =>
createReactBlockSpec(fluxOutputBlockConfig, {
render: FluxOutputBlockContent,
})

View File

@@ -0,0 +1,17 @@
/**
* BlockNote schema for Logos: default blocks + Flux output block.
*/
import { BlockNoteSchema, defaultBlockSpecs } from '@blocknote/core'
import { createFluxOutputBlock } from './blocks/fluxOutputBlock'
const fluxOutputBlock = createFluxOutputBlock()
export const logosSchema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
fluxOutput: fluxOutputBlock,
},
})
export type LogosSchema = typeof logosSchema

View File

@@ -0,0 +1,24 @@
/**
* Per-recollection graph persistence. Re-exports from recollectionStore for backward compatibility.
* New code should use recollectionStore (getGraph, setGraph, removeRecollectionData) directly.
*/
import {
getGraph,
setGraph,
removeRecollectionData,
RECOLLECTION_FILE_EXT,
RECOLLECTION_VERSION,
type StoredGraphState,
} from './recollectionStore'
export type { StoredGraphState }
export { RECOLLECTION_FILE_EXT, RECOLLECTION_VERSION }
export function getGraphStorageKey(recollectionId: string): string {
return `zui_graph_${recollectionId}`
}
export const loadGraphFromStorage = getGraph
export const saveGraphToStorage = setGraph
export const removeGraphFromStorage = removeRecollectionData

View File

@@ -0,0 +1,283 @@
/**
* Per-recollection persistence: graph (Flux) and logos (BlockNote) state.
* Single API for loading, saving, and removing all data for a recollection.
*/
import type { StoredGraphState } from '@/lib/graph/state'
export type { StoredGraphState }
/** BlockNote document: array of blocks (PartialBlock). Stored as JSON. */
export type StoredLogosContent = Record<string, unknown>[]
/** Id for a Logos page or subpage. */
export type LogosPageId = string
/** Metadata for one page or subpage in the Logos hierarchy (one level: page → subpages). */
export type LogosPageMeta = {
id: LogosPageId
title: string
/** null = top-level page; non-null = subpage under that page. */
parentId: LogosPageId | null
/** Order among siblings (same parent). */
position: number
}
/** One entry in the render-output cache (one per rendering node, overwritten on each update). */
export type RenderOutputCacheEntry = {
nodeId: string
label: string
type: 'image' | 'html'
content: string
/** Timestamp (ms) when this entry was last updated. */
updatedAt?: number
}
/** Returns a short "time since" string for display (e.g. "Just now", "2 min ago"). */
export function formatTimeSinceLastUpdate(updatedAt: number | undefined): string {
if (updatedAt == null || typeof updatedAt !== 'number') return ''
const delta = Date.now() - updatedAt
if (delta < 15_000) return 'Just now'
if (delta < 60_000) return `${Math.round(delta / 1000)}s ago`
if (delta < 3600_000) return `${Math.round(delta / 60_000)} min ago`
if (delta < 86400_000) return `${Math.round(delta / 3600_000)}h ago`
return `${Math.round(delta / 86400_000)}d ago`
}
export const RECOLLECTION_FILE_EXT = '.zui.json'
export const RECOLLECTION_VERSION = 1
const GRAPH_KEY_PREFIX = 'zui_graph_'
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_'
function getGraphKey(recollectionId: string): string {
return `${GRAPH_KEY_PREFIX}${recollectionId}`
}
function getLogosKey(recollectionId: string): string {
return `${LOGOS_KEY_PREFIX}${recollectionId}`
}
function getLogosPageTreeKey(recollectionId: string): string {
return `${LOGOS_PAGE_TREE_PREFIX}${recollectionId}`
}
function getLogosPageContentKey(recollectionId: string, pageId: LogosPageId): string {
return `${LOGOS_PAGE_CONTENT_PREFIX}${recollectionId}_${pageId}`
}
function getRenderCacheKey(recollectionId: string): string {
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
}
// ---------------------------------------------------------------------------
// Persisted State with Versioning
// ---------------------------------------------------------------------------
/**
* Wrapper for persisted state with version tracking.
* Enables schema migrations when the version changes.
*/
export type PersistedState<T> = {
/** Current schema version. Increment when making breaking changes. */
version: number
/** The actual data being persisted. */
data: T
/** Timestamp (ms) when this data was last migrated. */
migratedAt?: number
}
/**
* Check if a persisted state has the expected version.
* @param state - The persisted state to check
* @param expectedVersion - The expected version number
* @returns true if versions match, false otherwise
*/
export function isVersionMatch<T>(state: PersistedState<T> | null, expectedVersion: number): boolean {
return state?.version === expectedVersion
}
/**
* Migrate persisted state to the latest version.
* Add migration logic here when changing the schema.
*
* @param state - The persisted state to migrate
* @returns Migrated state with updated version
*/
export function migratePersistedState<T>(state: PersistedState<T>): PersistedState<T> {
if (state.version === RECOLLECTION_VERSION) {
return state
}
let migratedData = state.data
let migratedVersion = state.version
// Migration 0 -> 1: Initial version with version field
if (migratedVersion < 1) {
// Data from version 0 already has the correct structure
// Just add the version field
migratedVersion = 1
}
return {
version: migratedVersion,
data: migratedData,
migratedAt: Date.now(),
}
}
// ---------------------------------------------------------------------------
// Graph Storage with Versioning
// ---------------------------------------------------------------------------
export function getGraph(recollectionId: string): StoredGraphState | null {
try {
const raw = localStorage.getItem(getGraphKey(recollectionId))
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 setGraph(recollectionId: string, state: StoredGraphState): void {
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
}
// ---------------------------------------------------------------------------
// Logos Storage
// ---------------------------------------------------------------------------
export function getLogosContent(recollectionId: string): StoredLogosContent | null {
try {
const raw = localStorage.getItem(getLogosKey(recollectionId))
if (!raw) return null
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return null
if (!data.every((item) => item != null && typeof item === 'object')) return null
return data as StoredLogosContent
} catch {
return null
}
}
export function setLogosContent(recollectionId: string, content: StoredLogosContent): void {
localStorage.setItem(getLogosKey(recollectionId), JSON.stringify(content))
}
/** Logos page tree: ordered list of page metas (pages and subpages). */
export function getLogosPageTree(recollectionId: string): LogosPageMeta[] {
try {
const raw = localStorage.getItem(getLogosPageTreeKey(recollectionId))
if (!raw) return []
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
return data.filter(
(item): item is LogosPageMeta =>
item != null &&
typeof item === 'object' &&
typeof (item as LogosPageMeta).id === 'string' &&
typeof (item as LogosPageMeta).title === 'string' &&
((item as LogosPageMeta).parentId === null || typeof (item as LogosPageMeta).parentId === 'string') &&
typeof (item as LogosPageMeta).position === 'number'
) as LogosPageMeta[]
} catch {
return []
}
}
export function setLogosPageTree(recollectionId: string, tree: LogosPageMeta[]): void {
localStorage.setItem(getLogosPageTreeKey(recollectionId), JSON.stringify(tree))
}
/** Per-page Logos content (for page/subpage hierarchy). */
export function getLogosContentForPage(
recollectionId: string,
pageId: LogosPageId
): StoredLogosContent | null {
try {
const raw = localStorage.getItem(getLogosPageContentKey(recollectionId, pageId))
if (!raw) return null
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return null
if (!data.every((item) => item != null && typeof item === 'object')) return null
return data as StoredLogosContent
} catch {
return null
}
}
export function setLogosContentForPage(
recollectionId: string,
pageId: LogosPageId,
content: StoredLogosContent
): void {
localStorage.setItem(getLogosPageContentKey(recollectionId, pageId), JSON.stringify(content))
}
/** Remove stored content for a single page (e.g. when deleting that page). */
export function removeLogosPageContent(recollectionId: string, pageId: LogosPageId): void {
localStorage.removeItem(getLogosPageContentKey(recollectionId, pageId))
}
// ---------------------------------------------------------------------------
// Render Output Cache
// ---------------------------------------------------------------------------
/** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
try {
const raw = localStorage.getItem(getRenderCacheKey(recollectionId))
if (!raw) return []
const data = JSON.parse(raw) as unknown
if (!Array.isArray(data)) return []
return data.filter(
(item): item is RenderOutputCacheEntry =>
item != null &&
typeof item === 'object' &&
typeof (item as RenderOutputCacheEntry).nodeId === 'string' &&
typeof (item as RenderOutputCacheEntry).label === 'string' &&
((item as RenderOutputCacheEntry).type === 'image' || (item as RenderOutputCacheEntry).type === 'html') &&
typeof (item as RenderOutputCacheEntry).content === 'string'
) as RenderOutputCacheEntry[]
} catch {
return []
}
}
export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOutputCacheEntry): void {
const entries = getRenderOutputCache(recollectionId)
const byNodeId = new Map(entries.map((e) => [e.nodeId, e]))
byNodeId.set(entry.nodeId, entry)
localStorage.setItem(
getRenderCacheKey(recollectionId),
JSON.stringify(Array.from(byNodeId.values()))
)
}
// ---------------------------------------------------------------------------
// Remove Recollection Data
// ---------------------------------------------------------------------------
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */
export function removeRecollectionData(recollectionId: string): void {
localStorage.removeItem(getGraphKey(recollectionId))
localStorage.removeItem(getLogosKey(recollectionId))
const tree = getLogosPageTree(recollectionId)
for (const page of tree) {
removeLogosPageContent(recollectionId, page.id)
}
localStorage.removeItem(getLogosPageTreeKey(recollectionId))
localStorage.removeItem(getRenderCacheKey(recollectionId))
}

View File

@@ -0,0 +1,113 @@
/**
* Shared code editor with Nunjucks support by default.
* Uses react-simple-code-editor + Prism + prism-react-renderer; highlights base language + {{ }}, {% %}, {# #}.
* Wherever this editor is used, it provides the same features (syntax highlighting + Nunjucks).
* The parent (node) provides the expected base language for highlighting.
*/
import React, { useCallback, useLayoutEffect, useRef } from 'react'
import Editor from 'react-simple-code-editor'
import { highlight, type HighlightLanguage } from '@/lib/syntaxHighlight'
export type CodeEditorProps = {
value: string
onValueChange: (value: string) => void
/** Base language for syntax highlighting (Nunjucks is always applied on top). */
language: HighlightLanguage
/** Stable id for the underlying textarea (for insert-at-cursor). */
textareaId?: string
readOnly?: boolean
placeholder?: string
padding?: number
tabSize?: number
insertSpaces?: boolean
ignoreTabKey?: boolean
style?: React.CSSProperties
className?: string
textareaClassName?: string
preClassName?: string
/** Minimum height so the container doesn't collapse before resize. */
minHeight?: number
}
const defaultStyle: React.CSSProperties = {
fontFamily: 'ui-monospace, monospace',
fontSize: 12,
lineHeight: 1.5,
overflow: 'auto',
}
export function CodeEditor({
value,
onValueChange,
language,
textareaId,
readOnly = false,
padding = 8,
tabSize = 2,
insertSpaces = true,
ignoreTabKey = false,
style,
className,
textareaClassName,
preClassName,
minHeight = 120,
}: CodeEditorProps) {
const containerRef = useRef<HTMLDivElement | null>(null)
const selectionRestoreRef = useRef<{ start: number; end: number } | null>(null)
const highlightCode = useCallback(
(code: string) => highlight(code, language),
[language]
)
const handleValueChange = useCallback(
(newValue: string) => {
if (readOnly) {
onValueChange(newValue)
return
}
const ta =
(textareaId ? document.getElementById(textareaId) : containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
const start = ta?.selectionStart ?? newValue.length
const end = ta?.selectionEnd ?? newValue.length
selectionRestoreRef.current = { start, end }
onValueChange(newValue)
},
[onValueChange, readOnly, textareaId]
)
useLayoutEffect(() => {
const pending = selectionRestoreRef.current
if (pending == null) return
selectionRestoreRef.current = null
const el = (textareaId
? document.getElementById(textareaId)
: containerRef.current?.querySelector('textarea')) as HTMLTextAreaElement | null
if (el) {
const { start, end } = pending
const safeEnd = Math.min(end, el.value.length)
const safeStart = Math.min(start, safeEnd)
el.setSelectionRange(safeStart, safeEnd)
}
}, [value, textareaId])
return (
<div ref={containerRef} className="code-editor min-h-0 h-full overflow-auto" style={{ minHeight: 0 }}>
<Editor
value={value}
onValueChange={handleValueChange}
highlight={highlightCode}
tabSize={tabSize}
insertSpaces={insertSpaces}
ignoreTabKey={ignoreTabKey}
padding={padding}
readOnly={readOnly}
textareaId={textareaId}
style={{ ...defaultStyle, minHeight, ...style }}
className={className}
textareaClassName={textareaClassName}
preClassName={preClassName}
/>
</div>
)
}

View File

@@ -1,22 +1,20 @@
import React, { useContext, useMemo } from 'react'
import React, { memo } from 'react'
import {
BaseEdge,
getBezierPath,
type EdgeProps,
} from '@xyflow/react'
import FlowContext from '../../lib/flowContext'
import { useCanvasStore } from '@/app/canvas/canvasStore'
import { selectConnectionStatusForEdge } from '@/app/canvas/canvasStore.selectors'
import { CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
import { getConnectionLabelForTargetAndStatus } from '@/lib/graph/nodeRegistry'
const EDGE_STROKE_WIDTH = 2
const DOT_MARKER_R = 1.5
function getEdgeLabelByTargetType(targetType: string | undefined): string | undefined {
if (targetType === 'render') return 'render'
if (targetType === 'config' || targetType === 'function') return 'add input'
return undefined
}
export function AnimatedEdge({
function AnimatedEdgeInner({
id,
source,
sourceX,
sourceY,
targetX,
@@ -27,15 +25,17 @@ export function AnimatedEdge({
label: labelProp,
interactionWidth,
target,
data,
}: EdgeProps) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
const derivedLabel = useMemo(
() => getEdgeLabelByTargetType(targetNode?.type),
[targetNode?.type]
const connectionStatus = useCanvasStore((s) =>
selectConnectionStatusForEdge(s, source, target)
)
const label = labelProp ?? derivedLabel
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
const targetType = (data as { targetType?: string } | undefined)?.targetType ?? ''
const dataLabel = (data as { connectionLabel?: string } | undefined)?.connectionLabel
const displayLabel =
getConnectionLabelForTargetAndStatus(targetType, connectionStatus) ?? labelProp ?? dataLabel
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
sourceX,
@@ -93,10 +93,10 @@ export function AnimatedEdge({
strokeWidth: EDGE_STROKE_WIDTH,
...style,
}}
className="animated-edge-path"
className={`animated-edge-path${statusClass ? ` ${statusClass}` : ''}`}
interactionWidth={interactionWidth}
/>
{label != null && (
{displayLabel != null && displayLabel !== '' && (
<g transform={`translate(${edgeLabelX}, ${edgeLabelY})`} className="nodrag nopan">
<rect
x={-32}
@@ -113,10 +113,32 @@ export function AnimatedEdge({
dominantBaseline="middle"
className="fill-foreground text-[10px] font-medium"
>
{label}
{displayLabel}
</text>
</g>
)}
</>
)
}
function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean {
return (
prev.id === next.id &&
prev.source === next.source &&
prev.target === next.target &&
prev.sourceX === next.sourceX &&
prev.sourceY === next.sourceY &&
prev.targetX === next.targetX &&
prev.targetY === next.targetY &&
prev.sourcePosition === next.sourcePosition &&
prev.targetPosition === next.targetPosition &&
prev.style === next.style &&
prev.label === next.label &&
(prev.data as { connectionLabel?: string; targetType?: string } | undefined)?.connectionLabel ===
(next.data as { connectionLabel?: string; targetType?: string } | undefined)?.connectionLabel &&
(prev.data as { targetType?: string } | undefined)?.targetType ===
(next.data as { targetType?: string } | undefined)?.targetType
)
}
export const AnimatedEdge = memo(AnimatedEdgeInner, edgePropsAreEqual)

View File

@@ -1,10 +1,17 @@
import type { ComponentProps, ReactNode } from "react";
import { NodeResizeControl } from "@xyflow/react";
import { NodeResizer } from "@xyflow/react";
import { useContext } from "react";
import { FlowUIContext } from "@/lib/graph/flowContext";
import { useConnectionPathRoleFromStore } from "@/app/canvas/useCanvasConnectionPathFromStore";
import { cn } from "@/lib/utils";
/** Default min size for resizable nodes (used by NodeResizer). */
export const RESIZE_MIN_WIDTH = 120;
export const RESIZE_MIN_HEIGHT = 80;
export type BaseNodeProps = ComponentProps<"div"> & {
/** When true, shows a bottom-right resize handle. Requires nodeId when used inside React Flow. */
/** When true, shows resize handles on corners/edges (via NodeResizer). Requires nodeId when used inside React Flow. */
resizable?: boolean;
/** Node id for the resize control (required when resizable is true). */
nodeId?: string;
@@ -12,6 +19,10 @@ export type BaseNodeProps = ComponentProps<"div"> & {
handles?: ReactNode;
/** When provided, node uses this size (e.g. from React Flow width/height). Enables correct resize behavior. */
dimensions?: { width: number; height: number };
/** When true, node is selected (from React Flow NodeProps). Used for visible selected state. */
selected?: boolean;
/** Optional min/max for NodeResizer. Omit to use RESIZE_MIN_WIDTH / RESIZE_MIN_HEIGHT. */
resizeConstraints?: { minWidth?: number; minHeight?: number; maxWidth?: number; maxHeight?: number };
};
export function BaseNode({
@@ -22,8 +33,13 @@ export function BaseNode({
nodeId,
handles,
children,
selected,
resizeConstraints,
...props
}: BaseNodeProps) {
const flowUIContext = useContext(FlowUIContext);
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
const connectionPathRole = useConnectionPathRoleFromStore(nodeId);
const hasSize =
dimensions &&
dimensions.width > 0 &&
@@ -43,46 +59,49 @@ export function BaseNode({
? { ...style, display: "flex", flexDirection: "column" as const }
: undefined;
const minW = resizeConstraints?.minWidth ?? RESIZE_MIN_WIDTH;
const minH = resizeConstraints?.minHeight ?? RESIZE_MIN_HEIGHT;
return (
<div
className={cn(
"bg-card text-card-foreground relative rounded-md border",
"bg-card text-card-foreground relative rounded-md border transition-[border-color,box-shadow] duration-200",
"hover:ring-1",
// React Flow displays node elements inside of a `NodeWrapper` component,
// which compiles down to a div with the class `react-flow__node`.
// When a node is selected, the class `selected` is added to the
// `react-flow__node` element. This allows us to style the node when it
// is selected, using Tailwind's `&` selector.
"[.react-flow\\_\\_node.selected_&]:border-muted-foreground",
"[.react-flow\\_\\_node.selected_&]:shadow-lg",
selected && "border-primary/50 shadow-[0_0_0_2px_hsl(var(--primary)_/_0.15)] dark:border-primary/35 dark:shadow-[0_0_0_2px_hsl(var(--primary)_/_0.1)]",
connectionPathRole === "trigger" && "connection-path-trigger",
connectionPathRole === "on-path" && "connection-path-on-path",
className,
)}
data-selected={selected}
data-path-role={connectionPathRole ?? undefined}
style={appliedStyle}
tabIndex={0}
tabIndex={selected ? 0 : -1}
{...props}
>
<div
className="min-h-0 flex-1 flex flex-col overflow-hidden"
className={cn(
"min-h-0 flex-1 flex flex-col overflow-hidden",
!selected && "pointer-events-none",
)}
style={{ contain: "layout" }}
>
{children}
</div>
{resizable && nodeId && (
<NodeResizeControl
{resizable && nodeId && !isFullscreenInstance && (
<NodeResizer
nodeId={nodeId}
position="bottom-right"
minWidth={120}
minHeight={80}
className={cn(
"!border-0 !bg-transparent !rounded-none !p-0",
"absolute bottom-0 right-0 w-8 h-8 cursor-se-resize select-none",
"nodrag nopan touch-none",
)}
style={{ margin: 0, touchAction: "none", userSelect: "none" }}
>
</NodeResizeControl>
isVisible={selected}
minWidth={minW}
minHeight={minH}
maxWidth={resizeConstraints?.maxWidth}
maxHeight={resizeConstraints?.maxHeight}
color="transparent"
handleClassName="base-node-resize-handle nodrag nopan"
/>
)}
{!isFullscreenInstance && (
<div className={cn(!selected && "pointer-events-none")}>{handles}</div>
)}
{handles}
</div>
);
}
@@ -117,19 +136,37 @@ export function BaseNodeHeaderRow({
title,
right,
className,
onHeaderDoubleClick,
}: {
icon: ReactNode;
title: ReactNode;
/** Optional right-side content (e.g. type selector). */
right?: ReactNode;
className?: string;
/** When provided, double-click on the header triggers this (e.g. fullscreen). Header gets cursor-pointer and nodrag. */
onHeaderDoubleClick?: () => void;
}) {
return (
<header
role={onHeaderDoubleClick ? 'button' : undefined}
tabIndex={onHeaderDoubleClick ? 0 : undefined}
onDoubleClick={onHeaderDoubleClick}
onKeyDown={
onHeaderDoubleClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onHeaderDoubleClick();
}
}
: undefined
}
className={cn(
"shrink-0 mx-0 my-0 flex flex-row items-center justify-between gap-2 px-3 py-2",
onHeaderDoubleClick && "cursor-pointer",
className,
)}
title={onHeaderDoubleClick ? 'Double-click for full screen' : undefined}
>
{icon}
<h3
@@ -166,7 +203,7 @@ export function BaseNodeContent({
return (
<div
data-slot="base-node-content"
className={cn("min-h-0 flex-1 flex flex-col overflow-auto", className)}
className={cn("min-h-0 flex-1 flex flex-col", className)}
{...props}
/>
);
@@ -177,7 +214,7 @@ export function BaseNodeFooter({ className, ...props }: ComponentProps<"div">) {
<div
data-slot="base-node-footer"
className={cn(
"shrink-0 flex flex-col items-center gap-y-2 border-t p-1",
"shrink-0 flex flex-col items-center gap-y-2 border-t border-border/70 pt-1 px-1 pb-1",
className,
)}
{...props}

View File

@@ -0,0 +1,163 @@
import React, { useCallback, useContext, useEffect } from 'react'
import { useReactFlow } from '@xyflow/react'
import type { Node } from '@xyflow/react'
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
import { getNextNodeId, getDefaultDataForType } from '@/lib/graph/flowUtils'
import { getDefaultStyle, getRegisteredNodeTypeIds } from '@/lib/graph/nodeRegistry'
const DUPLICATE_OFFSET = { x: 30, y: 30 }
function isMod(ev: KeyboardEvent) {
return ev.ctrlKey || ev.metaKey
}
/** Must be rendered inside ReactFlowProvider and FlowContext. Handles Escape, ⌘C, ⌘V, ⌘D, ⌘0. */
export function FlowKeyboardShortcuts() {
const { fitView, screenToFlowPosition } = useReactFlow()
const graphCtx = useContext(GraphContext)
const uiCtx = useContext(FlowUIContext)
const setNodes = graphCtx?.setNodes
const setConnectionFrom = uiCtx?.setConnectionFrom
const flowActionsRef = uiCtx?.flowActionsRef
const pasteAtViewportCenter = useCallback(async () => {
if (!setNodes || !screenToFlowPosition) return
try {
const text = await navigator.clipboard?.readText()
if (!text) return
const raw = JSON.parse(text) as {
id?: string
type?: string
data?: Record<string, unknown>
position?: { x: number; y: number }
style?: unknown
}
const validIds = getRegisteredNodeTypeIds()
if (!raw || typeof raw.type !== 'string' || !validIds.includes(raw.type)) return
const nodeType = raw.type
const pane = document.querySelector('.react-flow__viewport')
const rect = pane?.getBoundingClientRect()
const center = rect
? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
const position = screenToFlowPosition(center)
setNodes((nds: Node[]) => {
const existingIds = nds.map((n) => n.id).filter((id): id is string => id != null)
const newId = getNextNodeId(nodeType, existingIds)
const data: Record<string, unknown> =
raw.data != null && typeof raw.data === 'object'
? { ...raw.data }
: (getDefaultDataForType(nodeType, newId) as Record<string, unknown>)
if (nodeType === 'config') data.title = `config-${newId}`
const style = getDefaultStyle(nodeType)
const newNode: Node = {
id: newId,
type: raw.type as Node['type'],
position: { x: position.x, y: position.y },
data,
style,
}
return nds.concat(newNode)
})
} catch {
// Invalid clipboard or not a copied node
}
}, [setNodes, screenToFlowPosition])
const doFitView = useCallback(() => {
fitView?.({ duration: 200 })
}, [fitView])
useEffect(() => {
if (flowActionsRef) {
flowActionsRef.current = { pasteAtViewportCenter, fitView: doFitView }
return () => {
flowActionsRef.current = null
}
}
}, [flowActionsRef, pasteAtViewportCenter, doFitView])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
if (ev.key === 'Escape') {
const openDialog = document.querySelector('[role="dialog"]')
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
setConnectionFrom?.(null)
setNodes?.((nds) => nds.map((n) => ({ ...n, selected: false })))
ev.preventDefault()
return
}
if (ev.key === 'c' && isMod(ev) && !ev.shiftKey) {
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
if (selectedNodes.length === 1) {
const node = selectedNodes[0] as Node & { selected?: boolean }
const copy = {
id: node.id,
type: node.type,
data: node.data,
position: node.position,
style: node.style,
}
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => { })
ev.preventDefault()
}
return
}
if (ev.key === 'v' && isMod(ev) && !ev.shiftKey) {
pasteAtViewportCenter()
ev.preventDefault()
return
}
if (ev.key === 'd' && isMod(ev) && !ev.shiftKey) {
const selectedNodes = nodes.filter((n) => (n as Node & { selected?: boolean }).selected)
if (selectedNodes.length > 0 && setNodes) {
setNodes((nds: Node[]) => {
const existingIds = nds.map((n) => n.id)
const toAdd: Node[] = []
for (const node of selectedNodes) {
const n = node as Node & { selected?: boolean }
const pos = n.position ?? { x: 0, y: 0 }
const newId = getNextNodeId(String(n.type), [...existingIds, ...toAdd.map((x) => x.id)])
existingIds.push(newId)
const newNode: Node = {
id: newId,
type: n.type,
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
data: typeof n.data === 'object' && n.data !== null ? { ...(n.data as object) } : n.data,
style: getDefaultStyle(String(n.type)),
}
if (
newNode.data &&
typeof newNode.data === 'object' &&
'title' in newNode.data &&
String(n.type) === 'config'
) {
; (newNode.data as Record<string, unknown>).title = `${newId}`
}
toAdd.push(newNode)
}
return nds.concat(toAdd)
})
ev.preventDefault()
}
return
}
if (ev.key === '0' && isMod(ev) && !ev.shiftKey) {
doFitView()
ev.preventDefault()
return
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [
graphCtx?.graphRef,
setNodes,
setConnectionFrom,
pasteAtViewportCenter,
doFitView,
])
return null
}

View File

@@ -1,28 +1,17 @@
import React, { useContext, useMemo } from 'react'
import FlowContext from '../../lib/flowContext'
import { GraphContext } from '@/lib/graph/flowContext'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
const NODE_HAS_INPUT: Record<string, boolean> = {
config: true,
function: true,
render: true,
variable: false,
}
const NODE_HAS_OUTPUT: Record<string, boolean> = {
config: true,
function: true,
render: true,
variable: true,
}
import { NodeHelpPopover } from '@/components/graph/NodeHelpPopover'
import { getNodeType, getNodeClassificationLabel } from '@/lib/graph/nodeRegistry'
type Props = {
nodeId: string
nodeType: 'config' | 'function' | 'render' | 'variable'
nodeType: string
children?: React.ReactNode
}
export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props) {
const ctx = useContext(FlowContext)
const ctx = useContext(GraphContext)
const edges = ctx?.edges ?? []
const { inputs, outputs } = useMemo(() => {
@@ -35,8 +24,10 @@ export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props)
return { inputs, outputs }
}, [edges, nodeId])
const showInput = NODE_HAS_INPUT[nodeType] ?? false
const showOutput = NODE_HAS_OUTPUT[nodeType] ?? false
const descriptor = getNodeType(nodeType)
const showInput = descriptor?.hasInput ?? false
const showOutput = descriptor?.hasOutput ?? false
const classificationLabel = getNodeClassificationLabel(nodeType)
return (
<div className="flex items-center gap-2 w-full text-xs text-muted-foreground">
@@ -58,6 +49,15 @@ export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props)
<span className="min-w-0 truncate">{children}</span>
</>
)}
{classificationLabel != null && (
<>
{(showInput || showOutput || children != null) && <span className="shrink-0">|</span>}
<span className="shrink-0" title="Node classification">{classificationLabel}</span>
</>
)}
<span className="shrink-0 ml-auto">
<NodeHelpPopover nodeType={nodeType} />
</span>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import React, { useContext } from 'react'
import { Handle, Position } from '@xyflow/react'
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
import FlowContext from '@/lib/flowContext'
import { FlowUIContext } from '@/lib/graph/flowContext'
import { cn } from '@/lib/utils'
type NodeHandleProps = {
@@ -11,7 +11,7 @@ type NodeHandleProps = {
}
export function InputHandle({ id, nodeId }: NodeHandleProps) {
const ctx = useContext(FlowContext)
const ctx = useContext(FlowUIContext)
const connectionFrom = ctx?.connectionFrom ?? null
const isValidConnection = ctx?.isValidConnection
const isConnecting = Boolean(nodeId && connectionFrom && connectionFrom.nodeId !== nodeId)
@@ -19,7 +19,7 @@ export function InputHandle({ id, nodeId }: NodeHandleProps) {
isConnecting &&
isValidConnection?.({
source: connectionFrom!.nodeId,
sourceHandle: connectionFrom!.sourceHandle ?? undefined,
sourceHandle: connectionFrom!.sourceHandle ?? null,
target: nodeId!,
targetHandle: id,
})

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
import FlowContext from '../../lib/flowContext'
import { replaceNodeIdInGraph } from '../../lib/flowUtils'
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
import type { AppNode } from '@/lib/graph/nodeTypes'
import { replaceNodeIdInGraph } from '@/lib/graph/flowUtils'
type Props = {
nodeId: string
@@ -8,13 +9,12 @@ type Props = {
}
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const edges = ctx?.edges ?? []
const setEdges = ctx?.setEdges
const renamingNodeId = ctx?.renamingNodeId ?? null
const setRenamingNodeId = ctx?.setRenamingNodeId
const graphCtx = useContext(GraphContext)
const uiCtx = useContext(FlowUIContext)
const setNodes = graphCtx?.setNodes
const setEdges = graphCtx?.setEdges
const renamingNodeId = uiCtx?.renamingNodeId ?? null
const setRenamingNodeId = uiCtx?.setRenamingNodeId
const [inputValue, setInputValue] = useState(nodeId)
const inputRef = useRef<HTMLInputElement>(null)
@@ -30,7 +30,9 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
}, [isRenaming, nodeId])
const applyRename = useCallback(() => {
if (!setNodes || !setEdges || !setRenamingNodeId) return
if (!setNodes || !setEdges || !setRenamingNodeId || !graphCtx?.graphRef) return
const nodes = graphCtx.graphRef.current.nodes
const edges = graphCtx.graphRef.current.edges
const newId = inputValue.trim()
if (!newId || newId === nodeId) {
setRenamingNodeId(null)
@@ -41,10 +43,10 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
return
}
const { nodes: nextNodes, edges: nextEdges } = replaceNodeIdInGraph(nodes, edges, nodeId, newId)
setNodes(nextNodes)
setNodes(nextNodes as AppNode[])
setEdges(nextEdges)
setRenamingNodeId(null)
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
}, [nodeId, inputValue, graphCtx?.graphRef, setNodes, setEdges, setRenamingNodeId])
const cancelRename = useCallback(() => {
setRenamingNodeId?.(null)

View File

@@ -0,0 +1,35 @@
import React from 'react'
import { HelpCircle } from 'lucide-react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { getNodeHelp } from '@/lib/graph/nodeRegistry'
import { cn } from '@/lib/utils'
type Props = {
nodeType: string
className?: string
}
export function NodeHelpPopover({ nodeType, className }: Props) {
const { title, content } = getNodeHelp(nodeType)
return (
<Popover>
<PopoverTrigger
asChild
className={cn(
'shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
className
)}
aria-label={`Help: ${title}`}
>
<button type="button">
<HelpCircle className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent side="top" align="end" className="w-80 max-h-[70vh] overflow-y-auto">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<div className="mt-2">{content}</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -1,25 +1,27 @@
import React, { useCallback, useContext } from 'react'
import FlowContext from '../../lib/flowContext'
import { DEFAULT_NODE_STYLE, getNextNodeId, getResetDataForType } from '../../lib/flowUtils'
/**
* Per-node menubar (Node, Output, Data, Inputs, Insert). Content can be passed as props
* or resolved from the node type descriptor: when nodeMenuExtraContent is not provided,
* it is taken from getNodeType(nodeType)?.getNodeMenuExtraContent?.(nodeId, data).
*/
import React, { useCallback, useContext, useMemo } from 'react'
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
import { getNodeType } from '@/lib/graph/nodeRegistry'
import {
Menubar,
MenubarContent,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarShortcut,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
MenubarTrigger,
} from '../ui/menubar'
const DUPLICATE_OFFSET = { x: 30, y: 30 }
type NodeType = 'config' | 'render' | 'variable' | 'function'
} from '@/components/ui/menubar'
import { Kbd } from '@/components/ui/kbd'
type Props = {
nodeId: string
nodeType: NodeType
nodeType: string
/** Content for Insert → Inputs (function nodes); when inputsMenuContent is set, Inputs is a separate menu and this is not used in Insert */
editInputsContent?: React.ReactNode
/** When set, Inputs is rendered as its own top-level menu (config nodes); Insert then only shows markup-specific options */
@@ -34,50 +36,27 @@ type Props = {
insertTagsLabel?: string
/** Extra content in Node menu (e.g. Export submenu for render nodes), before the separator */
nodeMenuExtraContent?: React.ReactNode
/** When set, renders a top-level "Output" menu with this content (e.g. render node: preview/raw, viewport, export) */
outputMenuContent?: React.ReactNode
/** When set, renders a top-level "Data" menu with this content (e.g. data nodes) */
dataMenuContent?: React.ReactNode
}
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent }: Props) {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges
const edges = ctx?.edges ?? []
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) {
const graphCtx = useContext(GraphContext)
const uiCtx = useContext(FlowUIContext)
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
const setNodes = graphCtx?.setNodes
const setEdges = graphCtx?.setEdges
const edges = graphCtx?.edges ?? []
const node = nodes.find((n: any) => n.id === nodeId)
const nodeMenuExtraContent = useMemo(
() => nodeMenuExtraContentProp ?? getNodeType(nodeType)?.getNodeMenuExtraContent?.(nodeId, node?.data ?? {}),
[nodeMenuExtraContentProp, nodeType, nodeId, node?.data]
)
const hasEdit = nodeType === 'config' || nodeType === 'function'
const hasConnectedNodes = edges.some((e: any) => e.target === nodeId)
const onDuplicate = useCallback(() => {
if (!setNodes || !node) return
const pos = node.position ?? { x: 0, y: 0 }
setNodes((nds: any[]) => {
const newId = getNextNodeId(nodeType, nds.map((n: any) => n.id))
const newNode = {
id: newId,
type: node.type,
position: { x: pos.x + DUPLICATE_OFFSET.x, y: pos.y + DUPLICATE_OFFSET.y },
data: typeof node.data === 'object' && node.data !== null ? { ...node.data } : node.data,
style: DEFAULT_NODE_STYLE[nodeType] ?? DEFAULT_NODE_STYLE.config,
}
if (newNode.data?.title && nodeType === 'config') newNode.data.title = `${newId}`
return nds.concat(newNode)
})
}, [node, nodeType, setNodes])
const onCopy = useCallback(() => {
if (!node) return
const copy = { id: node.id, type: node.type, data: node.data, position: node.position, style: node.style }
navigator.clipboard?.writeText(JSON.stringify(copy)).catch(() => { })
}, [node])
const onReset = useCallback(() => {
if (!setNodes) return
const resetData = getResetDataForType(nodeType, nodeId)
setNodes((nds: any[]) =>
nds.map((n) => (n.id === nodeId ? { ...n, data: resetData } : n))
)
}, [nodeId, nodeType, setNodes])
const onDelete = useCallback(() => {
if (!setNodes || !setEdges) return
setNodes((nds: any[]) => nds.filter((n: any) => n.id !== nodeId))
@@ -85,8 +64,8 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
}, [nodeId, setNodes, setEdges])
const onRename = useCallback(() => {
ctx?.setRenamingNodeId?.(nodeId)
}, [nodeId, ctx])
uiCtx?.setRenamingNodeId?.(nodeId)
}, [nodeId, uiCtx])
return (
<Menubar className="h-auto min-h-0 flex items-center bg-none p-1 border-t-0 border-l-0 border-r-0 border-b border-b-secondary shadow-none rounded-none text-muted-foreground">
@@ -94,26 +73,38 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
<MenubarTrigger className="px-1.5 py-0 text-xs">
Node
</MenubarTrigger>
<MenubarContent className="min-w-[10rem]">
<MenubarItem className="text-xs" onClick={onDuplicate}>
Duplicate
</MenubarItem>
<MenubarItem className="text-xs" onClick={onCopy}>
Copy
</MenubarItem>
<MenubarContent className="min-w-[12rem]">
<MenubarItem className="text-xs" onClick={onRename}>
Rename
</MenubarItem>
<MenubarItem className="text-xs" onClick={onReset}>
Reset
</MenubarItem>
{nodeMenuExtraContent}
<MenubarSeparator />
<MenubarItem className="text-xs text-destructive focus:text-destructive" onClick={onDelete}>
Delete
<MenubarShortcut className="ml-auto pl-4"><Kbd></Kbd></MenubarShortcut>
</MenubarItem>
</MenubarContent>
</MenubarMenu>
{outputMenuContent != null && (
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs">
Output
</MenubarTrigger>
<MenubarContent className="min-w-[12rem]">
{outputMenuContent}
</MenubarContent>
</MenubarMenu>
)}
{dataMenuContent != null && (
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs">
Data
</MenubarTrigger>
<MenubarContent className="min-w-[12rem]">
{dataMenuContent}
</MenubarContent>
</MenubarMenu>
)}
{inputsMenuContent != null && (
<MenubarMenu>
<MenubarTrigger className="px-1.5 py-0 text-xs" disabled={!hasConnectedNodes}>

View File

@@ -1,6 +1,7 @@
import { useId, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { observeResize } from '@/lib/sharedResizeObserver'
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
@@ -58,27 +59,15 @@ function BorderLoadingIndicator({
useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
// Observe the first child (BaseNode) so we use its actual rendered size.
const target =
container.firstElementChild instanceof HTMLElement
? container.firstElementChild
: container
const syncMeasure = () => {
const cw = (target as HTMLElement).offsetWidth
const ch = (target as HTMLElement).offsetHeight
if (cw > 0 && ch > 0) {
queueMicrotask(() => setMeasured({ w: cw, h: ch }))
}
}
syncMeasure()
const ro = new ResizeObserver((entries) => {
const entry = entries[0]
if (!entry) return
const { width: cw, height: ch } = entry.contentRect
setMeasured({ w: Math.round(cw), h: Math.round(ch) })
// Use ResizeObserver only to avoid forced synchronous layout (offsetWidth/offsetHeight).
const unObserve = observeResize(target, ({ width: w, height: h }) => {
if (w > 0 && h > 0) setMeasured({ w, h })
})
ro.observe(target)
return () => ro.disconnect()
return unObserve
}, [])
useLayoutEffect(() => {

View File

@@ -0,0 +1,152 @@
import React, { useCallback, useContext, useMemo } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
} from '@/lib/graph/abstractNode'
import {
BaseNode,
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from '@/components/graph/BaseNode'
import { NodeMenubar } from '@/components/graph/NodeMenubar'
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import { FlowUIContext } from '@/lib/graph/flowContext'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { getNodeType } from '@/lib/graph/nodeRegistry'
import { Bot } from 'lucide-react'
import { Checkbox } from '@/components/ui/checkbox'
export type AgentNodeData = {
/** Additional context for the agent (configuration). */
context?: string
/** When true, agent returns reasoning in a "## Reasoning" section and output in "## Output". */
reasoning?: boolean
/** Cached output from last run; set by connected Rendering node. Not displayed on this node. */
outputMarkdown?: string
/** Signature of inputs from last successful run; set by Rendering node when it runs the agent. Used as cache. */
lastRunSourceSignature?: string
}
type Props = AbstractNodeProps<AgentNodeData>
function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
const contextText = data?.context ?? ''
const reasoningEnabled = data?.reasoning ?? false
const onReasoningChange = useCallback(
(checked: boolean) => updateData({ reasoning: checked }),
[updateData]
)
const connectedSources = useMemo(() => {
return sourceIds.map((sid) => {
const node = nodes.find((n: { id: string }) => n.id === sid)
return { id: sid, type: (node as { type?: string } | undefined)?.type ?? 'unknown' }
})
}, [sourceIds, nodes])
const onContextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => updateData({ context: e.target.value }),
[updateData]
)
// Agent is configuration-only; it does not block the flow. Only the Rendering node reports
// paused/updating, so the connection path runs trigger → … → agent (on-path) → rendering (paused).
useSyncConnectionStatus(id, {
updating: false,
error: false,
paused: false,
})
return (
<BaseNode
className="min-w-[360px] min-h-[320px]"
dimensions={dimensions}
resizable
nodeId={id}
selected={selected}
handles={
<>
<InputHandle id="in" nodeId={id} />
<OutputHandle id="out" />
</>
}
>
<BaseNodeHeaderRow
icon={<Bot className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
/>
<BaseNodeContent>
<div className="shrink-0 w-full">
<NodeMenubar nodeId={id} nodeType="agent" />
</div>
<div className="flex flex-col gap-3 p-2 min-h-0 flex-1">
<div className="flex flex-col gap-1.5">
<label htmlFor={`agent-reasoning-${id}`} className="flex items-center gap-2 text-xs font-medium text-foreground cursor-pointer">
<Checkbox
id={`agent-reasoning-${id}`}
checked={reasoningEnabled}
onCheckedChange={(c) => onReasoningChange(c === true)}
className="nodrag nopan"
aria-label="Enable reasoning"
/>
Enable reasoning
</label>
<p className="text-xs text-muted-foreground">
When enabled, the model will output a Reasoning section and an Output section; the renderer shows them separately.
</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-foreground">Context</label>
<textarea
className="nodrag nopan w-full min-h-[72px] rounded-md border border-input bg-background px-2 py-1.5 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Additional context for the agent…"
value={contextText}
onChange={onContextChange}
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">Context from connected nodes</span>
{connectedSources.length === 0 ? (
<p className="text-xs text-muted-foreground">Connect Config, Variable, or Data nodes as input.</p>
) : (
<ul className="text-xs text-muted-foreground list-disc list-inside space-y-0.5">
{connectedSources.map(({ id: sid, type }) => (
<li key={sid}>
<code className="rounded bg-muted px-1">{sid}</code> ({type})
</li>
))}
</ul>
)}
</div>
<p className="text-xs text-muted-foreground">
Connect to a Renderer node and click Run there to generate output.
</p>
</div>
</BaseNodeContent>
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="agent">
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>
)
}
export const AgentNode = createAbstractNodeComponent<AgentNodeData>('AgentNode', AgentNodeComponent)
export default AgentNode

View File

@@ -0,0 +1,25 @@
import React from 'react'
import { Bot } from 'lucide-react'
import { createNodeTypeBuilder } from '@/lib/graph/nodeTypeBuilder'
import { NODE_HELP } from '@/lib/graph/nodeHelp'
import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry'
import AgentNode from './AgentNode'
import { agentRenderingLogic } from './renderingLogic'
const ICON_CLASS = 'mr-2 h-4 w-4'
export function getAgentNodeDescriptor(): NodeTypeDescriptor {
return createNodeTypeBuilder('agent', AgentNode, { width: 360, height: 320 }, { context: '' })
.idPrefix('agt_')
.withInputOutput(true, true)
.classification('archon')
.allowedSourceTypes(['config', 'variable', 'data', 'render'])
.allowedTargetTypes(['render'])
.help(NODE_HELP.agent)
.menu('Agent', <Bot className={ICON_CLASS} />)
.connectionLabelByStatus({ default: 'prompt/context', updating: 'running', paused: 'pending', error: 'corrupted' })
.withFullscreen()
.sourceRenderingLogic(agentRenderingLogic)
.outputMenuContent(() => null)
.build()
}

View File

@@ -0,0 +1,2 @@
export { default as AgentNode, type AgentNodeData } from './AgentNode'
export { getAgentNodeDescriptor } from './descriptor'

View File

@@ -0,0 +1,210 @@
/**
* Agent node rendering logic: implements the "Resolve" step for agent sources.
* Calls the agent API and updates the agent node's output. Update mode is manual.
* See lib/graph/rendering.ts (IRenderingSource).
*/
import { getConfigContent } from '@/lib/graph/rendering'
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/rendering'
import type { AiConnection } from '@/app/kosmos/KosmosContext'
function serializeNodeForContext(
nodes: { id: string; type?: string; data?: unknown }[],
nodeId: string
): string {
const node = nodes.find((n) => n.id === nodeId)
if (!node) return `${nodeId}: (not found)`
const type = node.type ?? 'unknown'
const data = node.data as Record<string, unknown> | undefined
if (type === 'config') {
const content = getConfigContent(data)
return `[config ${nodeId}]\n${content || '(empty)'}`
}
if (type === 'variable') {
const v = data?.value
return `[variable ${nodeId}]: ${v === undefined || v === null ? '' : String(v)}`
}
if (type === 'data') {
const rows = (data?.rows as Record<string, string>[] | undefined) ?? []
const columns = (data?.columns as string[] | undefined) ?? []
const preview = rows
.slice(0, 20)
.map((r) => columns.map((c) => r[c] ?? '').join(', '))
.join('\n')
return `[data ${nodeId}] ${columns.length} columns, ${rows.length} rows\n${preview}${rows.length > 20 ? '\n...' : ''}`
}
return `[${type} ${nodeId}]: ${JSON.stringify(data ?? {}).slice(0, 200)}`
}
function buildSourceSignature(
nodes: { id: string; type?: string; data?: unknown }[],
sourceIds: string[]
): string {
const configContents = sourceIds
.filter((sid) => {
const n = nodes.find((nn) => nn.id === sid)
return (n as { type?: string } | undefined)?.type === 'config'
})
.map((sid) =>
getConfigContent(
(nodes.find((n) => n.id === sid)?.data ?? undefined) as Record<string, unknown> | undefined
)
)
return JSON.stringify({ sourceIds: sourceIds.slice().sort(), configContents })
}
/** Parse agent response into reasoning and output when format is "## Reasoning" / "## Output". */
function parseReasoningAndOutput(fullMarkdown: string): { reasoning?: string; output: string } {
const normalized = fullMarkdown.trim()
const outputMatch = normalized.match(/\s*##\s*Output\s*/i)
if (!outputMatch) return { output: fullMarkdown }
const idx = outputMatch.index! + outputMatch[0].length
const output = normalized.slice(idx).trim()
const beforeOutput = normalized.slice(0, outputMatch.index).trim()
const reasoningMatch = beforeOutput.match(/\s*##\s*Reasoning\s*/i)
const reasoning = reasoningMatch
? beforeOutput.slice(reasoningMatch.index! + reasoningMatch[0].length).trim()
: beforeOutput
return { reasoning: reasoning || undefined, output: output || fullMarkdown }
}
export const agentRenderingLogic = {
defaultUpdateMode: 'manual' as const,
getResolvedContent: async (context: SourceRenderingLogicContext): Promise<ResolvedContentResult> => {
const { nodes, sourceNodeId, setNodes, aiConnection, onStreamingStart, onStreamingChunk } = context
const sourceNode = nodes.find((n) => n.id === sourceNodeId)
const agentData = sourceNode?.data as { context?: string; outputMarkdown?: string; reasoning?: boolean } | undefined
const contextText = agentData?.context ?? ''
const reasoningEnabled = Boolean(agentData?.reasoning)
const sourceIds = (context.edges
.filter((e) => e.target === sourceNodeId)
.map((e) => e.source)) as string[]
type NodeWithData = { id: string; type?: string; data?: unknown }
const setAgentData = (patch: Record<string, unknown>) => {
if (!setNodes) return
setNodes((prev: unknown[]) =>
(prev as NodeWithData[]).map((n) =>
n.id === sourceNodeId ? { ...n, data: { ...(n.data as object), ...patch } } : n
)
)
}
if (setNodes && aiConnection) {
const connection = aiConnection as AiConnection
const configContents = sourceIds
.filter((sid) => {
const n = nodes.find((nn) => nn.id === sid)
return (n as { type?: string } | undefined)?.type === 'config'
})
.map((sid) =>
getConfigContent(
(nodes.find((n) => n.id === sid)?.data ?? undefined) as Record<string, unknown> | undefined
)
)
const prompt =
configContents.length > 0
? configContents.join('\n\n---\n\n')
: 'No prompt provided. Please describe what you want in structured markdown.'
const contextNodes = sourceIds.map((sid) => ({
id: sid,
content: serializeNodeForContext(nodes, sid),
}))
const sourceSignature = buildSourceSignature(nodes, sourceIds)
const useStream = Boolean(onStreamingChunk)
try {
if (useStream) {
onStreamingStart?.()
const res = await fetch('/api/agent/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
context: contextText.trim() || undefined,
contextNodes,
connection,
reasoning: reasoningEnabled,
}),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
let msg = `Request failed: ${res.status}`
try {
const json = text ? JSON.parse(text) : {}
if (typeof (json as { error?: string }).error === 'string') msg = (json as { error: string }).error
} catch {
if (text) msg = text.slice(0, 200)
}
setAgentData({ outputMarkdown: undefined })
throw new Error(msg)
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let markdown = ''
try {
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
markdown += chunk
onStreamingChunk?.(chunk)
}
}
} finally {
reader?.cancel()
}
setAgentData({
outputMarkdown: markdown,
lastRunSourceSignature: sourceSignature,
})
if (reasoningEnabled) {
const { reasoning: reasoningText, output } = parseReasoningAndOutput(markdown)
return { resolved: output, outputTypeId: 'markdown' as const, reasoning: reasoningText }
}
return { resolved: markdown, outputTypeId: 'markdown' }
}
const res = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
context: contextText.trim() || undefined,
contextNodes,
connection,
reasoning: reasoningEnabled,
}),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
setAgentData({ outputMarkdown: undefined })
const msg = (json as { error?: string }).error ?? `Request failed: ${res.status}`
throw new Error(msg)
}
const markdown = (json as { markdown?: string }).markdown ?? ''
setAgentData({
outputMarkdown: markdown,
lastRunSourceSignature: sourceSignature,
})
if (reasoningEnabled) {
const { reasoning: reasoningText, output } = parseReasoningAndOutput(markdown)
return { resolved: output, outputTypeId: 'markdown' as const, reasoning: reasoningText }
}
return { resolved: markdown, outputTypeId: 'markdown' }
} catch (err: unknown) {
setAgentData({ outputMarkdown: undefined })
throw err instanceof Error ? err : new Error('Agent request failed')
}
}
const outputMarkdown =
(sourceNode?.data as { outputMarkdown?: string } | undefined)?.outputMarkdown ?? ''
if (reasoningEnabled && outputMarkdown) {
const { reasoning: reasoningText, output } = parseReasoningAndOutput(outputMarkdown)
return { resolved: output, outputTypeId: 'markdown' as const, reasoning: reasoningText }
}
return { resolved: outputMarkdown, outputTypeId: 'markdown' }
},
}

View File

@@ -1,29 +1,31 @@
import React, { memo, useCallback, useContext, useMemo, useRef } from 'react'
import { autocompletion } from '@codemirror/autocomplete'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import FlowContext from '../../lib/flowContext'
import { nodePropsAreEqual } from '../../lib/flowUtils'
import { useResizeHeight } from '../../hooks/useResizeHeight'
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
import { plantumlLanguage } from '../../lib/plantumlLanguage'
import { useTheme } from '../../lib/themeContext'
import React, { useCallback, useContext, useId, useMemo } from 'react'
import {
CONFIG_TYPES,
AbstractNodeProps,
createAbstractNodeComponent,
getConnectedNodesByType,
useAbstractNode,
} from '@/lib/graph/abstractNode'
import { CodeEditor } from '@/components/editor/CodeEditor'
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
import {
getConfigTypes,
getConfigContent,
getConfigType,
getConfigTypeId,
isGroup,
type ConfigTypeId,
} from '../../lib/configTypes'
type InsertBlockGroup,
type InsertBlock,
} from '@/lib/graph/configTypes'
import {
BaseNode,
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from './BaseNode'
import { Code2, ScrollText, Variable } from 'lucide-react'
} from '@/components/graph/BaseNode'
import { Code2, Database, ScrollText, Sparkles, Variable } from 'lucide-react'
import {
MenubarItem,
MenubarSeparator,
@@ -31,111 +33,72 @@ import {
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
} from '../ui/menubar'
import { Kbd } from '../ui/kbd'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
import { InputHandle, OutputHandle } from './NodeHandles'
import { NodeFooterEdgeIndicators } from './NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from './NodeHeaderTitle'
import { NodeMenubar } from './NodeMenubar'
} from '@/components/ui/menubar'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Kbd } from '@/components/ui/kbd'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
import { NodeMenubar } from '@/components/graph/NodeMenubar'
import { FlowUIContext } from '@/lib/graph/flowContext'
import { getNodeType } from '@/lib/graph/nodeRegistry'
type Props = {
id: string
data: any
width?: number
height?: number
}
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string }
export const ConfigNode = memo(function ConfigNode({ id, data, width, height }: Props) {
const configTypeId = getConfigTypeId(data)
type Props = AbstractNodeProps<ConfigNodeData>
function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('config')?.supportsFullscreen
const configTypeId = getConfigTypeId(data ?? {})
const configType = getConfigType(configTypeId)
const content = getConfigContent(data)
const { theme } = useTheme()
const ctx = useContext(FlowContext)
const setNodes = ctx?.setNodes
const content = getConfigContent(data ?? {})
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
const editorId = useId()
const onChange = useCallback(
(val: string) => updateData({ content: val, configType: configTypeId }),
[updateData, configTypeId]
)
const insertAt = useSimpleEditorInsert(editorId, content, onChange)
const editorRef = useRef<unknown>(null)
const edges = ctx?.edges ?? []
const nodes = ctx?.nodes ?? []
const incomingEdges = useMemo(() => edges.filter((e: any) => e.target === id), [edges, id])
const incomingIds = useMemo(() => incomingEdges.map((e: any) => e.source).sort(), [incomingEdges])
const connectedConfigNodes = useMemo(
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'config'),
[nodes, incomingIds]
() => getConnectedNodesByType(nodes, sourceIds, 'config'),
[nodes, sourceIds]
)
const connectedVariableNodes = useMemo(
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'variable'),
[nodes, incomingIds]
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
[nodes, sourceIds]
)
const connectedFunctionNodes = useMemo(
() => (nodes as any[]).filter((n: any) => incomingIds.includes(n.id) && n.type === 'function'),
[nodes, incomingIds]
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
[nodes, sourceIds]
)
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
const onChange = useCallback(
(val: string) => {
if (setNodes) {
setNodes((nds: any[]) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...n.data, content: val, configType: configTypeId } } : n
)
)
}
},
[id, setNodes, configTypeId]
const connectedDataNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'data'),
[nodes, sourceIds]
)
const connectedRenderNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'render'),
[nodes, sourceIds]
)
const hasDependencies =
connectedConfigNodes.length > 0 ||
connectedVariableNodes.length > 0 ||
connectedFunctionNodes.length > 0 ||
connectedDataNodes.length > 0 ||
connectedRenderNodes.length > 0
const setConfigType = useCallback(
(newTypeId: ConfigTypeId) => {
if (!setNodes || newTypeId === configTypeId) return
setNodes((nds: any[]) =>
nds.map((n) =>
n.id === id
? {
...n,
data: {
...n.data,
configType: newTypeId,
content: getConfigContent(n.data) ?? '',
},
}
: n
)
)
if (newTypeId === configTypeId) return
updateData({
configType: newTypeId,
content: getConfigContent({ ...data, configType: newTypeId }) ?? '',
})
},
[id, setNodes, configTypeId]
)
const insertAt = useCallback(
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
const ref = editorRef.current as { view: { state: { doc: { length: number; toString(): string }; selection: { main: { from: number } } }; dispatch: (arg: { changes: { from: number; to: number; insert: string } }) => void } } | null
if (ref?.view) {
const view = ref.view
const doc = view.state.doc
const len = doc.length
let from: number
if (mode === 'prepend') {
from = 0
} else if (mode === 'append') {
from = len
} else {
const main = view.state.selection.main
from = main.from
}
view.dispatch({ changes: { from, to: from, insert: insertText } })
const newVal = view.state.doc.toString()
onChange(newVal)
return
}
if (mode === 'prepend') {
onChange(insertText + content)
} else {
onChange(content + insertText)
}
},
[onChange, content]
[configTypeId, data, updateData]
)
const insertExtendsFromNode = useCallback(
@@ -173,60 +136,67 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
[insertAt]
)
const insertDataReference = useCallback(
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
insertAt(`{% for row in ${sourceNode.id} %}\n \n{% endfor %}`, mode)
},
[insertAt]
)
const insertRenderReference = useCallback(
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
const isMarkdownImage =
configTypeId === 'markdown' && (sourceNode.data?.outputMode ?? 'image') === 'image'
const snippet = isMarkdownImage
? `![Rendered]({{ ${sourceNode.id} }})`
: `{{ ${sourceNode.id} }}`
insertAt(snippet, mode)
},
[insertAt, configTypeId]
)
const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes])
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
const configTitles = useMemo(
() => connectedConfigNodes.map((n: any) => n.data?.title ?? n.id),
[connectedConfigNodes],
)
const extensions = useMemo(() => {
const lang =
configTypeId === 'wireframe'
? javascript()
: configType.language === 'plantuml'
? plantumlLanguage.extension
: markdown()
return [
lang,
autocompletion({
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds)],
activateOnTyping: true,
}),
]
}, [configTypeId, configType.language, variableIds, functionIds, configTitles])
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
const highlightLang: HighlightLanguage =
configTypeId === 'wireframe' ? 'javascript' : configType.language === 'plantuml' ? 'plantuml' : 'markdown'
const [editorHeight, editorContainerRef] = useResizeHeight(180)
const insertBlocksContent = useMemo(() => {
const templatingGroup = configType.insertBlocks.find(
(b): b is import('../../lib/configTypes').InsertBlockGroup => isGroup(b) && b.label === 'Templating'
(b): b is InsertBlockGroup => isGroup(b) && b.label === 'Templating'
)
const typeBlocks = configType.insertBlocks.filter((b) => !(isGroup(b) && b.label === 'Templating'))
const insertShortcut = <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
const typeItems = typeBlocks.flatMap((block) =>
isGroup(block)
? block.items.map(({ label, snippet }) => (
<MenubarItem
key={label}
className="text-xs flex items-center group"
onClick={() => insertAt(snippet, 'cursor')}
>
{label}
{insertShortcut}
</MenubarItem>
))
? block.items.map(({ label, snippet }: InsertBlock) => (
<MenubarItem
key={label}
className="text-xs flex items-center group"
onClick={() => insertAt(snippet, 'cursor')}
>
{label}
{insertShortcut}
</MenubarItem>
))
: [
<MenubarItem
key={block.label}
className="text-xs flex items-center group"
onClick={() => insertAt(block.snippet, 'cursor')}
>
{block.label}
{insertShortcut}
</MenubarItem>,
]
<MenubarItem
key={block.label}
className="text-xs flex items-center group"
onClick={() => insertAt(block.snippet, 'cursor')}
>
{block.label}
{insertShortcut}
</MenubarItem>,
]
)
const templatingItems =
templatingGroup?.items.map(({ label, snippet }) => (
templatingGroup?.items.map(({ label, snippet }: InsertBlock) => (
<MenubarItem
key={label}
className="text-xs flex items-center group"
@@ -255,7 +225,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
: undefined
return (
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNode className="min-w-80 min-h-[280px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<><InputHandle id="ain" nodeId={id} /><OutputHandle id="out" /></>}>
<BaseNodeHeaderRow
icon={<ScrollText className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={`${id}`} />}
@@ -265,7 +235,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONFIG_TYPES.map((t) => (
{getConfigTypes().map((t) => (
<SelectItem key={t.id} value={t.id} className="text-xs">
{t.label}
</SelectItem>
@@ -273,6 +243,7 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</SelectContent>
</Select>
}
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
/>
<BaseNodeContent>
@@ -336,6 +307,51 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
))}
{connectedDataNodes.map((n: any) => (
<MenubarItem
key={n.id}
className="text-xs flex items-center gap-2 group"
onClick={() => insertDataReference(n, 'cursor')}
>
<Database className="size-3.5 shrink-0" />
{n.id}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem>
))}
{connectedRenderNodes.map((n: any) => {
const renderInputDisabled =
configTypeId === 'plantuml' || configTypeId === 'wireframe'
const item = (
<MenubarItem
key={n.id}
className="text-xs flex items-center gap-2 group"
disabled={renderInputDisabled}
onClick={() =>
!renderInputDisabled && insertRenderReference(n, 'cursor')
}
>
<Sparkles className="size-3.5 shrink-0" />
{n.id}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity">
<Kbd>Insert</Kbd>
</MenubarShortcut>
</MenubarItem>
)
return renderInputDisabled ? (
<TooltipProvider key={n.id} delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{item}</TooltipTrigger>
<TooltipContent side="right">
Rendering output is not available for Diagram and
Wireframe configs. Use a Markdown config to embed
render output.
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
item
)
})}
</>
) : (
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span>
@@ -347,17 +363,17 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
/>
</div>
<div ref={editorContainerRef} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<CodeMirror
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
ref={editorRef}
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input" style={{ minHeight: editorHeight }}>
<CodeEditor
textareaId={editorId}
value={content}
height={`${editorHeight}px`}
theme={theme}
extensions={extensions}
onChange={onChange}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-sm [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
onValueChange={onChange}
language={highlightLang}
minHeight={editorHeight}
style={{ fontSize: 14 }}
textareaClassName="text-sm outline-none border-0 resize-none nodrag nopan"
preClassName="text-sm nodrag nopan"
className="min-h-0 w-full"
/>
</div>
</BaseNodeContent>
@@ -369,8 +385,11 @@ export const ConfigNode = memo(function ConfigNode({ id, data, width, height }:
</BaseNodeFooter>
</BaseNode>
)
}, nodePropsAreEqual)
}
ConfigNode.displayName = 'ConfigNode'
export const ConfigNode = createAbstractNodeComponent<ConfigNodeData>(
'ConfigNode',
ConfigNodeComponent
)
export default ConfigNode

View File

@@ -0,0 +1,93 @@
import React from 'react'
import { ScrollText } from 'lucide-react'
import { createNodeTypeBuilder } from '@/lib/graph/nodeTypeBuilder'
import { NODE_HELP } from '@/lib/graph/nodeHelp'
import { getConfigType } from '@/lib/graph/rendering'
import {
MenubarItem,
MenubarSeparator,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
} from '@/components/ui/menubar'
import { getResolvedContentForConfig } from './renderingLogic'
import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry'
import ConfigNode from './ConfigNode'
import { createImageExportHandlers, type OutputMenuContext } from '@/components/nodes/render/outputMenuHandlers'
const ICON_CLASS = 'mr-2 h-4 w-4'
function configOutputMenuContent(outputTypeId: string, ctx: unknown): React.ReactNode {
const c = ctx as OutputMenuContext
const configType = getConfigType(outputTypeId as 'plantuml' | 'markdown' | 'wireframe')
const descriptor = configType?.outputMenuDescriptor
if (!descriptor?.items?.length) return null
const disabled = !c.state.isSvgOutput
const handlers = createImageExportHandlers(c)
return (
<>
<MenubarSeparator />
<MenubarSub>
<MenubarSubTrigger className="text-xs" disabled={disabled}>
{descriptor.submenuLabel ?? 'Export'}
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]" aria-label={descriptor.submenuLabel ?? 'Export'}>
{descriptor.items.map((item) => {
const handler =
item.action === 'downloadSvg'
? handlers.downloadSvg
: item.action === 'downloadPng'
? handlers.downloadPng
: item.action === 'copySvg'
? handlers.copySvg
: handlers.copyPng
return (
<MenubarItem
key={item.id}
className="text-xs"
onClick={handler}
disabled={disabled}
>
{item.label}
</MenubarItem>
)
})}
</MenubarSubContent>
</MenubarSub>
</>
)
}
export function getConfigNodeDescriptor(): NodeTypeDescriptor {
return createNodeTypeBuilder(
'config',
ConfigNode,
{ width: 320, height: 320 },
{ configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' }
)
.idPrefix('cfg_')
.withInputOutput(true, true)
.classification('psyche')
.allowedSourceTypes(['config', 'variable', 'function', 'data', 'render'])
.allowedTargetTypes(['config', 'render', 'agent'])
.help(NODE_HELP.config)
.menu('Config', <ScrollText className={ICON_CLASS} />)
.getDefaultData((newId) => ({
configType: 'plantuml',
content: '@startuml\n\n@enduml\n',
title: newId ?? '',
}))
.getResetData((nodeId) => ({
configType: 'plantuml',
content: '@startuml\n\n@enduml\n',
title: nodeId ?? '',
}))
.connectionLabelByStatus({ default: 'adding input', updating: 'pushing new form', paused: 'pending', error: 'corrupted' })
.withFullscreen()
.sourceRenderingLogic({
defaultUpdateMode: 'auto',
getResolvedContent: getResolvedContentForConfig,
})
.outputMenuContent(configOutputMenuContent)
.build()
}

View File

@@ -0,0 +1,2 @@
export { default as ConfigNode, type ConfigNodeData } from './ConfigNode'
export { getConfigNodeDescriptor } from './descriptor'

View File

@@ -0,0 +1,285 @@
/**
* Config node rendering logic: implements the "Resolve" step for config sources.
* Resolves Nunjucks (extends/include/import, variables, data, functions) and returns
* resolved string + output type. See lib/graph/rendering.ts (IRenderingSource).
*/
import nunjucks from 'nunjucks'
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/rendering'
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike } from '@/lib/graph/templateRefs'
// Module-level cache for resolved Nunjucks output.
// Keyed by a fingerprint of all inputs (template contents + variable/data values + function bodies).
// Avoids re-running the Nunjucks environment when the same inputs are seen again (e.g. undo/redo,
// multiple rendering nodes sharing the same config, rapid edits cycling back to a prior value).
const MAX_RESOLVE_CACHE = 200
const resolveCache = new Map<string, ResolvedContentResult>()
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
const { nodes, edges, sourceNodeId, renderNodeId } = context
const srcId = sourceNodeId
const id = renderNodeId
const sourceNode = nodes.find((n) => n.id === sourceNodeId)
const outputTypeId: ConfigTypeId = sourceNode
? getConfigTypeId((sourceNode.data ?? undefined) as Record<string, unknown> | undefined)
: 'plantuml'
const configIdsUsed = new Set<string>()
const addConfigAndRefs = (templateName: string, visited = new Set<string>()) => {
const refId = resolveExtendsRef(nodes, templateName)
if (visited.has(refId)) throw new Error(`Circular reference detected: ${templateName}`)
const node = nodes.find((n) => n.id === refId && n.type === 'config')
if (!node) throw new Error(`Config not found: ${templateName}`)
if (refId !== srcId && !isReachable(edges, refId, id))
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
visited.add(refId)
configIdsUsed.add(refId)
const content = getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined)
for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited)
}
addConfigAndRefs(srcId)
const configLoader = {
getSource: (name: string): { src: string; path: string } | null => {
const refId = resolveExtendsRef(nodes, name)
const node = nodes.find((n) => n.id === refId && n.type === 'config')
if (!node) return null
if (refId !== srcId && !isReachable(edges, refId, id))
throw new Error(`Referenced config not connected to renderer: ${name}`)
return {
src: getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined),
path: name,
}
},
}
const nunjucksContext = Object.create(null) as Record<string, unknown>
const setVarInContext = (src: NodeLike) => {
const v = (src.data as Record<string, unknown>)?.value
const str = v === undefined || v === null ? '' : String(v)
nunjucksContext[src.id] =
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
}
for (const e of edges) {
if (!configIdsUsed.has(e.target)) continue
const src = nodes.find((n) => n.id === e.source)
if (src?.type === 'variable') setVarInContext(src)
if (src?.type === 'data') {
const rows = ((src.data as Record<string, unknown>)?.rows as Record<string, string>[]) ?? []
const hidden = ((src.data as Record<string, unknown>)?.hiddenColumns as string[]) ?? []
const visibleCols = ((src.data as Record<string, unknown>)?.columns as string[] | undefined) ?? []
const filtered = (visibleCols as string[]).filter((c) => !hidden.includes(c))
const filteredRows = rows.map((row) => {
const out: Record<string, string> = {}
for (const col of filtered) {
if (col in row) out[col] = row[col]
}
return out
})
nunjucksContext[src.id] = filteredRows
}
if (src?.type === 'render') {
nunjucksContext[src.id] =
((src.data as Record<string, unknown>)?.cachedOutputValue as string | undefined) ?? ''
}
}
const functionIdsToRegister = new Set<string>()
let added = true
while (added) {
added = false
for (const e of edges) {
const src = nodes.find((n) => n.id === e.source)
if (src?.type !== 'function') continue
const targetInScope = configIdsUsed.has(e.target) || functionIdsToRegister.has(e.target)
if (!targetInScope) continue
if (!functionIdsToRegister.has(src.id)) {
functionIdsToRegister.add(src.id)
added = true
}
}
}
for (const e of edges) {
if (!configIdsUsed.has(e.target) && !functionIdsToRegister.has(e.target)) continue
const src = nodes.find((n) => n.id === e.source)
if (src?.type === 'function') {
for (const e2 of edges) {
if (e2.target !== src.id) continue
const vNode = nodes.find((n) => n.id === e2.source)
if (vNode?.type === 'variable') setVarInContext(vNode)
}
}
}
// Build cache key from all inputs that affect the resolved output.
const allTemplateContents = [...configIdsUsed]
.map((cid) => {
const node = nodes.find((n) => n.id === cid)
return getConfigContent((node?.data ?? undefined) as Record<string, unknown> | undefined)
})
.join('\x00')
const allFunctionBodies = [...functionIdsToRegister]
.map((fid) => {
const node = nodes.find((n) => n.id === fid)
return ((node?.data as Record<string, unknown>)?.body as string) ?? ''
})
.join('\x00')
const cacheKey = `${srcId}\x01${JSON.stringify(nunjucksContext)}\x01${allTemplateContents}\x01${allFunctionBodies}`
const cached = resolveCache.get(cacheKey)
if (cached) return cached
const env = new nunjucks.Environment([configLoader], { autoescape: false })
const formatFilterResult = (r: unknown): string => {
if (r === undefined || r === null) return ''
if (typeof r === 'string' || typeof r === 'number' || typeof r === 'boolean') return String(r)
return String(r)
}
const parseFunctionSignature = (body: string): { paramNames: string[]; innerBody: string } | null => {
const withCommentsStripped = body.replace(/^\s*\/\/[^\n]*\n?/gm, '').trim()
const trimmed = withCommentsStripped.trim()
const fnMatch = trimmed.match(/^function\s*\(([^)]*)\)\s*\{([\s\S]*)\}\s*$/)
if (fnMatch) {
const paramNames = fnMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
return { paramNames, innerBody: fnMatch[2].trim() }
}
const arrowBlockMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*\{([\s\S]*)\}\s*$/)
if (arrowBlockMatch) {
const paramNames = arrowBlockMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
return { paramNames, innerBody: arrowBlockMatch[2].trim() }
}
const arrowExprMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*(.+)\s*$/s)
if (arrowExprMatch) {
const paramNames = arrowExprMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
return { paramNames, innerBody: 'return ' + arrowExprMatch[2].trim() }
}
return null
}
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v)
const functionConnectedVariableIds = Object.create(null) as Record<string, string[]>
const functionConnectedFunctionIds = Object.create(null) as Record<string, string[]>
for (const fid of functionIdsToRegister) {
for (const e of edges) {
if (e.target !== fid) continue
const src = nodes.find((n) => n.id === e.source)
if (src?.type === 'variable') {
if (!functionConnectedVariableIds[fid]) functionConnectedVariableIds[fid] = []
functionConnectedVariableIds[fid].push(src.id)
} else if (src?.type === 'function') {
if (!functionConnectedFunctionIds[fid]) functionConnectedFunctionIds[fid] = []
functionConnectedFunctionIds[fid].push(src.id)
}
}
}
for (const fid of functionIdsToRegister) {
const src = nodes.find((n) => n.id === fid)
if (!src || src.type !== 'function') continue
const body = ((src.data as Record<string, unknown>)?.body as string) ?? 'return args[0];'
const parsed = parseFunctionSignature(body)
const connectedVarIds = new Set<string>(functionConnectedVariableIds[fid] ?? [])
const connectedFuncIds = functionConnectedFunctionIds[fid] ?? []
env.addFilter(
src.id,
(value: unknown, ...args: unknown[]) => {
const callback = args[args.length - 1] as (err: Error | null, res: string) => void
const raw = [value, ...args.slice(0, -1)]
const hasKwargs = raw.length > 0 && isPlainObject(raw[raw.length - 1])
const positionals = hasKwargs ? raw.slice(0, -1) : raw
const kwargs = hasKwargs ? (raw[raw.length - 1] as Record<string, unknown>) : Object.create(null)
const nestedCache = new Map<string, string>()
const coerceCached = (s: string): string | number => {
const n = Number(s)
return s.trim() !== '' && !Number.isNaN(n) ? n : s
}
const makeCallable = (filterId: string) => (input: unknown) => {
const key = `${filterId}::${JSON.stringify(input)}`
if (nestedCache.has(key)) return coerceCached(nestedCache.get(key)!)
const p = new Promise<string>((resolve, reject) => {
env.getFilter(filterId)(input, (err: Error | null, res: string) =>
err ? reject(err) : resolve(res)
)
})
p.then((res) => nestedCache.set(key, res))
const suspend = { __suspend: true as const, promise: p, key }
throw suspend
}
let invoke: () => unknown
if (parsed) {
const { paramNames, innerBody } = parsed
const lastParam = paramNames[paramNames.length - 1]
const invocationArgs = paramNames.map((name, i) => {
if (name === lastParam && lastParam === 'kwargs') return kwargs
if (connectedVarIds.has(name) && name in nunjucksContext) return nunjucksContext[name]
if (connectedFuncIds.includes(name)) return makeCallable(name)
return positionals[i]
})
const extraVarIds = [...connectedVarIds].filter((vid) => !paramNames.includes(vid))
const extraFuncIds = connectedFuncIds.filter((fid2) => !paramNames.includes(fid2))
const allParamNames = [...paramNames, ...extraVarIds, ...extraFuncIds]
const allArgs = [
...invocationArgs,
...extraVarIds.map((vid) => nunjucksContext[vid]),
...extraFuncIds.map((fid2) => makeCallable(fid2)),
]
const fn = new Function(...allParamNames, innerBody)
invoke = () => fn(...allArgs)
} else {
const fn = new Function('args', body)
invoke = () => fn(positionals)
}
const done = (err: Error | null, res: string) => callback(err, res)
const runInvoke = () => {
try {
const result = invoke()
if (result != null && typeof (result as Promise<unknown>).then === 'function') {
(result as Promise<unknown>).then(
(r) => done(null, formatFilterResult(r)),
(err) => done(err instanceof Error ? err : new Error(String(err)), '')
)
} else {
done(null, formatFilterResult(result))
}
} catch (e: unknown) {
const s = e as { __suspend?: boolean; promise?: Promise<string>; key?: string }
if (s?.__suspend && s.promise) {
s.promise.then(() => runInvoke(), (err) =>
done(err instanceof Error ? err : new Error(String(err)), '')
)
} else {
done(e instanceof Error ? e : new Error(String(e)), '')
}
}
}
runInvoke()
},
true
)
}
return new Promise((resolve, reject) => {
env.render(srcId, nunjucksContext, (nunjucksErr: Error | null, afterNunjucks: string) => {
if (nunjucksErr) {
reject(new Error(`Nunjucks: ${nunjucksErr.message}`))
return
}
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
const result: ResolvedContentResult = { resolved, outputTypeId }
// FIFO eviction when cache is full
if (resolveCache.size >= MAX_RESOLVE_CACHE) {
resolveCache.delete(resolveCache.keys().next().value as string)
}
resolveCache.set(cacheKey, result)
resolve(result)
})
})
}

View File

@@ -0,0 +1,363 @@
import React, { useCallback, useContext, useMemo, useRef, useState } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
} from '@/lib/graph/abstractNode'
import {
BaseNode,
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from '@/components/graph/BaseNode'
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
import { NodeMenubar } from '@/components/graph/NodeMenubar'
import { FlowUIContext } from '@/lib/graph/flowContext'
import { getNodeType } from '@/lib/graph/nodeRegistry'
import { OutputHandle } from '@/components/graph/NodeHandles'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { parseCsvToRows } from '@/lib/csvParse'
import { ArrowDown, ArrowUp, ArrowUpDown, Database, FileUp } from 'lucide-react'
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type SortingState,
} from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ContextMenuCheckboxItem } from '@/components/ui/context-menu'
import {
MenubarCheckboxItem,
MenubarItem,
MenubarSeparator,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
} from '@/components/ui/menubar'
export type DataNodeData = {
rows?: Record<string, string>[]
columns?: string[]
fileName?: string
/** Column ids (header names) that are hidden; hidden columns are excluded from template output */
hiddenColumns?: string[]
}
type Props = AbstractNodeProps<DataNodeData>
function DataNodeComponent({ id, data, width, height, selected }: Props) {
const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('data')?.supportsFullscreen
const { updateData } = useAbstractNode<DataNodeData>(id, data ?? {})
const rows = data?.rows ?? []
const columns = data?.columns ?? []
const hiddenColumns = data?.hiddenColumns ?? []
const [isDragging, setIsDragging] = useState(false)
const [globalFilter, setGlobalFilter] = useState('')
const [sorting, setSorting] = useState<SortingState>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const hasData = rows.length > 0 && columns.length > 0
const onFile = useCallback(
(file: File) => {
if (!file.name.toLowerCase().endsWith('.csv')) return
const reader = new FileReader()
reader.onload = () => {
try {
const text = (reader.result as string) ?? ''
const { columns: cols, rows: parsed } = parseCsvToRows(text)
updateData({ rows: parsed, columns: cols, fileName: file.name, hiddenColumns: [] })
} catch {
updateData({ rows: [], columns: [], fileName: '', hiddenColumns: [] })
}
}
reader.readAsText(file)
},
[updateData]
)
const onDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer?.files?.[0]
if (file) onFile(file)
},
[onFile]
)
const onDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
setIsDragging(true)
}, [])
const onDragLeave = useCallback(() => setIsDragging(false), [])
const onInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) onFile(file)
e.target.value = ''
},
[onFile]
)
const openFileDialog = useCallback(() => {
fileInputRef.current?.click()
}, [])
const clearData = useCallback(() => {
updateData({ rows: [], columns: [], fileName: '', hiddenColumns: [] })
}, [updateData])
const columnVisibility = useMemo(
() => Object.fromEntries(columns.map((col) => [col, !hiddenColumns.includes(col)])),
[columns, hiddenColumns]
)
const onColumnVisibilityChange = useCallback(
(updater: (prev: Record<string, boolean>) => Record<string, boolean>) => {
const next = updater(columnVisibility)
const newHidden = columns.filter((col) => !next[col])
updateData({ hiddenColumns: newHidden })
},
[columnVisibility, columns, updateData]
)
const tableColumns = useMemo<ColumnDef<Record<string, string>>[]>(() => {
return columns.map((key) => ({
accessorKey: key,
header: ({ column }: { column: { getIsSorted: () => false | 'asc' | 'desc'; toggleSorting: (desc: boolean) => void } }) => (
<Button
variant="ghost"
size="sm"
className="-ml-2 h-8 data-[state=open]:bg-accent"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{key}
{column.getIsSorted() === 'desc' ? (
<ArrowDown className="ml-1.5 size-3.5" />
) : column.getIsSorted() === 'asc' ? (
<ArrowUp className="ml-1.5 size-3.5" />
) : (
<ArrowUpDown className="ml-1.5 size-3.5" />
)}
</Button>
),
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => (
<span className="truncate max-w-[200px] block" title={String(row.getValue(key) ?? '')}>
{String(row.getValue(key) ?? '')}
</span>
),
}))
}, [columns])
const table = useReactTable({
data: rows,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onColumnVisibilityChange,
onGlobalFilterChange: setGlobalFilter,
state: {
sorting,
columnVisibility,
globalFilter,
},
globalFilterFn: (row, _columnIds, filterValue) => {
const str = String(filterValue ?? '').toLowerCase()
if (!str) return true
const obj = row.original as Record<string, unknown>
for (const v of Object.values(obj)) {
if (String(v ?? '').toLowerCase().includes(str)) return true
}
return false
},
})
const fileInputId = `data-node-file-${id}`
const dataMenuContent = (
<>
<MenubarItem className="text-xs" onClick={openFileDialog}>
Upload new dataset
</MenubarItem>
<MenubarItem className="text-xs" onClick={clearData}>
Clear dataset
</MenubarItem>
{hasData && (
<>
<MenubarSeparator />
<MenubarSub>
<MenubarSubTrigger className="text-xs">Columns</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]">
{table
.getAllColumns()
.filter((col) => typeof col.accessorFn !== 'undefined' && col.getCanHide())
.map((col) => (
<MenubarCheckboxItem
key={col.id}
className="text-xs capitalize"
checked={col.getIsVisible()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
>
{col.id}
</MenubarCheckboxItem>
))}
</MenubarSubContent>
</MenubarSub>
<MenubarSub>
<MenubarSubTrigger className="text-xs">Rows per page</MenubarSubTrigger>
<MenubarSubContent className="min-w-[8rem]">
{[10, 20, 30, 50].map((size) => (
<ContextMenuCheckboxItem
key={size}
className="text-xs"
checked={table.getState().pagination.pageSize === size}
onCheckedChange={() => table.setPageSize(size)}
>
{size}
</ContextMenuCheckboxItem>
))}
</MenubarSubContent>
</MenubarSub>
</>
)}
</>
)
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
return (
<BaseNode className="min-w-[320px] min-h-[200px]" dimensions={dimensions} resizable nodeId={id} selected={selected} handles={<OutputHandle id="out" />}>
<input
id={fileInputId}
ref={fileInputRef}
type="file"
accept=".csv"
onChange={onInputChange}
className="sr-only"
aria-label="Upload CSV file"
/>
<BaseNodeHeaderRow
icon={<Database className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
/>
<BaseNodeContent>
<div className="shrink-0 w-full">
<NodeMenubar nodeId={id} nodeType="data" dataMenuContent={dataMenuContent} />
</div>
{!hasData ? (
<label
htmlFor={fileInputId}
className={`flex-1 min-h-[140px] flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-4 m-2 transition-colors nodrag nopan cursor-pointer ${isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
}`}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<FileUp className="size-8 text-muted-foreground" />
<p className="text-xs text-muted-foreground text-center">
Drop a .csv file here or click to browse
</p>
</label>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-2 pt-2 nodrag nopan">
<div className="flex shrink-0 flex-wrap items-center gap-2 px-2">
<Input
placeholder="Search..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="h-8 max-w-[180px] text-xs"
/>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
</Button>
<Button
variant="outline"
size="sm"
className="h-8 w-8 p-0"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="whitespace-nowrap">
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-16 text-center text-muted-foreground text-xs">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)}
</BaseNodeContent>
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="data">
{hasData
? `${table.getFilteredRowModel().rows.length} of ${rows.length} rows · ${table.getVisibleLeafColumns().length} cols`
: 'Drop CSV'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>
)
}
export const DataNode = createAbstractNodeComponent<DataNodeData>('DataNode', DataNodeComponent)
export default DataNode

View File

@@ -0,0 +1,26 @@
import React from 'react'
import { Database } from 'lucide-react'
import { createNodeTypeBuilder } from '@/lib/graph/nodeTypeBuilder'
import { NODE_HELP } from '@/lib/graph/nodeHelp'
import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry'
import DataNode from './DataNode'
const ICON_CLASS = 'mr-2 h-4 w-4'
export function getDataNodeDescriptor(): NodeTypeDescriptor {
return createNodeTypeBuilder(
'data',
DataNode,
{ width: 360, height: 280 },
{ rows: [], columns: [], fileName: '' }
)
.idPrefix('data_')
.withInputOutput(false, true)
.classification('physis')
.allowedTargetTypes(['config', 'agent'])
.help(NODE_HELP.data)
.menu('Data', <Database className={ICON_CLASS} />)
.connectionLabelByStatus({ default: 'data source' })
.withFullscreen()
.build()
}

View File

@@ -0,0 +1,2 @@
export { default as DataNode, type DataNodeData } from './DataNode'
export { getDataNodeDescriptor } from './descriptor'

Some files were not shown because too many files have changed in this diff Show More