Compare commits
53 Commits
084863909a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 223336c606 | |||
| cbd8f1568b | |||
| 166c7b0b7f | |||
| 2635b45973 | |||
| c3cbe35883 | |||
| bc67d01bc2 | |||
| 96cb3701ba | |||
| 5bce586db6 | |||
| febca67c9b | |||
| 9076a45510 | |||
| be0f743970 | |||
| 3de9a33074 | |||
| 9d38b1df2b | |||
| 9f56b728c0 | |||
| f6193bf180 | |||
| e64e29d773 | |||
| 7c7d470f15 | |||
| c2db0b33d6 | |||
| f67c491deb | |||
| 45c387c9e6 | |||
| 4d673ab556 | |||
| 290217791f | |||
| 929359b4bc | |||
| a8bd579043 | |||
| 202462bad7 | |||
| 449d2cb382 | |||
| 04c039bdde | |||
| 2326bbe035 | |||
| ce58673d18 | |||
| bcb9c5946d | |||
| a1ad4d52cf | |||
| 78f5a51aa8 | |||
| 1ee078c350 | |||
| b642a7aab2 | |||
| 353c2db335 | |||
| fc265266c7 | |||
| 2d8f13aebb | |||
| 2ff7e1f5f2 | |||
| 4dca9c6478 | |||
| b71d32da5e | |||
| 0578b26241 | |||
| 029bab8917 | |||
| 92fdba7eef | |||
| fb1df18256 | |||
| 35b2e9d538 | |||
| e9ed508bfc | |||
| a0bf9c6b70 | |||
| 51c96641f0 | |||
| 923bf4bff0 | |||
| 4bd2f1b458 | |||
| bfd1a40332 | |||
| f5b12949d3 | |||
| e29c5d643c |
216
ARCHITECTURE.md
Normal file
216
ARCHITECTURE.md
Normal 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
61
CONTRIBUTING.md
Normal 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
108
PERFORMANCE_IMPROVEMENTS.md
Normal 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 60–70% for the JS bundle.
|
||||
|
||||
---
|
||||
|
||||
## 4. Success Metrics
|
||||
|
||||
| Metric | Current (estimated) | Target |
|
||||
| --- | --- | --- |
|
||||
| Frame time on 50-node canvas | ~16 ms | < 10 ms |
|
||||
| Initial JS bundle (gzipped) | ~800 KB | < 600 KB |
|
||||
| Repeated identical LLM calls | uncached | 0 network round-trips |
|
||||
| localStorage write frequency | every change | debounced 300 ms |
|
||||
|
||||
---
|
||||
|
||||
## 5. Contribution Path
|
||||
|
||||
1. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the module you're optimizing.
|
||||
2. Pick one item from section 2.
|
||||
3. Add a Vitest benchmark (`performance.now()` before/after) alongside your change.
|
||||
4. Submit a PR with the benchmark results in the description and update this file's "Current" column.
|
||||
|
||||
*This plan is a living document; update the metrics table when improvements land.*
|
||||
141
README.md
141
README.md
@@ -1,42 +1,61 @@
|
||||
# Zui
|
||||
|
||||
Node-based editor (React Flow) for configs, variables, and rendering (PlantUML, Markdown, etc.). Optional Node.js backend API for demos or future features (e.g. todos CRUD).
|
||||
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
|
||||
|
||||
```
|
||||
my-app/
|
||||
├── frontend/ # React app (Vite, TypeScript)
|
||||
│ ├── Dockerfile # Multi-stage for prod (build → Nginx)
|
||||
│ ├── Dockerfile.dev # Dev with hot reload
|
||||
```text
|
||||
zui/
|
||||
├── frontend/ # React SPA (Vite, TypeScript)
|
||||
│ ├── src/
|
||||
│ ├── public/
|
||||
│ │ ├── 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
|
||||
│ ├── Dockerfile
|
||||
├── backend/ # Node.js/Express API
|
||||
│ ├── src/
|
||||
│ │ └── index.js
|
||||
│ │ ├── 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
|
||||
├── .dockerignore
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
Ignored by git: `node_modules`, `dist`, `.env`, `.env.*` (see [.gitignore](.gitignore)). Local Docker overrides: `docker-compose.override.yml` (optional, not committed).
|
||||
Ignored by git: `node_modules`, `dist`, `.env`, `.env.*`. Local Docker overrides: `docker-compose.override.yml` (optional, not committed).
|
||||
|
||||
---
|
||||
|
||||
## Run locally (dev)
|
||||
|
||||
**Frontend only:**
|
||||
**Frontend only** (no AI agent):
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run dev
|
||||
# → http://localhost:3000
|
||||
```
|
||||
|
||||
**Frontend + backend** (so the app can show “Backend API: N todos”):
|
||||
**Frontend + backend** (for AI agent and health check):
|
||||
|
||||
```bash
|
||||
# Terminal 1 – backend
|
||||
@@ -45,7 +64,7 @@ cd backend && npm install && npm run dev
|
||||
|
||||
# Terminal 2 – frontend
|
||||
cd frontend && npm install && npm run dev
|
||||
# → http://localhost:3000 (Vite proxies /api/todos to backend)
|
||||
# → http://localhost:3000 (Vite proxies /api/* and /health to backend)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -56,77 +75,79 @@ cd frontend && npm install && npm run dev
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- **Frontend**: http://localhost:3000 (Nginx; `/api/*` proxied to backend).
|
||||
- **Backend**: http://localhost:8080 (Express).
|
||||
- **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. |
|
||||
| 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; put Caddy or Nginx in front for TLS if needed.
|
||||
|
||||
**Dev with Docker (frontend hot reload):** use `frontend/Dockerfile.dev` and mount `./frontend` as a volume, or run `cd frontend && npm run dev` locally.
|
||||
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. |
|
||||
| `cd frontend && npm run build` | Build frontend for production. |
|
||||
| `cd frontend && npm run preview` | Preview production build. |
|
||||
| `cd backend && npm run dev` | Backend with `--watch`. |
|
||||
| `cd backend && npm start` | Backend production run. |
|
||||
| `docker compose up --build` | Run frontend + backend in Docker. |
|
||||
| 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 (no DB)
|
||||
## Backend API
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/todos` | List all todos. |
|
||||
| GET | `/api/todos/:id` | Get one todo. |
|
||||
| POST | `/api/todos` | Create (`{ "title": "...", "completed": false }`). |
|
||||
| PUT | `/api/todos/:id` | Update. |
|
||||
| DELETE | `/api/todos/:id` | Delete. |
|
||||
| GET | `/health` | Health check (e.g. for Docker). |
|
||||
| POST | `/api/agent` | Run AI agent; body `{ "prompt", "context?", "contextNodes?" }` → `{ "markdown" }`. |
|
||||
|
||||
Data is in-memory (resets on restart). Add a JSON file or DB later if needed.
|
||||
| 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)
|
||||
|
||||
The **Agent** node uses an OpenAI-compatible API. You can use:
|
||||
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.
|
||||
|
||||
**1. Local LLM (e.g. LM Studio)**
|
||||
The backend reads its AI config from environment variables as a fallback:
|
||||
|
||||
1. Install [LM Studio](https://lmstudio.ai/) and load a model.
|
||||
2. Start the local server: in LM Studio open the **Developer** tab and run the **Local Server** (default: `http://localhost:1234`).
|
||||
3. In the project root or `backend/`, set:
|
||||
### 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
|
||||
# Optional: set to the model name shown in LM Studio (e.g. the loaded model id). Default is "local-model".
|
||||
export AI_MODEL=your-model-name
|
||||
export AI_MODEL=your-model-name # optional; matches the name shown in LM Studio
|
||||
```
|
||||
|
||||
4. Start the backend (`cd backend && npm run dev`). The Agent node will use your local model.
|
||||
### OpenAI
|
||||
|
||||
**2. OpenAI**
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export AI_MODEL=gpt-4o-mini # optional; defaults to gpt-4o-mini
|
||||
```
|
||||
|
||||
Set `OPENAI_API_KEY` to your API key. The backend will use `gpt-4o-mini` unless you set `AI_MODEL`.
|
||||
---
|
||||
|
||||
**Env summary (backend)**
|
||||
## Contribute
|
||||
|
||||
| Variable | When to use | Description |
|
||||
|----------|--------------|-------------|
|
||||
| `AI_BASE_URL` | Local LLM (LM Studio, Ollama, etc.) | OpenAI-compatible base URL, e.g. `http://localhost:1234/v1`. |
|
||||
| `AI_MODEL` | Optional | Model id (for local: use the name shown in LM Studio; for OpenAI: e.g. `gpt-4o-mini`). |
|
||||
| `OPENAI_API_KEY` | OpenAI only | Your OpenAI API key. Not required when using `AI_BASE_URL` only. |
|
||||
- [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!*
|
||||
|
||||
678
backend/package-lock.json
generated
678
backend/package-lock.json
generated
@@ -13,6 +13,13 @@
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
@@ -103,6 +110,448 @@
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -112,12 +561,120 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/diff-match-patch": {
|
||||
"version": "1.0.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
|
||||
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/serve-static": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -392,6 +949,48 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -489,6 +1088,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -535,6 +1149,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -853,6 +1480,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -1051,6 +1688,26 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -1064,6 +1721,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
"name": "zui-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Minimal Express API for Zui (todos CRUD, no DB)",
|
||||
"main": "src/index.js",
|
||||
"description": "Minimal Express API for Zui (agent, health; no DB)",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
"start": "node src/index.ts",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -16,5 +18,12 @@
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* Minimal Express API: /api/todos CRUD (in-memory).
|
||||
* No DB, no auth. CORS allowed for frontend. Production-ready env (PORT, CORS_ORIGIN).
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const cors = require('cors')
|
||||
|
||||
const PORT = Number(process.env.PORT) || 8080
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:3000'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(cors({ origin: CORS_ORIGIN }))
|
||||
app.use(express.json())
|
||||
|
||||
// In-memory store (replace with JSON file or DB later)
|
||||
let todos = [
|
||||
{ id: '1', title: 'Sample todo', completed: false },
|
||||
{ id: '2', title: 'Another item', completed: true },
|
||||
]
|
||||
let nextId = 3
|
||||
|
||||
/** GET /api/todos — list all */
|
||||
app.get('/api/todos', (req, res) => {
|
||||
try {
|
||||
res.json(todos)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** GET /api/todos/:id — get one */
|
||||
app.get('/api/todos/:id', (req, res) => {
|
||||
try {
|
||||
const todo = todos.find((t) => t.id === req.params.id)
|
||||
if (!todo) return res.status(404).json({ error: 'Not found' })
|
||||
res.json(todo)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** POST /api/todos — create */
|
||||
app.post('/api/todos', (req, res) => {
|
||||
try {
|
||||
const { title, completed } = req.body ?? {}
|
||||
const id = String(nextId++)
|
||||
const todo = { id, title: title ?? '', completed: Boolean(completed) }
|
||||
todos.push(todo)
|
||||
res.status(201).json(todo)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** PUT /api/todos/:id — update */
|
||||
app.put('/api/todos/:id', (req, res) => {
|
||||
try {
|
||||
const idx = todos.findIndex((t) => t.id === req.params.id)
|
||||
if (idx === -1) return res.status(404).json({ error: 'Not found' })
|
||||
const { title, completed } = req.body ?? {}
|
||||
if (title !== undefined) todos[idx].title = title
|
||||
if (completed !== undefined) todos[idx].completed = Boolean(completed)
|
||||
res.json(todos[idx])
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** DELETE /api/todos/:id — delete */
|
||||
app.delete('/api/todos/:id', (req, res) => {
|
||||
try {
|
||||
const idx = todos.findIndex((t) => t.id === req.params.id)
|
||||
if (idx === -1) return res.status(404).json({ error: 'Not found' })
|
||||
const removed = todos.splice(idx, 1)[0]
|
||||
res.json(removed)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
/** Build OpenAI client and full prompt from request body. Returns { openai, modelId, fullPrompt } or { error }. */
|
||||
function buildAgentRequest(body) {
|
||||
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
|
||||
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 }
|
||||
}
|
||||
|
||||
/** POST /api/agent — run AI agent; body: { prompt, context?, contextNodes?, connection? }; returns { markdown }. */
|
||||
app.post('/api/agent', async (req, res) => {
|
||||
try {
|
||||
const built = buildAgentRequest(req.body)
|
||||
if (built.error) return res.status(503).json({ error: built.error })
|
||||
const { generateText } = await import('ai')
|
||||
const result = await generateText({
|
||||
model: built.openai(built.modelId),
|
||||
prompt: built.fullPrompt,
|
||||
})
|
||||
const markdown = result?.text ?? ''
|
||||
res.json({ markdown })
|
||||
} catch (err) {
|
||||
console.error('Agent error:', err)
|
||||
res.status(500).json({ error: err?.message ?? 'Agent request failed' })
|
||||
}
|
||||
})
|
||||
|
||||
/** POST /api/agent/stream — same as /api/agent but streams plain text (markdown) chunks. */
|
||||
app.post('/api/agent/stream', async (req, res) => {
|
||||
try {
|
||||
const built = buildAgentRequest(req.body)
|
||||
if (built.error) return res.status(503).json({ error: built.error })
|
||||
const { streamText } = await import('ai')
|
||||
const result = streamText({
|
||||
model: built.openai(built.modelId),
|
||||
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?.message ?? 'Agent request failed' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Health check for Docker / orchestration */
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({ ok: true })
|
||||
})
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
|
||||
})
|
||||
102
backend/src/index.ts
Normal file
102
backend/src/index.ts
Normal 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
|
||||
88
backend/src/middleware/rateLimiter.ts
Normal file
88
backend/src/middleware/rateLimiter.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
63
backend/src/models/index.ts
Normal file
63
backend/src/models/index.ts
Normal 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
|
||||
}
|
||||
103
backend/src/repositories/cacheRepository.ts
Normal file
103
backend/src/repositories/cacheRepository.ts
Normal 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()
|
||||
72
backend/src/routes/agentRoutes.ts
Normal file
72
backend/src/routes/agentRoutes.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
65
backend/src/services/agentService.ts
Normal file
65
backend/src/services/agentService.ts
Normal 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
20
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
48
docs/CODE_REVIEW_CHECKLIST.md
Normal file
48
docs/CODE_REVIEW_CHECKLIST.md
Normal 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 sub‑components.
|
||||
- [ ] **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 re‑renders.
|
||||
- [ ] **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 hard‑coded secrets.
|
||||
- [ ] **Error Handling**: Errors are caught and logged appropriately; user‑friendly 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.*
|
||||
185
frontend/docs/CANVAS_PERFORMANCE_OPTIONS.md
Normal file
185
frontend/docs/CANVAS_PERFORMANCE_OPTIONS.md
Normal 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`) don’t re-render when only connection path state changes. Edges that only need connection path state don’t 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) don’t 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 haven’t 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 don’t 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 500 ms 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 hasn’t 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, low–medium 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 don’t 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 don’t 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 doesn’t 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 doesn’t 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 > N) or simplifying their rendering (e.g. simpler background pattern, minimap with lower refresh rate).
|
||||
|
||||
**Pattern:** **Use platform features** — lean on React Flow’s 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 shouldn’t 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 don’t mount at all, so their hooks and effects don’t 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()` isn’t 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` doesn’t close over changing `nodes`**
|
||||
You already use `nodes` in `isValidConnection`; that’s 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 doesn’t change when `nodes` changes, and the validator always reads the latest nodes from the ref. Then nodes/edges that only need `isValidConnection` don’t 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 don’t 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 don’t 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 don’t 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 hasn’t 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.
|
||||
218
frontend/docs/CANVAS_STATE_DESIGN.md
Normal file
218
frontend/docs/CANVAS_STATE_DESIGN.md
Normal 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 hasn’t 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 edge’s 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 edge’s 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. Zustand’s `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.
|
||||
77
frontend/docs/IMPROVEMENTS.md
Normal file
77
frontend/docs/IMPROVEMENTS.md
Normal 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.
|
||||
99
frontend/docs/QUICKSTART_FOR_JUNIORS.md
Normal file
99
frontend/docs/QUICKSTART_FOR_JUNIORS.md
Normal 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!*
|
||||
@@ -1,13 +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>React Flow + shadcn Canvas</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</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>
|
||||
4856
frontend/package-lock.json
generated
4856
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,11 +5,14 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@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",
|
||||
@@ -25,24 +28,32 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@uiw/react-codemirror": "^4.25.7",
|
||||
"@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-zoom-pan-pinch": "^3.7.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"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",
|
||||
@@ -52,6 +63,7 @@
|
||||
"shadcn": "^4.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^2.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
21
frontend/src/app/NotFoundPage.tsx
Normal file
21
frontend/src/app/NotFoundPage.tsx
Normal 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 you’re looking for doesn’t exist or has been moved.
|
||||
</p>
|
||||
<Button asChild variant="default">
|
||||
<Link to="/recollections">Back to Recollections</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
frontend/src/app/canvas/CanvasContextMenuContent.tsx
Normal file
62
frontend/src/app/canvas/CanvasContextMenuContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Menubar,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarMenu,
|
||||
MenubarSeparator,
|
||||
MenubarTrigger
|
||||
} from '@/components/ui/menubar'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Undo2 } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export type CanvasMenubarProps = {
|
||||
onImport: () => void
|
||||
onExport: () => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
canUndo: boolean
|
||||
canRedo: boolean
|
||||
onDuplicate?: () => void
|
||||
onCopy?: () => void
|
||||
onPaste?: () => void
|
||||
canDuplicate?: boolean
|
||||
canCopy?: boolean
|
||||
onFitView?: () => void
|
||||
}
|
||||
|
||||
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 CanvasMenubar({
|
||||
onImport,
|
||||
onExport,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onDuplicate,
|
||||
onCopy,
|
||||
onPaste,
|
||||
canDuplicate = false,
|
||||
canCopy = false,
|
||||
onFitView,
|
||||
}: CanvasMenubarProps) {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { projects, renameProject } = usePlatform()
|
||||
const projectName = useMemo(
|
||||
() => (projectId ? projects.find((p) => p.id === projectId)?.name ?? null : null),
|
||||
[projectId, projects]
|
||||
)
|
||||
|
||||
const [isRenamingProject, setIsRenamingProject] = useState(false)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
const ignoreNextBlurRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isRenamingProject) {
|
||||
setRenameValue(projectName ?? '')
|
||||
ignoreNextBlurRef.current = true
|
||||
// Delay focus so the Project dropdown can close first and not steal focus back (which would trigger blur)
|
||||
const t = setTimeout(() => {
|
||||
renameInputRef.current?.focus()
|
||||
renameInputRef.current?.select()
|
||||
}, 100)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [isRenamingProject, projectName])
|
||||
|
||||
const applyRename = useCallback(() => {
|
||||
if (!projectId || !renameProject) return
|
||||
const trimmed = renameValue.trim()
|
||||
if (trimmed) renameProject(projectId, trimmed)
|
||||
setIsRenamingProject(false)
|
||||
}, [projectId, renameProject, renameValue])
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setIsRenamingProject(false)
|
||||
}, [])
|
||||
|
||||
const handleRenameBlur = useCallback(() => {
|
||||
if (ignoreNextBlurRef.current) {
|
||||
ignoreNextBlurRef.current = false
|
||||
return
|
||||
}
|
||||
applyRename()
|
||||
}, [applyRename])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if (matchKey(ev, UNDO_KEYS)) {
|
||||
if (canUndo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
undo()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (matchKey(ev, REDO_KEYS)) {
|
||||
if (canRedo) {
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
redo()
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [undo, redo, canUndo, canRedo])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-9 w-full shrink-0 items-center border-b border-border/40 bg-background">
|
||||
<Menubar className="flex-1 shrink-0 rounded-none border-0 border-b-0 bg-transparent p-0 shadow-none">
|
||||
<Link
|
||||
to="/projects"
|
||||
aria-label="Back to projects"
|
||||
className="flex shrink-0 items-center rounded-sm px-2 py-1 ml-1 text-sm outline-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-normal text-muted-foreground">Project</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
{projectId && (
|
||||
<>
|
||||
<MenubarItem
|
||||
onClick={() => setIsRenamingProject(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Rename
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
</>
|
||||
)}
|
||||
<MenubarItem onClick={onImport} className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Import…
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={onExport} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
Export…
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger className="font-normal text-muted-foreground">Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={undo} disabled={!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={redo} disabled={!canRedo} className="gap-2">
|
||||
<Redo2 className="h-4 w-4" />
|
||||
Redo
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘ + ⇧ + Z</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
{(onDuplicate != null || onCopy != null || onPaste != null) && <MenubarSeparator />}
|
||||
{onDuplicate != null && (
|
||||
<MenubarItem onClick={onDuplicate} disabled={!canDuplicate} className="gap-2">
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
Duplicate
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘D</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{onCopy != null && (
|
||||
<MenubarItem onClick={onCopy} disabled={!canCopy} className="gap-2">
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘C</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{onPaste != null && (
|
||||
<MenubarItem onClick={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="font-normal text-muted-foreground">View</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
{onFitView && (
|
||||
<MenubarItem onClick={onFitView} className="gap-2">
|
||||
Fit View
|
||||
<span className="ml-auto pl-4">
|
||||
<KbdGroup>
|
||||
<Kbd>⌘0</Kbd>
|
||||
</KbdGroup>
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
{projectId && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 flex justify-center max-w-[40%] min-w-[120px]">
|
||||
{isRenamingProject ? (
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
applyRename()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={handleRenameBlur}
|
||||
className="h-7 text-sm font-medium text-center font-serif"
|
||||
aria-label="Project name"
|
||||
/>
|
||||
) : (
|
||||
<span className="pointer-events-none truncate text-sm font-medium text-foreground font-serif">
|
||||
{projectName ?? 'Untitled'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Canvas page: the graph editor (React Flow) with nodes, edges, context menu, import/export.
|
||||
* Rendered inside the platform when a project is selected.
|
||||
* Rendered inside the platform when a recollection is selected.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
@@ -23,24 +23,23 @@ import {
|
||||
type EdgeChange,
|
||||
} from '@xyflow/react'
|
||||
import { AnimatedEdge } from '@/components/graph/AnimatedEdge'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import {
|
||||
GraphContext,
|
||||
ConnectionPathContext,
|
||||
FlowUIContext,
|
||||
} from '@/lib/graph/flowContext'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuGroup,
|
||||
} from '@/components/ui/context-menu'
|
||||
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
|
||||
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,
|
||||
@@ -56,87 +55,81 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ClipboardPaste, FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
|
||||
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||
import { FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
|
||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||
import {
|
||||
getRegisteredNodeTypes,
|
||||
getRegisteredNodeTypesGroupedByClassification,
|
||||
getRegisteredNodeTypeIds,
|
||||
getDefaultStyle,
|
||||
getNodeType,
|
||||
getConnectionLabelForTarget,
|
||||
isConnectionAllowed,
|
||||
} from '@/lib/graph/nodeRegistry'
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
loadGraphFromStorage,
|
||||
saveGraphToStorage,
|
||||
PROJECT_FILE_EXT,
|
||||
PROJECT_VERSION,
|
||||
} from '@/app/pleroma/projectGraphStorage'
|
||||
import { RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||
|
||||
const SNAP_GRID: [number, number] = [15, 15]
|
||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
|
||||
const CONNECTION_PATH_UPDATE_MIN_MS = 1500
|
||||
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],
|
||||
})
|
||||
|
||||
const NODE_GAP = 150
|
||||
const initialNodes: 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 initialEdges: 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' },
|
||||
]
|
||||
|
||||
function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
return {
|
||||
nodes: initialNodes.map((n) => ({
|
||||
...n,
|
||||
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
|
||||
})),
|
||||
edges: initialEdges.map((e) => ({ ...e })),
|
||||
}
|
||||
}
|
||||
|
||||
function FlowFitViewOnLoad() {
|
||||
function FlowFitViewOnLoad({ disabled }: { disabled?: boolean }) {
|
||||
const nodesInitialized = useNodesInitialized()
|
||||
const { fitView } = useReactFlow()
|
||||
React.useEffect(() => {
|
||||
if (nodesInitialized) fitView?.({ duration: 200 })
|
||||
}, [nodesInitialized, fitView])
|
||||
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
|
||||
}
|
||||
|
||||
@@ -205,25 +198,15 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
|
||||
}
|
||||
|
||||
export type CanvasPageProps = {
|
||||
/** Optional project id for future per-project graph loading */
|
||||
projectId?: string
|
||||
/** 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
|
||||
}
|
||||
|
||||
function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||
if (projectId) {
|
||||
const stored = loadGraphFromStorage(projectId)
|
||||
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
||||
return { nodes: stored.nodes as AppNode[], edges: stored.edges as AppEdge[] }
|
||||
}
|
||||
return { nodes: [], edges: [] }
|
||||
}
|
||||
return getExampleGraph()
|
||||
}
|
||||
|
||||
export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||
const { theme } = useTheme()
|
||||
const { showMinimap } = usePlatform()
|
||||
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
@@ -238,22 +221,10 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
canUndo,
|
||||
canRedo,
|
||||
setStateImmediate,
|
||||
} = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||
save,
|
||||
saveStatus,
|
||||
} = useCanvasGraph(recollectionId)
|
||||
|
||||
// Persist graph to localStorage when projectId is set (debounced)
|
||||
const saveTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
React.useEffect(() => {
|
||||
if (!projectId) return
|
||||
const save = () => {
|
||||
saveGraphToStorage(projectId, { version: PROJECT_VERSION, nodes, edges })
|
||||
}
|
||||
saveTimeoutRef.current = setTimeout(save, 500)
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
||||
}
|
||||
}, [projectId, nodes, edges])
|
||||
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
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)
|
||||
@@ -267,95 +238,30 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const [isSelecting, setIsSelecting] = React.useState(false)
|
||||
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||||
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
|
||||
const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = React.useState<string[]>([])
|
||||
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = React.useState<string[]>([])
|
||||
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
const pathUpdateStartTimeRef = useRef<number | null>(null)
|
||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = React.useState<string[]>([])
|
||||
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = React.useState<string[]>([])
|
||||
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
|
||||
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
|
||||
|
||||
const clearPathUpdateSession = useCallback(() => {
|
||||
setConnectionPathUpdatingNodeIds([])
|
||||
if (connectionPathPausedNodeIdsRef.current.length === 0) {
|
||||
setConnectionPathTriggerNodeIds([])
|
||||
setConnectionPathPausedNodeIds([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.add(nodeId)
|
||||
if (ref.size === 1) {
|
||||
pathUpdateStartTimeRef.current = Date.now()
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
pathUpdateEndTimeoutRef.current = 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
|
||||
}
|
||||
}
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
}, [])
|
||||
}, [nodes, edges])
|
||||
|
||||
const endConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.delete(nodeId)
|
||||
if (ref.size > 0) {
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
return
|
||||
}
|
||||
const startedAt = pathUpdateStartTimeRef.current ?? 0
|
||||
const elapsed = Date.now() - startedAt
|
||||
const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed)
|
||||
if (remaining === 0) {
|
||||
clearPathUpdateSession()
|
||||
} else {
|
||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
clearPathUpdateSession()
|
||||
}, remaining)
|
||||
}
|
||||
}, [clearPathUpdateSession])
|
||||
|
||||
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
|
||||
const pathTriggerScheduledRef = useRef(false)
|
||||
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)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
const connectionPath = useCanvasConnectionPathFromStore()
|
||||
|
||||
const nodesRef = useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
|
||||
const [apiTodosCount, setApiTodosCount] = React.useState<number | null>(null)
|
||||
React.useEffect(() => {
|
||||
fetch('/api/todos')
|
||||
.then((r) => r.json())
|
||||
.then((data: unknown) => {
|
||||
if (Array.isArray(data)) setApiTodosCount(data.length)
|
||||
})
|
||||
.catch(() => setApiTodosCount(-1))
|
||||
}, [])
|
||||
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)
|
||||
@@ -375,7 +281,22 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null
|
||||
const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
|
||||
if (toApply.length > 0) setNodesSilent((nds) => applyNodeChanges(toApply, nds))
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -401,28 +322,31 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
)
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||
(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 sourceNode = nodes.find((n) => n.id === src)
|
||||
const targetNode = nodes.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)
|
||||
},
|
||||
[nodes]
|
||||
)
|
||||
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(
|
||||
(
|
||||
@@ -449,55 +373,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
|
||||
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
|
||||
|
||||
const handleExportProject = useCallback(() => {
|
||||
const state = { version: PROJECT_VERSION, nodes, edges }
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `project${PROJECT_FILE_EXT}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Project exported')
|
||||
}, [nodes, edges])
|
||||
|
||||
const handleImportProject = useCallback(() => importInputRef.current?.click(), [])
|
||||
|
||||
const handleLoadExample = useCallback(() => {
|
||||
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
||||
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
|
||||
toast.success('Example loaded')
|
||||
}, [setStateImmediate])
|
||||
|
||||
const onImportFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const text = reader.result as string
|
||||
const state = JSON.parse(text) as { version?: number; nodes?: unknown[]; edges?: unknown[] }
|
||||
if (!state || !Array.isArray(state.nodes) || !Array.isArray(state.edges)) {
|
||||
toast.error('Invalid file: expected nodes and edges arrays')
|
||||
return
|
||||
}
|
||||
setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] })
|
||||
if (state.version != null && state.version > PROJECT_VERSION) {
|
||||
toast.error('Project was created with a newer app version')
|
||||
} else {
|
||||
toast.success('Project loaded')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Invalid file: not valid JSON')
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
},
|
||||
[setStateImmediate]
|
||||
)
|
||||
|
||||
const selectedNodes = useMemo(
|
||||
() => nodes.filter((n) => (n as Node & { selected?: boolean }).selected),
|
||||
[nodes]
|
||||
@@ -551,56 +432,82 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef.current?.pasteAtViewportCenter?.()
|
||||
}, [])
|
||||
|
||||
const connectionPathNodeIds = useMemo(
|
||||
() =>
|
||||
getPathNodeIds(
|
||||
edges,
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
connectionPathPausedNodeIds
|
||||
),
|
||||
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
||||
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]
|
||||
)
|
||||
|
||||
const connectionPathPausedSegmentNodeIds = useMemo(
|
||||
() =>
|
||||
getPausedSegmentNodeIds(
|
||||
edges,
|
||||
connectionPathNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
connectionPathPausedNodeIds
|
||||
),
|
||||
[edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
||||
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 connectionPathActiveSegmentNodeIds = useMemo(() => {
|
||||
const active = new Set(connectionPathNodeIds)
|
||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
||||
return active
|
||||
}, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds])
|
||||
|
||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
||||
}, [])
|
||||
|
||||
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId))
|
||||
}, [])
|
||||
|
||||
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))
|
||||
}, [])
|
||||
|
||||
const flowContextValue = useMemo(
|
||||
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(
|
||||
() => ({
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
renamingNodeId,
|
||||
setRenamingNodeId,
|
||||
connectionFrom,
|
||||
@@ -609,26 +516,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef,
|
||||
fullscreenNodeId,
|
||||
setFullscreenNodeId,
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
addConnectionPathTrigger,
|
||||
connectionPathNodeIds,
|
||||
connectionPathPausedSegmentNodeIds,
|
||||
connectionPathActiveSegmentNodeIds,
|
||||
connectionPathPausedNodeIds,
|
||||
addConnectionPathPausedNode,
|
||||
removeConnectionPathPausedNode,
|
||||
connectionPathErrorNodeIds,
|
||||
addConnectionPathError,
|
||||
removeConnectionPathError,
|
||||
startConnectionPathUpdate,
|
||||
endConnectionPathUpdate,
|
||||
}),
|
||||
[
|
||||
nodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
renamingNodeId,
|
||||
setRenamingNodeId,
|
||||
connectionFrom,
|
||||
@@ -637,23 +526,50 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef,
|
||||
fullscreenNodeId,
|
||||
setFullscreenNodeId,
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
addConnectionPathTrigger,
|
||||
connectionPathNodeIds,
|
||||
connectionPathPausedSegmentNodeIds,
|
||||
connectionPathActiveSegmentNodeIds,
|
||||
connectionPathPausedNodeIds,
|
||||
addConnectionPathPausedNode,
|
||||
removeConnectionPathPausedNode,
|
||||
connectionPathErrorNodeIds,
|
||||
addConnectionPathError,
|
||||
removeConnectionPathError,
|
||||
startConnectionPathUpdate,
|
||||
endConnectionPathUpdate,
|
||||
]
|
||||
)
|
||||
|
||||
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')) {
|
||||
@@ -674,8 +590,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const clientX = click?.clientX ?? window.innerWidth / 2
|
||||
const clientY = click?.clientY ?? window.innerHeight / 2
|
||||
try {
|
||||
const inst = rfInstance as { screenToFlowPosition?: (p: { x: number; y: number }) => { x: number; y: number }; project?: (p: { x: number; y: number }) => { x: number; y: number } }
|
||||
const screenToFlow = inst.screenToFlowPosition ?? inst.project
|
||||
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 {
|
||||
@@ -777,36 +694,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
<div role="status" aria-live="polite" aria-atomic className="sr-only">
|
||||
{ariaAnnouncement}
|
||||
</div>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".json,.zui.json,application/json"
|
||||
className="hidden"
|
||||
onChange={onImportFileChange}
|
||||
aria-hidden
|
||||
/>
|
||||
<CanvasMenubar
|
||||
onImport={handleImportProject}
|
||||
onExport={handleExportProject}
|
||||
undo={undo}
|
||||
redo={redo}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onDuplicate={handleDuplicate}
|
||||
onCopy={handleCopy}
|
||||
onPaste={handlePaste}
|
||||
canDuplicate={selectedNodes.length > 0}
|
||||
canCopy={selectedNodes.length === 1}
|
||||
onFitView={() => flowActionsRef.current?.fitView?.()}
|
||||
/>
|
||||
{apiTodosCount !== null && (
|
||||
<div className="shrink-0 px-3 py-1 text-xs text-muted-foreground border-b border-border/50">
|
||||
Backend API: {apiTodosCount >= 0 ? `${apiTodosCount} todos` : 'unavailable'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 relative flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<FlowContext.Provider value={flowContextValue}>
|
||||
<GraphContext.Provider value={graphContextValue}>
|
||||
<ConnectionPathContext.Provider value={connectionPathContextValue}>
|
||||
<FlowUIContext.Provider value={flowUIContextValue}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
@@ -827,11 +719,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
<EmptyTitle>Start adding a new node!</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Right‑click to add nodes. <br />
|
||||
Import a project or paste a node.
|
||||
Import a recollection or paste a node.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
|
||||
<Button onClick={handleImportProject} variant="outline" size="sm">
|
||||
<Button onClick={onImport} variant="outline" size="sm">
|
||||
<FolderOpen className="size-4" />
|
||||
Import…
|
||||
</Button>
|
||||
@@ -845,15 +737,14 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
</div>
|
||||
)}
|
||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||
<FlowFitViewOnLoad />
|
||||
<FlowKeyboardShortcuts />
|
||||
<ReactFlow
|
||||
<ViewportDisplayProvider>
|
||||
<FlowFitViewOnLoad disabled={Boolean(focusNodeId)} />
|
||||
<FocusNodeOnLoad focusNodeId={focusNodeId} />
|
||||
<FlowKeyboardShortcuts />
|
||||
<ReactFlow
|
||||
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
||||
nodes={nodes.map((n) => ({
|
||||
...n,
|
||||
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
|
||||
}))}
|
||||
edges={edges}
|
||||
nodes={nodesForFlow}
|
||||
edges={edgesForFlow}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
@@ -877,6 +768,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
fitView
|
||||
onInit={onInit}
|
||||
nodeDragThreshold={1}
|
||||
onlyRenderVisibleElements
|
||||
nodeOrigin={[0, 0]}
|
||||
nodesDraggable
|
||||
nodesConnectable
|
||||
elementsSelectable
|
||||
@@ -891,48 +784,17 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
<div role="group" aria-label="Canvas controls: zoom and fit view">
|
||||
<Controls />
|
||||
</div>
|
||||
{showMinimap && (
|
||||
{showMinimap && nodes.length > 5 && (
|
||||
<div role="region" aria-label="Minimap: overview of the graph">
|
||||
<MiniMap />
|
||||
</div>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</ViewportDisplayProvider>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48" aria-label="Canvas menu: create node or paste">
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-44">
|
||||
{getRegisteredNodeTypesGroupedByClassification().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={() => createNode(desc.id)}>
|
||||
{desc.menuIcon}
|
||||
{desc.menuLabel}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuGroup>
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => pasteNode()}>
|
||||
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
<CanvasContextMenuContent onCreateNode={createNode} onPaste={pasteNode} />
|
||||
</ContextMenu>
|
||||
{fullscreenNodeId && (
|
||||
<FullscreenNodeOverlay
|
||||
@@ -941,7 +803,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
onClose={() => setFullscreenNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
</FlowContext.Provider>
|
||||
</FlowUIContext.Provider>
|
||||
</ConnectionPathContext.Provider>
|
||||
</GraphContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Route wrapper for the canvas: resolves projectId from URL and updates lastEditedAt on open.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CanvasPage } from './CanvasPage'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
|
||||
export function CanvasRoute() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { projects, updateLastEdited } = usePlatform()
|
||||
|
||||
const project = projects.find((p) => p.id === projectId)
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) updateLastEdited(projectId)
|
||||
}, [projectId, updateLastEdited])
|
||||
|
||||
if (!projectId) return null
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
||||
<p className="text-sm text-muted-foreground">Project not found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<CanvasPage key={projectId} projectId={projectId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
* @see https://reactflow.dev/examples/interaction/contextual-zoom
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { useViewport } from '@xyflow/react'
|
||||
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'
|
||||
|
||||
@@ -86,8 +86,8 @@ export function createContextualNode<P extends NodeProps>(
|
||||
Inner: React.ComponentType<P>
|
||||
): React.ComponentType<P> {
|
||||
function ContextualZoomNode(props: P) {
|
||||
const { zoom } = useViewport()
|
||||
const showCompact = zoom <= CONTEXTUAL_ZOOM_THRESHOLD
|
||||
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 }
|
||||
|
||||
55
frontend/src/app/canvas/ViewportDisplayContext.tsx
Normal file
55
frontend/src/app/canvas/ViewportDisplayContext.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
frontend/src/app/canvas/canvasGraphUtils.ts
Normal file
84
frontend/src/app/canvas/canvasGraphUtils.ts
Normal 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()
|
||||
}
|
||||
44
frontend/src/app/canvas/canvasStore.index.ts
Normal file
44
frontend/src/app/canvas/canvasStore.index.ts
Normal 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'
|
||||
122
frontend/src/app/canvas/canvasStore.reducer.ts
Normal file
122
frontend/src/app/canvas/canvasStore.reducer.ts
Normal 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 }
|
||||
}
|
||||
205
frontend/src/app/canvas/canvasStore.selectors.ts
Normal file
205
frontend/src/app/canvas/canvasStore.selectors.ts
Normal 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
|
||||
}
|
||||
504
frontend/src/app/canvas/canvasStore.test.ts
Normal file
504
frontend/src/app/canvas/canvasStore.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
74
frontend/src/app/canvas/canvasStore.ts
Normal file
74
frontend/src/app/canvas/canvasStore.ts
Normal 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 }
|
||||
66
frontend/src/app/canvas/canvasStore.types.ts
Normal file
66
frontend/src/app/canvas/canvasStore.types.ts
Normal 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 }
|
||||
155
frontend/src/app/canvas/useCanvasConnectionPath.ts
Normal file
155
frontend/src/app/canvas/useCanvasConnectionPath.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
128
frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts
Normal file
128
frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts
Normal 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
|
||||
)
|
||||
}
|
||||
74
frontend/src/app/canvas/useCanvasGraph.ts
Normal file
74
frontend/src/app/canvas/useCanvasGraph.ts
Normal 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 }
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Keroma page. Rendered at /keroma.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export function KeromaPage() {
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col min-h-0 p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance font-serif">
|
||||
Keroma
|
||||
</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-4 text-card-foreground shadow-sm">
|
||||
<p className="text-muted-foreground">Welcome to Keroma.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
/**
|
||||
* Platform context: projects list and handlers for create/delete/rename/updateLastEdited/reorder/restore.
|
||||
* Used by AppSidebar, ProjectsTablePage, and canvas route.
|
||||
* 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 { Project } from './types'
|
||||
import { saveGraphToStorage } from '@/app/pleroma/projectGraphStorage'
|
||||
import { PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
||||
import type { Recollection } from './types'
|
||||
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||
|
||||
const STORAGE_KEY = 'zui_platform_projects'
|
||||
const ORDER_STORAGE_KEY = 'zui_platform_project_order'
|
||||
const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids'
|
||||
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
|
||||
@@ -72,8 +75,15 @@ function saveShowMinimap(value: boolean) {
|
||||
|
||||
function loadOrder(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(ORDER_STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
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 {
|
||||
@@ -87,8 +97,15 @@ function saveOrder(ids: string[]) {
|
||||
|
||||
function loadRecentIds(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
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 {
|
||||
@@ -100,41 +117,48 @@ function saveRecentIds(ids: string[]) {
|
||||
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids))
|
||||
}
|
||||
|
||||
function loadProjects(): Project[] {
|
||||
function loadRecollections(): Recollection[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
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 Project =>
|
||||
(p): p is Recollection =>
|
||||
p &&
|
||||
typeof p === 'object' &&
|
||||
typeof (p as Project).id === 'string' &&
|
||||
typeof (p as Project).name === 'string' &&
|
||||
typeof (p as Project).iconId === 'string' &&
|
||||
typeof (p as Project).createdAt === 'number'
|
||||
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 Project).lastEditedAt === 'number' ? (p as Project).lastEditedAt : p.createdAt,
|
||||
lastEditedAt: typeof (p as Recollection).lastEditedAt === 'number' ? (p as Recollection).lastEditedAt : p.createdAt,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveProjects(projects: Project[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
|
||||
function saveRecollections(recollections: Recollection[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(recollections))
|
||||
}
|
||||
|
||||
export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] }
|
||||
|
||||
/** Projects sorted by projectOrder, then by lastEditedAt desc for any not in order */
|
||||
export function sortProjectsByOrder(projects: Project[], order: string[]): Project[] {
|
||||
const byId = new Map(projects.map((p) => [p.id, p]))
|
||||
const ordered: Project[] = []
|
||||
/** 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)
|
||||
@@ -143,27 +167,27 @@ export function sortProjectsByOrder(projects: Project[], order: string[]): Proje
|
||||
seen.add(id)
|
||||
}
|
||||
}
|
||||
const rest = projects
|
||||
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 = {
|
||||
projects: Project[]
|
||||
projectOrder: string[]
|
||||
/** Projects in display order (sidebar order, then lastEditedAt) */
|
||||
orderedProjects: Project[]
|
||||
/** Last RECENT_MAX accessed project IDs (most recent first) */
|
||||
recentProjectIds: string[]
|
||||
recordProjectAccess: (id: string) => void
|
||||
persist: (next: Project[]) => void
|
||||
createProject: (project: Project) => void
|
||||
deleteProject: (id: string) => void
|
||||
renameProject: (id: string, name: string) => void
|
||||
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
|
||||
reorderProjects: (orderedIds: string[]) => void
|
||||
restoreProject: (project: Project, graphSnapshot: GraphSnapshot | null) => void
|
||||
reorderRecollections: (orderedIds: string[]) => void
|
||||
restoreRecollection: (recollection: Recollection, graphSnapshot: GraphSnapshot | null) => void
|
||||
/** Canvas: show React Flow minimap (persisted) */
|
||||
showMinimap: boolean
|
||||
setShowMinimap: (value: boolean) => void
|
||||
@@ -175,9 +199,9 @@ export type KosmosContextValue = {
|
||||
const KosmosContext = createContext<KosmosContextValue | null>(null)
|
||||
|
||||
export function KosmosProvider({ children }: { children: React.ReactNode }) {
|
||||
const [projects, setProjects] = useState<Project[]>(loadProjects)
|
||||
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
|
||||
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
|
||||
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)
|
||||
|
||||
@@ -191,128 +215,128 @@ export function KosmosProvider({ children }: { children: React.ReactNode }) {
|
||||
saveAiConnection(value)
|
||||
}, [])
|
||||
|
||||
const persist = useCallback((next: Project[]) => {
|
||||
setProjects(next)
|
||||
saveProjects(next)
|
||||
const persist = useCallback((next: Recollection[]) => {
|
||||
setRecollections(next)
|
||||
saveRecollections(next)
|
||||
}, [])
|
||||
|
||||
const createProject = useCallback(
|
||||
(project: Project) => {
|
||||
const withEdited = { ...project, lastEditedAt: project.createdAt }
|
||||
persist([...projects, withEdited])
|
||||
setProjectOrder((prev) => {
|
||||
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
|
||||
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
|
||||
})
|
||||
},
|
||||
[projects, persist]
|
||||
[recollections, persist]
|
||||
)
|
||||
|
||||
const deleteProject = useCallback(
|
||||
const deleteRecollection = useCallback(
|
||||
(id: string) => {
|
||||
const next = projects.filter((p) => p.id !== id)
|
||||
const next = recollections.filter((p) => p.id !== id)
|
||||
persist(next)
|
||||
setProjectOrder((prev) => {
|
||||
setRecollectionOrder((prev) => {
|
||||
const nextOrder = prev.filter((oid) => oid !== id)
|
||||
saveOrder(nextOrder)
|
||||
return nextOrder
|
||||
})
|
||||
setRecentProjectIds((prev) => {
|
||||
setRecentRecollectionIds((prev) => {
|
||||
const next = prev.filter((oid) => oid !== id)
|
||||
saveRecentIds(next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[projects, persist]
|
||||
[recollections, persist]
|
||||
)
|
||||
|
||||
const renameProject = useCallback(
|
||||
const renameRecollection = useCallback(
|
||||
(id: string, name: string) => {
|
||||
const next = projects.map((p) => (p.id === id ? { ...p, name } : p))
|
||||
const next = recollections.map((p) => (p.id === id ? { ...p, name } : p))
|
||||
persist(next)
|
||||
},
|
||||
[projects, persist]
|
||||
[recollections, persist]
|
||||
)
|
||||
|
||||
const updateLastEdited = useCallback(
|
||||
(id: string) => {
|
||||
const now = Date.now()
|
||||
const next = projects.map((p) => (p.id === id ? { ...p, lastEditedAt: now } : p))
|
||||
const next = recollections.map((p) => (p.id === id ? { ...p, lastEditedAt: now } : p))
|
||||
persist(next)
|
||||
},
|
||||
[projects, persist]
|
||||
[recollections, persist]
|
||||
)
|
||||
|
||||
const reorderProjects = useCallback((orderedIds: string[]) => {
|
||||
setProjectOrder(orderedIds)
|
||||
const reorderRecollections = useCallback((orderedIds: string[]) => {
|
||||
setRecollectionOrder(orderedIds)
|
||||
saveOrder(orderedIds)
|
||||
}, [])
|
||||
|
||||
const recordProjectAccess = useCallback((id: string) => {
|
||||
setRecentProjectIds((prev) => {
|
||||
const recordRecollectionAccess = useCallback((id: string) => {
|
||||
setRecentRecollectionIds((prev) => {
|
||||
const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX)
|
||||
saveRecentIds(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const restoreProject = useCallback(
|
||||
(project: Project, graphSnapshot: GraphSnapshot | null) => {
|
||||
persist([...projects, project])
|
||||
setProjectOrder((prev) => {
|
||||
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
|
||||
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(project.id, {
|
||||
version: PROJECT_VERSION,
|
||||
saveGraphToStorage(recollection.id, {
|
||||
version: RECOLLECTION_VERSION,
|
||||
nodes: graphSnapshot.nodes,
|
||||
edges: graphSnapshot.edges,
|
||||
})
|
||||
}
|
||||
},
|
||||
[projects, persist]
|
||||
[recollections, persist]
|
||||
)
|
||||
|
||||
const orderedProjects = useMemo(
|
||||
() => sortProjectsByOrder(projects, projectOrder),
|
||||
[projects, projectOrder]
|
||||
const orderedRecollections = useMemo(
|
||||
() => sortRecollectionsByOrder(recollections, recollectionOrder),
|
||||
[recollections, recollectionOrder]
|
||||
)
|
||||
|
||||
const value: KosmosContextValue = useMemo(
|
||||
() => ({
|
||||
projects,
|
||||
projectOrder,
|
||||
orderedProjects,
|
||||
recentProjectIds,
|
||||
recordProjectAccess,
|
||||
recollections,
|
||||
recollectionOrder,
|
||||
orderedRecollections,
|
||||
recentRecollectionIds,
|
||||
recordRecollectionAccess,
|
||||
persist,
|
||||
createProject,
|
||||
deleteProject,
|
||||
renameProject,
|
||||
createRecollection,
|
||||
deleteRecollection,
|
||||
renameRecollection,
|
||||
updateLastEdited,
|
||||
reorderProjects,
|
||||
restoreProject,
|
||||
reorderRecollections,
|
||||
restoreRecollection,
|
||||
showMinimap,
|
||||
setShowMinimap,
|
||||
aiConnection,
|
||||
setAiConnection,
|
||||
}),
|
||||
[
|
||||
projects,
|
||||
projectOrder,
|
||||
orderedProjects,
|
||||
recentProjectIds,
|
||||
recordProjectAccess,
|
||||
recollections,
|
||||
recollectionOrder,
|
||||
orderedRecollections,
|
||||
recentRecollectionIds,
|
||||
recordRecollectionAccess,
|
||||
persist,
|
||||
createProject,
|
||||
deleteProject,
|
||||
renameProject,
|
||||
createRecollection,
|
||||
deleteRecollection,
|
||||
renameRecollection,
|
||||
updateLastEdited,
|
||||
reorderProjects,
|
||||
restoreProject,
|
||||
reorderRecollections,
|
||||
restoreRecollection,
|
||||
showMinimap,
|
||||
setShowMinimap,
|
||||
aiConnection,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Platform: main layout with sidebar and header. Renders child routes (projects list or canvas) via Outlet.
|
||||
* Platform: main layout with sidebar and header. Renders child routes (recollections list or canvas) via Outlet.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
@@ -9,17 +9,19 @@ import { KosmosProvider, usePlatform } from './KosmosContext'
|
||||
import { KosmosSidebar } from './KosmosSidebar'
|
||||
|
||||
function KosmosLayoutInner() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { recordProjectAccess } = usePlatform()
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { recordRecollectionAccess } = usePlatform()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) recordProjectAccess(projectId)
|
||||
}, [projectId, recordProjectAccess])
|
||||
if (recollectionId) recordRecollectionAccess(recollectionId)
|
||||
}, [recollectionId, recordRecollectionAccess])
|
||||
|
||||
const showSidebar = !recollectionId
|
||||
|
||||
return (
|
||||
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<KosmosSidebar />
|
||||
{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 />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Platform sidebar: All projects, Recently used, New project.
|
||||
* Platform sidebar: Recollections, Recently used, New recollection.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
@@ -17,36 +17,34 @@ import {
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Plus, Settings, Triangle } from 'lucide-react'
|
||||
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 { getProjectIcon } from '@/lib/iconMap'
|
||||
import { NewProjectDialog } from './NewProjectDialog'
|
||||
import { getRecollectionIcon } from '@/lib/iconMap'
|
||||
import { RecollectionsIcon } from '@/lib/icons'
|
||||
import { NewRecollectionDialog } from './NewRecollectionDialog'
|
||||
import { SettingsDialog } from './SettingsDialog'
|
||||
import type { Project } from './types'
|
||||
import type { Recollection } from './types'
|
||||
|
||||
export function KosmosSidebar() {
|
||||
const { state, setOpen } = useSidebar()
|
||||
const isCollapsed = state === 'collapsed'
|
||||
const { orderedProjects, createProject, recentProjectIds } = usePlatform()
|
||||
const recentProjects = useMemo(() => {
|
||||
const byId = new Map(orderedProjects.map((p) => [p.id, p]))
|
||||
return recentProjectIds.map((id) => byId.get(id)).filter((p): p is Project => p != null)
|
||||
}, [orderedProjects, recentProjectIds])
|
||||
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 { projectId: selectedProjectId } = useParams<{ projectId: string }>()
|
||||
const isKeroma = location.pathname === '/keroma'
|
||||
const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
|
||||
|
||||
const handleSelectProject = useCallback((id: string) => navigate(`/projects/${id}`), [navigate])
|
||||
|
||||
const handleCreateProject = useCallback(
|
||||
(project: Parameters<typeof createProject>[0]) => {
|
||||
createProject(project)
|
||||
navigate(`/projects/${project.id}`)
|
||||
const handleCreateRecollection = useCallback(
|
||||
(recollection: Parameters<typeof createRecollection>[0]) => {
|
||||
createRecollection(recollection)
|
||||
navigate(`/recollections/${recollection.id}`)
|
||||
},
|
||||
[createProject, navigate]
|
||||
[createRecollection, navigate]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -99,8 +97,8 @@ export function KosmosSidebar() {
|
||||
</Tooltip>
|
||||
) : (
|
||||
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
|
||||
<Link to="/projects">
|
||||
<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">
|
||||
<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={{
|
||||
@@ -132,39 +130,29 @@ export function KosmosSidebar() {
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="All projects" isActive={location.pathname === '/projects' && !selectedProjectId}>
|
||||
<Link to="/projects">
|
||||
<Triangle className="size-4" />
|
||||
<span>Pleroma</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Keroma" isActive={isKeroma}>
|
||||
<Link to="/keroma">
|
||||
<Triangle className="size-4 rotate-180" />
|
||||
<span>Keroma</span>
|
||||
<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>
|
||||
{recentProjects.length > 0 && (
|
||||
{recentRecollections.length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{recentProjects.map((project) => {
|
||||
const Icon = getProjectIcon(project.iconId)
|
||||
const isActive = selectedProjectId === project.id
|
||||
{recentRecollections.map((recollection) => {
|
||||
const Icon = getRecollectionIcon(recollection.iconId)
|
||||
const isActive = selectedRecollectionId === recollection.id
|
||||
return (
|
||||
<SidebarMenuItem key={project.id}>
|
||||
<SidebarMenuButton
|
||||
tooltip={project.name}
|
||||
isActive={isActive}
|
||||
onClick={() => handleSelectProject(project.id)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{project.name}</span>
|
||||
<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>
|
||||
)
|
||||
@@ -175,13 +163,13 @@ export function KosmosSidebar() {
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<NewProjectDialog
|
||||
onCreate={handleCreateProject}
|
||||
existingNames={orderedProjects.map((p) => p.name)}
|
||||
<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>{orderedProjects.length === 0 ? 'Create your first project' : 'New project'}</span>
|
||||
<span>{orderedRecollections.length === 0 ? 'Create your first recollection' : 'New recollection'}</span>
|
||||
</SidebarMenuButton>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Dialog to create a new project: name + icon.
|
||||
* Dialog to create a new recollection: name + icon.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react'
|
||||
@@ -16,20 +16,20 @@ 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 { PROJECT_ICON_IDS, type Project, type ProjectIconId } from './types'
|
||||
import { getProjectIcon } from '@/lib/iconMap'
|
||||
import { RECOLLECTION_ICON_IDS, type Recollection, type RecollectionIconId } from './types'
|
||||
import { getRecollectionIcon } from '@/lib/iconMap'
|
||||
|
||||
type NewProjectDialogProps = {
|
||||
onCreate: (project: Project) => void
|
||||
type NewRecollectionDialogProps = {
|
||||
onCreate: (recollection: Recollection) => void
|
||||
trigger?: React.ReactNode
|
||||
/** Other project names to check for duplicates (case-insensitive warning only) */
|
||||
/** Other recollection names to check for duplicates (case-insensitive warning only) */
|
||||
existingNames?: string[]
|
||||
}
|
||||
|
||||
export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewProjectDialogProps) {
|
||||
export function NewRecollectionDialog({ onCreate, trigger, existingNames = [] }: NewRecollectionDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [iconId, setIconId] = useState<ProjectIconId>('cat')
|
||||
const [iconId, setIconId] = useState<RecollectionIconId>('cat')
|
||||
|
||||
const trimmed = name.trim()
|
||||
const isDuplicate = trimmed.length > 0 && existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())
|
||||
@@ -37,13 +37,13 @@ export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewP
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!trimmed) return
|
||||
const project: Project = {
|
||||
id: `proj_${Date.now()}`,
|
||||
const recollection: Recollection = {
|
||||
id: `recollection_${Date.now()}`,
|
||||
name: trimmed,
|
||||
iconId,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
onCreate(project)
|
||||
onCreate(recollection)
|
||||
setName('')
|
||||
setIconId('cat')
|
||||
setOpen(false)
|
||||
@@ -55,41 +55,41 @@ export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewP
|
||||
{trigger ?? (
|
||||
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
|
||||
<Plus className="size-4" />
|
||||
New project
|
||||
New recollection
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New project</DialogTitle>
|
||||
<DialogDescription>Create a project to start editing a graph canvas.</DialogDescription>
|
||||
<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="project-name" className="text-sm font-medium">
|
||||
<label htmlFor="recollection-name" className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
id="project-name"
|
||||
id="recollection-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My project"
|
||||
placeholder="My recollection"
|
||||
autoFocus
|
||||
/>
|
||||
{isDuplicate && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p>
|
||||
<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 ProjectIconId)}>
|
||||
<Select value={iconId} onValueChange={(v) => setIconId(v as RecollectionIconId)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROJECT_ICON_IDS.map((id) => {
|
||||
const Icon = getProjectIcon(id)
|
||||
{RECOLLECTION_ICON_IDS.map((id) => {
|
||||
const Icon = getRecollectionIcon(id)
|
||||
return (
|
||||
<SelectItem key={id} value={id}>
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* Platform types: projects and sidebar state.
|
||||
* Platform types: recollections and sidebar state.
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export type Project = {
|
||||
export type Recollection = {
|
||||
id: string
|
||||
name: string
|
||||
/** Icon identifier: key of PROJECT_ICONS map */
|
||||
/** Icon identifier: key of RECOLLECTION_ICONS map */
|
||||
iconId: string
|
||||
createdAt: number
|
||||
/** Last time the project was opened/edited; used for sorting. Defaults to createdAt if missing. */
|
||||
/** Last time the recollection was opened/edited; used for sorting. Defaults to createdAt if missing. */
|
||||
lastEditedAt?: number
|
||||
}
|
||||
|
||||
/** Project icon ids: Lucide "Animals" category only */
|
||||
export const PROJECT_ICON_IDS = [
|
||||
/** Recollection icon ids: Lucide "Animals" category only */
|
||||
export const RECOLLECTION_ICON_IDS = [
|
||||
'bird',
|
||||
'bug',
|
||||
'cat',
|
||||
@@ -30,4 +30,4 @@ export const PROJECT_ICON_IDS = [
|
||||
'egg'
|
||||
] as const
|
||||
|
||||
export type ProjectIconId = (typeof PROJECT_ICON_IDS)[number]
|
||||
export type RecollectionIconId = (typeof RECOLLECTION_ICON_IDS)[number]
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Per-project graph persistence (localStorage).
|
||||
* Used by CanvasPage to load/save and by ProjectsTablePage for export.
|
||||
*/
|
||||
|
||||
export const PROJECT_FILE_EXT = '.zui.json'
|
||||
export const PROJECT_VERSION = 1
|
||||
|
||||
const GRAPH_KEY_PREFIX = 'zui_graph_'
|
||||
|
||||
export function getGraphStorageKey(projectId: string): string {
|
||||
return `${GRAPH_KEY_PREFIX}${projectId}`
|
||||
}
|
||||
|
||||
export type GraphState = { version: number; nodes: unknown[]; edges: unknown[] }
|
||||
|
||||
export function loadGraphFromStorage(projectId: string): GraphState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(getGraphStorageKey(projectId))
|
||||
if (!raw) return null
|
||||
const data = JSON.parse(raw) as unknown
|
||||
if (!data || typeof data !== 'object' || !Array.isArray((data as GraphState).nodes) || !Array.isArray((data as GraphState).edges))
|
||||
return null
|
||||
return data as GraphState
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveGraphToStorage(projectId: string, state: GraphState): void {
|
||||
localStorage.setItem(getGraphStorageKey(projectId), JSON.stringify(state))
|
||||
}
|
||||
|
||||
export function removeGraphFromStorage(projectId: string): void {
|
||||
localStorage.removeItem(getGraphStorageKey(projectId))
|
||||
}
|
||||
65
frontend/src/app/recollections/RecollectionLayout.tsx
Normal file
65
frontend/src/app/recollections/RecollectionLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Projects list page: table or cards view with search, sort, pagination, actions.
|
||||
* Recollections list page: table or cards view with search, sort, pagination, actions.
|
||||
* Sort by last edited (default), name, or created; configurable page size; export toast; undo delete.
|
||||
*/
|
||||
|
||||
@@ -56,18 +56,20 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { getProjectIcon } from '@/lib/iconMap'
|
||||
import { getRecollectionIcon } from '@/lib/iconMap'
|
||||
import {
|
||||
loadGraphFromStorage,
|
||||
saveGraphToStorage,
|
||||
removeGraphFromStorage,
|
||||
PROJECT_FILE_EXT,
|
||||
PROJECT_VERSION,
|
||||
} from './projectGraphStorage'
|
||||
RECOLLECTION_FILE_EXT,
|
||||
RECOLLECTION_VERSION,
|
||||
} from './state/recollectionGraphStorage'
|
||||
import { getLogosContent, setLogosContent } from './state/recollectionStore'
|
||||
import { toast } from 'sonner'
|
||||
import { NewProjectDialog } from '@/app/kosmos/NewProjectDialog'
|
||||
import { ProjectsPageBackground } from './ProjectsPageBackground'
|
||||
import type { Project } from '@/app/kosmos/types'
|
||||
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
||||
import { RecollectionsPageBackground } from './layout/RecollectionsPageBackground'
|
||||
import { RenameRecollectionDialog } from './layout/RenameRecollectionDialog'
|
||||
import type { Recollection } from '@/app/kosmos/types'
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
||||
type SortKey = 'lastEdited' | 'name' | 'created'
|
||||
@@ -98,8 +100,8 @@ function getRelativeTime(ts: number): string {
|
||||
return 'Just now'
|
||||
}
|
||||
|
||||
function getGraphCounts(projectId: string): { nodes: number; edges: number } {
|
||||
const stored = loadGraphFromStorage(projectId)
|
||||
function getGraphCounts(recollectionId: string): { nodes: number; edges: number } {
|
||||
const stored = loadGraphFromStorage(recollectionId)
|
||||
if (!stored) return { nodes: 0, edges: 0 }
|
||||
return {
|
||||
nodes: Array.isArray(stored.nodes) ? stored.nodes.length : 0,
|
||||
@@ -110,7 +112,7 @@ function getGraphCounts(projectId: string): { nodes: number; edges: number } {
|
||||
type NodeLike = { id: string; position?: { x: number; y: number } }
|
||||
type EdgeLike = { id?: string; source: string; target: string }
|
||||
|
||||
/** Shared grid style for empty thumbnail, non-empty thumbnail SVG, and New project placeholder. */
|
||||
/** Shared grid style for empty thumbnail, non-empty thumbnail SVG, and New recollection placeholder. */
|
||||
const THUMBNAIL_GRID = {
|
||||
baseFill: 'hsl(var(--muted) / 0.4)',
|
||||
dotFill: 'hsl(var(--muted-foreground) / 0.06)',
|
||||
@@ -124,13 +126,13 @@ const thumbnailGridStyle: React.CSSProperties = {
|
||||
}
|
||||
|
||||
/** Renders a minimal SVG preview of the graph from storage, or a placeholder. */
|
||||
function GraphThumbnail({ projectId, className }: { projectId: string; className?: string }) {
|
||||
const stored = loadGraphFromStorage(projectId)
|
||||
function GraphThumbnail({ recollectionId, className }: { recollectionId: string; className?: string }) {
|
||||
const stored = loadGraphFromStorage(recollectionId)
|
||||
const nodes = (stored?.nodes ?? []) as NodeLike[]
|
||||
const edges = (stored?.edges ?? []) as EdgeLike[]
|
||||
const withPos = nodes.filter((n) => n.position && typeof n.position.x === 'number' && typeof n.position.y === 'number')
|
||||
|
||||
const dotGridPatternId = `dotgrid-${projectId.replace(/\W/g, '-')}`
|
||||
const dotGridPatternId = `dotgrid-${recollectionId.replace(/\W/g, '-')}`
|
||||
|
||||
if (withPos.length === 0) {
|
||||
return (
|
||||
@@ -207,47 +209,47 @@ function GraphThumbnail({ projectId, className }: { projectId: string; className
|
||||
)
|
||||
}
|
||||
|
||||
type ProjectActionsMenuProps = {
|
||||
project: Project
|
||||
type RecollectionActionsMenuProps = {
|
||||
recollection: Recollection
|
||||
onOpen: (id: string) => void
|
||||
onRenameOpen: (project: Project) => void
|
||||
onDuplicateOpen: (project: Project) => void
|
||||
onExport: (project: Project) => void
|
||||
onDeleteOpen: (project: Project) => void
|
||||
onRenameOpen: (recollection: Recollection) => void
|
||||
onDuplicateOpen: (recollection: Recollection) => void
|
||||
onExport: (recollection: Recollection) => void
|
||||
onDeleteOpen: (recollection: Recollection) => void
|
||||
trigger: React.ReactNode
|
||||
}
|
||||
|
||||
function ProjectActionsMenu({
|
||||
project,
|
||||
function RecollectionActionsMenu({
|
||||
recollection,
|
||||
onOpen,
|
||||
onRenameOpen,
|
||||
onDuplicateOpen,
|
||||
onExport,
|
||||
onDeleteOpen,
|
||||
trigger,
|
||||
}: ProjectActionsMenuProps) {
|
||||
}: RecollectionActionsMenuProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onOpen(project.id)}>
|
||||
<DropdownMenuItem onClick={() => onOpen(recollection.id)}>
|
||||
<FolderOpen className="size-4" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onRenameOpen(project)}>
|
||||
<DropdownMenuItem onClick={() => onRenameOpen(recollection)}>
|
||||
<Pencil className="size-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onDuplicateOpen(project)}>
|
||||
<DropdownMenuItem onClick={() => onDuplicateOpen(recollection)}>
|
||||
<Copy className="size-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onExport(project)}>
|
||||
<DropdownMenuItem onClick={() => onExport(recollection)}>
|
||||
<Download className="size-4" />
|
||||
Export
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => onDeleteOpen(project)}>
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => onDeleteOpen(recollection)}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -258,8 +260,11 @@ function ProjectActionsMenu({
|
||||
|
||||
export type ViewMode = 'table' | 'cards'
|
||||
|
||||
export function ProjectsPage() {
|
||||
const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform()
|
||||
const RECOLLECTIONS_SCROLL_KEY = 'recollections-list-scroll'
|
||||
|
||||
export function RecollectionsPage() {
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
||||
const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform()
|
||||
const navigate = useNavigate()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -267,21 +272,19 @@ export function ProjectsPage() {
|
||||
const [page, setPage] = useState(0)
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastEdited')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||
const [renameTarget, setRenameTarget] = useState<Project | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
||||
const [duplicateTarget, setDuplicateTarget] = useState<Project | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<Recollection | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<Recollection | null>(null)
|
||||
const [duplicateTarget, setDuplicateTarget] = useState<Recollection | null>(null)
|
||||
const [duplicateName, setDuplicateName] = useState('')
|
||||
const duplicateInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Project[] | null>(null)
|
||||
const [bulkDeleteTargets, setBulkDeleteTargets] = useState<Recollection[] | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return orderedProjects
|
||||
return orderedProjects.filter((p) => p.name.toLowerCase().includes(q))
|
||||
}, [orderedProjects, search])
|
||||
if (!q) return orderedRecollections
|
||||
return orderedRecollections.filter((p) => p.name.toLowerCase().includes(q))
|
||||
}, [orderedRecollections, search])
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const arr = [...filtered]
|
||||
@@ -312,24 +315,16 @@ export function ProjectsPage() {
|
||||
}
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(projectId: string) => {
|
||||
navigate(`/projects/${projectId}`)
|
||||
(recollectionId: string) => {
|
||||
navigate(`/recollections/${recollectionId}`)
|
||||
},
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const handleRenameOpen = useCallback((project: Project) => {
|
||||
setRenameTarget(project)
|
||||
setRenameValue(project.name)
|
||||
const handleRenameOpen = useCallback((recollection: Recollection) => {
|
||||
setRenameTarget(recollection)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (renameTarget) {
|
||||
const t = setTimeout(() => renameInputRef.current?.focus(), 0)
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
}, [renameTarget])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (duplicateTarget) {
|
||||
const t = setTimeout(() => duplicateInputRef.current?.focus(), 0)
|
||||
@@ -337,49 +332,63 @@ export function ProjectsPage() {
|
||||
}
|
||||
}, [duplicateTarget])
|
||||
|
||||
const handleRenameSubmit = useCallback(() => {
|
||||
if (renameTarget && renameValue.trim()) {
|
||||
renameProject(renameTarget.id, renameValue.trim())
|
||||
setRenameTarget(null)
|
||||
setRenameValue('')
|
||||
// Restore scroll position when returning to the list
|
||||
React.useEffect(() => {
|
||||
const el = scrollContainerRef.current
|
||||
if (!el) return
|
||||
try {
|
||||
const saved = sessionStorage.getItem(RECOLLECTIONS_SCROLL_KEY)
|
||||
if (saved !== null) {
|
||||
const top = parseInt(saved, 10)
|
||||
if (Number.isFinite(top)) requestAnimationFrame(() => { el.scrollTop = top })
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [renameTarget, renameValue, renameProject])
|
||||
return () => {
|
||||
try {
|
||||
sessionStorage.setItem(RECOLLECTIONS_SCROLL_KEY, String(el.scrollTop))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDeleteOpen = useCallback((project: Project) => {
|
||||
setDeleteTarget(project)
|
||||
const handleDeleteOpen = useCallback((recollection: Recollection) => {
|
||||
setDeleteTarget(recollection)
|
||||
}, [])
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (!deleteTarget) return
|
||||
const project = deleteTarget
|
||||
const graphSnapshot = loadGraphFromStorage(project.id)
|
||||
const recollection = deleteTarget
|
||||
const graphSnapshot = loadGraphFromStorage(recollection.id)
|
||||
const snapshot =
|
||||
graphSnapshot && (graphSnapshot.nodes.length > 0 || graphSnapshot.edges.length > 0)
|
||||
? { nodes: graphSnapshot.nodes, edges: graphSnapshot.edges }
|
||||
: null
|
||||
removeGraphFromStorage(project.id)
|
||||
deleteProject(project.id)
|
||||
removeGraphFromStorage(recollection.id)
|
||||
deleteRecollection(recollection.id)
|
||||
setDeleteTarget(null)
|
||||
navigate('/projects', { replace: true })
|
||||
toast(`"${project.name}" deleted`, {
|
||||
navigate('/recollections', { replace: true })
|
||||
toast(`"${recollection.name}" deleted`, {
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: () => restoreProject(project, snapshot),
|
||||
onClick: () => restoreRecollection(recollection, snapshot),
|
||||
},
|
||||
duration: 8000,
|
||||
})
|
||||
}, [deleteTarget, deleteProject, navigate, restoreProject])
|
||||
}, [deleteTarget, deleteRecollection, navigate, restoreRecollection])
|
||||
|
||||
const handleExport = useCallback(
|
||||
(project: Project) => {
|
||||
const stored = loadGraphFromStorage(project.id)
|
||||
(recollection: Recollection) => {
|
||||
const stored = loadGraphFromStorage(recollection.id)
|
||||
const state = stored
|
||||
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
||||
? { version: RECOLLECTION_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||
: { version: RECOLLECTION_VERSION, nodes: [], edges: [] }
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
const filename = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
|
||||
const filename = `${recollection.name.replace(/[^\w.-]/g, '_')}${RECOLLECTION_FILE_EXT}`
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
@@ -389,40 +398,44 @@ export function ProjectsPage() {
|
||||
[]
|
||||
)
|
||||
|
||||
const handleCreateProject = useCallback(
|
||||
(project: Project) => {
|
||||
createProject(project)
|
||||
navigate(`/projects/${project.id}`)
|
||||
const handleCreateRecollection = useCallback(
|
||||
(recollection: Recollection) => {
|
||||
createRecollection(recollection)
|
||||
navigate(`/recollections/${recollection.id}`)
|
||||
},
|
||||
[createProject, navigate]
|
||||
[createRecollection, navigate]
|
||||
)
|
||||
|
||||
const handleDuplicateOpen = useCallback((project: Project) => {
|
||||
setDuplicateTarget(project)
|
||||
setDuplicateName(`${project.name} (copy)`)
|
||||
const handleDuplicateOpen = useCallback((recollection: Recollection) => {
|
||||
setDuplicateTarget(recollection)
|
||||
setDuplicateName(`${recollection.name} (copy)`)
|
||||
}, [])
|
||||
|
||||
const handleDuplicateConfirm = useCallback(() => {
|
||||
if (!duplicateTarget || !duplicateName.trim()) return
|
||||
const name = duplicateName.trim()
|
||||
const newId = `proj_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const newId = `recollection_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`
|
||||
const now = Date.now()
|
||||
const newProject: Project = {
|
||||
const newRecollection: Recollection = {
|
||||
id: newId,
|
||||
name,
|
||||
iconId: duplicateTarget.iconId,
|
||||
createdAt: now,
|
||||
lastEditedAt: now,
|
||||
}
|
||||
createProject(newProject)
|
||||
createRecollection(newRecollection)
|
||||
const graph = loadGraphFromStorage(duplicateTarget.id)
|
||||
if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) {
|
||||
saveGraphToStorage(newId, { version: PROJECT_VERSION, nodes: graph.nodes, edges: graph.edges })
|
||||
saveGraphToStorage(newId, { version: RECOLLECTION_VERSION, nodes: graph.nodes, edges: graph.edges })
|
||||
}
|
||||
toast.success('Project duplicated')
|
||||
const logosContent = getLogosContent(duplicateTarget.id)
|
||||
if (logosContent && logosContent.length > 0) {
|
||||
setLogosContent(newId, logosContent)
|
||||
}
|
||||
toast.success('Recollection duplicated')
|
||||
setDuplicateTarget(null)
|
||||
setDuplicateName('')
|
||||
}, [duplicateTarget, duplicateName, createProject])
|
||||
}, [duplicateTarget, duplicateName, createRecollection])
|
||||
|
||||
const allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id))
|
||||
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
|
||||
@@ -462,33 +475,33 @@ export function ProjectsPage() {
|
||||
const handleBulkDeleteConfirm = useCallback(() => {
|
||||
if (!bulkDeleteTargets || bulkDeleteTargets.length === 0) return
|
||||
const count = bulkDeleteTargets.length
|
||||
bulkDeleteTargets.forEach((project) => {
|
||||
removeGraphFromStorage(project.id)
|
||||
deleteProject(project.id)
|
||||
bulkDeleteTargets.forEach((recollection) => {
|
||||
removeGraphFromStorage(recollection.id)
|
||||
deleteRecollection(recollection.id)
|
||||
})
|
||||
setBulkDeleteTargets(null)
|
||||
setSelectedIds(new Set())
|
||||
navigate('/projects', { replace: true })
|
||||
toast(`${count} project${count === 1 ? '' : 's'} deleted`)
|
||||
}, [bulkDeleteTargets, deleteProject, navigate])
|
||||
navigate('/recollections', { replace: true })
|
||||
toast(`${count} recollection${count === 1 ? '' : 's'} deleted`)
|
||||
}, [bulkDeleteTargets, deleteRecollection, navigate])
|
||||
|
||||
const handleBulkExport = useCallback(() => {
|
||||
const toExport = sorted.filter((p) => selectedIds.has(p.id))
|
||||
toExport.forEach((project) => {
|
||||
const stored = loadGraphFromStorage(project.id)
|
||||
toExport.forEach((recollection) => {
|
||||
const stored = loadGraphFromStorage(recollection.id)
|
||||
const state = stored
|
||||
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
||||
? { version: RECOLLECTION_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||
: { version: RECOLLECTION_VERSION, nodes: [], edges: [] }
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
const filename = `${project.name.replace(/[^\w.-]/g, '_')}${PROJECT_FILE_EXT}`
|
||||
const filename = `${recollection.name.replace(/[^\w.-]/g, '_')}${RECOLLECTION_FILE_EXT}`
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
toast.success(`Exported ${toExport.length} project${toExport.length === 1 ? '' : 's'}`)
|
||||
toast.success(`Exported ${toExport.length} recollection${toExport.length === 1 ? '' : 's'}`)
|
||||
}, [sorted, selectedIds])
|
||||
|
||||
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
|
||||
@@ -498,8 +511,8 @@ export function ProjectsPage() {
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 flex-col min-h-0">
|
||||
<ProjectsPageBackground className="absolute inset-0 pointer-events-none" />
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
||||
<RecollectionsPageBackground className="absolute inset-0 pointer-events-none" />
|
||||
<div ref={scrollContainerRef} className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="p-3 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance font-serif">
|
||||
@@ -511,14 +524,14 @@ export function ProjectsPage() {
|
||||
<div className="relative w-64 shrink-0">
|
||||
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search projects"
|
||||
placeholder="Search recollections"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
className="h-9 w-full pl-8"
|
||||
aria-label="Search projects by name"
|
||||
aria-label="Search recollections by name"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex h-9 items-center gap-2">
|
||||
@@ -562,9 +575,9 @@ export function ProjectsPage() {
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<NewProjectDialog
|
||||
onCreate={handleCreateProject}
|
||||
existingNames={orderedProjects.map((p) => p.name)}
|
||||
<NewRecollectionDialog
|
||||
onCreate={handleCreateRecollection}
|
||||
existingNames={orderedRecollections.map((p) => p.name)}
|
||||
trigger={
|
||||
<div
|
||||
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
|
||||
@@ -576,15 +589,15 @@ export function ProjectsPage() {
|
||||
; (e.currentTarget as HTMLElement).click()
|
||||
}
|
||||
}}
|
||||
aria-label="Create new project"
|
||||
aria-label="Create new recollection"
|
||||
>
|
||||
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
|
||||
<Plus className="size-12 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<span className="font-medium text-muted-foreground">New project</span>
|
||||
<span className="font-medium text-muted-foreground">New recollection</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{search.trim() ? 'No projects match your search.' : 'Create a new project'}
|
||||
{search.trim() ? 'No recollections match your search.' : 'Create a new recollection'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -663,42 +676,42 @@ export function ProjectsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="text-xs">
|
||||
{pageItems.map((project) => {
|
||||
const Icon = getProjectIcon(project.iconId)
|
||||
const counts = getGraphCounts(project.id)
|
||||
const lastEdited = project.lastEditedAt ?? project.createdAt
|
||||
const isSelected = selectedIds.has(project.id)
|
||||
{pageItems.map((recollection) => {
|
||||
const Icon = getRecollectionIcon(recollection.iconId)
|
||||
const counts = getGraphCounts(recollection.id)
|
||||
const lastEdited = recollection.lastEditedAt ?? recollection.createdAt
|
||||
const isSelected = selectedIds.has(recollection.id)
|
||||
return (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
key={recollection.id}
|
||||
className={`group cursor-pointer hover:bg-muted/50 h-8 ${isSelected ? 'bg-muted/70' : ''}`}
|
||||
onClick={() => handleOpen(project.id)}
|
||||
onClick={() => handleOpen(recollection.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpen(project.id)
|
||||
handleOpen(recollection.id)
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Open ${project.name}`}
|
||||
aria-label={`Open ${recollection.name}`}
|
||||
>
|
||||
<TableCell className="px-2 py-1.5 w-10 [&:has([role=checkbox])]:pr-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelection(project.id)}
|
||||
aria-label={`Select ${project.name}`}
|
||||
onCheckedChange={() => toggleSelection(recollection.id)}
|
||||
aria-label={`Select ${recollection.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-1.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium truncate">{project.name}</span>
|
||||
<span className="font-medium truncate">{recollection.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell text-muted-foreground px-2 py-1.5 w-[120px] min-w-[100px]">
|
||||
<span className="truncate block" title={formatDate(project.createdAt)}>
|
||||
{formatDate(project.createdAt)}
|
||||
<span className="truncate block" title={formatDate(recollection.createdAt)}>
|
||||
{formatDate(recollection.createdAt)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-muted-foreground px-2 py-1.5 w-[100px] min-w-[80px]" title={`${counts.nodes} nodes, ${counts.edges} edges`}>
|
||||
@@ -716,15 +729,15 @@ export function ProjectsPage() {
|
||||
className={`sticky right-0 w-12 min-w-12 px-1 py-1.5 ${isSelected ? 'bg-muted/70' : 'bg-card group-hover:bg-muted/50'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ProjectActionsMenu
|
||||
project={project}
|
||||
<RecollectionActionsMenu
|
||||
recollection={recollection}
|
||||
onOpen={handleOpen}
|
||||
onRenameOpen={handleRenameOpen}
|
||||
onDuplicateOpen={handleDuplicateOpen}
|
||||
onExport={handleExport}
|
||||
onDeleteOpen={handleDeleteOpen}
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${project.name}`}>
|
||||
<Button variant="ghost" size="icon" className="size-7" aria-label={`Actions for ${recollection.name}`}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
@@ -741,7 +754,7 @@ export function ProjectsPage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {page + 1} of {totalPages}
|
||||
{pageSize !== -1 && ` · ${sorted.length} projects`}
|
||||
{pageSize !== -1 && ` · ${sorted.length} recollections`}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
@@ -787,9 +800,9 @@ export function ProjectsPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<NewProjectDialog
|
||||
onCreate={handleCreateProject}
|
||||
existingNames={orderedProjects.map((p) => p.name)}
|
||||
<NewRecollectionDialog
|
||||
onCreate={handleCreateRecollection}
|
||||
existingNames={orderedRecollections.map((p) => p.name)}
|
||||
trigger={
|
||||
<div
|
||||
className="flex cursor-pointer flex-col overflow-hidden rounded-lg border border-dashed border-muted-foreground/30 bg-muted/20 shadow-sm transition-colors hover:border-muted-foreground/50 hover:bg-muted/30"
|
||||
@@ -801,44 +814,44 @@ export function ProjectsPage() {
|
||||
; (e.currentTarget as HTMLElement).click()
|
||||
}
|
||||
}}
|
||||
aria-label="Create new project"
|
||||
aria-label="Create new recollection"
|
||||
>
|
||||
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
|
||||
<Plus className="size-12 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<span className="font-medium text-muted-foreground">New project</span>
|
||||
<span className="text-xs text-muted-foreground">Create a new project</span>
|
||||
<span className="font-medium text-muted-foreground">New recollection</span>
|
||||
<span className="text-xs text-muted-foreground">Create a new recollection</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{pageItems.map((project) => {
|
||||
const Icon = getProjectIcon(project.iconId)
|
||||
const lastEdited = project.lastEditedAt ?? project.createdAt
|
||||
{pageItems.map((recollection) => {
|
||||
const Icon = getRecollectionIcon(recollection.iconId)
|
||||
const lastEdited = recollection.lastEditedAt ?? recollection.createdAt
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
key={recollection.id}
|
||||
className="group flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow hover:shadow-md"
|
||||
onClick={() => handleOpen(project.id)}
|
||||
onClick={() => handleOpen(recollection.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpen(project.id)
|
||||
handleOpen(recollection.id)
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Open ${project.name}`}
|
||||
aria-label={`Open ${recollection.name}`}
|
||||
>
|
||||
<div className="relative aspect-video w-full shrink-0 overflow-hidden bg-muted">
|
||||
<GraphThumbnail projectId={project.id} className="h-full w-full object-cover" />
|
||||
<GraphThumbnail recollectionId={recollection.id} className="h-full w-full object-cover" />
|
||||
<div
|
||||
className="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ProjectActionsMenu
|
||||
project={project}
|
||||
<RecollectionActionsMenu
|
||||
recollection={recollection}
|
||||
onOpen={handleOpen}
|
||||
onRenameOpen={handleRenameOpen}
|
||||
onDuplicateOpen={handleDuplicateOpen}
|
||||
@@ -849,7 +862,7 @@ export function ProjectsPage() {
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-7 rounded-full shadow-sm"
|
||||
aria-label={`Actions for ${project.name}`}
|
||||
aria-label={`Actions for ${recollection.name}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
@@ -861,7 +874,7 @@ export function ProjectsPage() {
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium font-serif">{project.name}</span>
|
||||
<span className="truncate font-medium font-serif">{recollection.name}</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -878,7 +891,7 @@ export function ProjectsPage() {
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {page + 1} of {totalPages}
|
||||
{pageSize !== -1 && ` · ${sorted.length} projects`}
|
||||
{pageSize !== -1 && ` · ${sorted.length} recollections`}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
@@ -924,44 +937,24 @@ export function ProjectsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rename dialog */}
|
||||
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
|
||||
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename project</DialogTitle>
|
||||
<DialogDescription>Enter a new name for this project.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameSubmit()
|
||||
if (e.key === 'Escape') setRenameTarget(null)
|
||||
}}
|
||||
placeholder="Project name"
|
||||
aria-label="Project name"
|
||||
/>
|
||||
{renameTarget && renameValue.trim() && sorted.some((p) => p.id !== renameTarget.id && p.name.toLowerCase() === renameValue.trim().toLowerCase()) && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRenameSubmit} disabled={!renameValue.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<RenameRecollectionDialog
|
||||
open={!!renameTarget}
|
||||
onOpenChange={(open) => !open && setRenameTarget(null)}
|
||||
recollectionId={renameTarget?.id ?? ''}
|
||||
initialName={renameTarget?.name ?? ''}
|
||||
recollections={sorted}
|
||||
onRename={(id, newName) => {
|
||||
renameRecollection(id, newName)
|
||||
setRenameTarget(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Duplicate dialog */}
|
||||
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>
|
||||
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duplicate project</DialogTitle>
|
||||
<DialogDescription>Enter a name for the duplicate project.</DialogDescription>
|
||||
<DialogTitle>Duplicate recollection</DialogTitle>
|
||||
<DialogDescription>Enter a name for the duplicate recollection.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
ref={duplicateInputRef}
|
||||
@@ -971,11 +964,11 @@ export function ProjectsPage() {
|
||||
if (e.key === 'Enter') handleDuplicateConfirm()
|
||||
if (e.key === 'Escape') setDuplicateTarget(null)
|
||||
}}
|
||||
placeholder="Project name"
|
||||
aria-label="Duplicate project name"
|
||||
placeholder="Recollection name"
|
||||
aria-label="Duplicate recollection name"
|
||||
/>
|
||||
{duplicateTarget && duplicateName.trim() && orderedProjects.some((p) => p.name.toLowerCase() === duplicateName.trim().toLowerCase()) && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">A project with this name already exists.</p>
|
||||
{duplicateTarget && duplicateName.trim() && orderedRecollections.some((p) => p.name.toLowerCase() === duplicateName.trim().toLowerCase()) && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">An recollection with this name already exists.</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setDuplicateTarget(null); setDuplicateName('') }}>
|
||||
@@ -992,7 +985,7 @@ export function ProjectsPage() {
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete project</DialogTitle>
|
||||
<DialogTitle>Delete recollection</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteTarget
|
||||
? `Are you sure you want to delete "${deleteTarget.name}"? You can undo this from the notification.`
|
||||
@@ -1014,9 +1007,9 @@ export function ProjectsPage() {
|
||||
<Dialog open={bulkDeleteTargets !== null && bulkDeleteTargets.length > 0} onOpenChange={(open) => !open && setBulkDeleteTargets(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {bulkDeleteTargets?.length ?? 0} projects</DialogTitle>
|
||||
<DialogTitle>Delete {bulkDeleteTargets?.length ?? 0} recollections</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete these projects? This cannot be undone.
|
||||
Are you sure you want to delete these recollections? This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
31
frontend/src/app/recollections/flux/FluxRoute.tsx
Normal file
31
frontend/src/app/recollections/flux/FluxRoute.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
frontend/src/app/recollections/katalogos/KatalogosPage.tsx
Normal file
127
frontend/src/app/recollections/katalogos/KatalogosPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
102
frontend/src/app/recollections/layout/RecollectionFileMenu.tsx
Normal file
102
frontend/src/app/recollections/layout/RecollectionFileMenu.tsx
Normal 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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
114
frontend/src/app/recollections/layout/RecollectionMenubar.tsx
Normal file
114
frontend/src/app/recollections/layout/RecollectionMenubar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
111
frontend/src/app/recollections/layout/RecollectionSidebar.tsx
Normal file
111
frontend/src/app/recollections/layout/RecollectionSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Subtle animated dot grid background for ProjectsPage.
|
||||
* Subtle animated dot grid background for RecollectionsPage.
|
||||
* Matches canvas grid (20px gap), with gentle wave movement.
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,7 @@ function getDotColor(): string {
|
||||
return isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)'
|
||||
}
|
||||
|
||||
export function ProjectsPageBackground({ className }: { className?: string }) {
|
||||
export function RecollectionsPageBackground({ className }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const timeRef = useRef(0)
|
||||
const rafRef = useRef<number>(0)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
609
frontend/src/app/recollections/layout/TreeBrowser.tsx
Normal file
609
frontend/src/app/recollections/layout/TreeBrowser.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
frontend/src/app/recollections/layout/recollectionNav.ts
Normal file
25
frontend/src/app/recollections/layout/recollectionNav.ts
Normal 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`
|
||||
}
|
||||
214
frontend/src/app/recollections/logos/LogosPage.tsx
Normal file
214
frontend/src/app/recollections/logos/LogosPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
frontend/src/app/recollections/logos/blocks/fluxOutputBlock.tsx
Normal file
200
frontend/src/app/recollections/logos/blocks/fluxOutputBlock.tsx
Normal 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,
|
||||
})
|
||||
17
frontend/src/app/recollections/logos/logosSchema.ts
Normal file
17
frontend/src/app/recollections/logos/logosSchema.ts
Normal 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
|
||||
@@ -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
|
||||
283
frontend/src/app/recollections/state/recollectionStore.ts
Normal file
283
frontend/src/app/recollections/state/recollectionStore.ts
Normal 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))
|
||||
}
|
||||
113
frontend/src/components/editor/CodeEditor.tsx
Normal file
113
frontend/src/components/editor/CodeEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import React, { useContext, useMemo } from 'react'
|
||||
import React, { memo } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
getBezierPath,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
||||
import { getConnectionLabelForTarget } from '@/lib/graph/nodeRegistry'
|
||||
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
|
||||
const EMPTY_PATH_NODE_IDS = new Set<string>()
|
||||
|
||||
export function AnimatedEdge({
|
||||
function AnimatedEdgeInner({
|
||||
id,
|
||||
source,
|
||||
sourceX,
|
||||
@@ -25,44 +25,18 @@ export function AnimatedEdge({
|
||||
label: labelProp,
|
||||
interactionWidth,
|
||||
target,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const pausedSegmentNodeIds = ctx?.connectionPathPausedSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const activeSegmentNodeIds = ctx?.connectionPathActiveSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
const errorTargetNodeIds = useMemo(
|
||||
() => new Set(ctx?.connectionPathErrorNodeIds ?? []),
|
||||
[ctx?.connectionPathErrorNodeIds]
|
||||
)
|
||||
const targetNode = useMemo(() => nodes.find((n: any) => n.id === target), [nodes, target])
|
||||
const derivedLabel = useMemo(
|
||||
() => (targetNode?.type != null ? getConnectionLabelForTarget(targetNode.type) : undefined),
|
||||
[targetNode?.type]
|
||||
)
|
||||
const label = labelProp ?? derivedLabel
|
||||
|
||||
const connectionStatus = useMemo(
|
||||
() =>
|
||||
getConnectionStatus({
|
||||
source,
|
||||
target,
|
||||
pathNodeIds,
|
||||
pausedSegmentNodeIds,
|
||||
activeSegmentNodeIds,
|
||||
errorTargetNodeIds,
|
||||
}),
|
||||
[
|
||||
source,
|
||||
target,
|
||||
pathNodeIds,
|
||||
pausedSegmentNodeIds,
|
||||
activeSegmentNodeIds,
|
||||
errorTargetNodeIds,
|
||||
]
|
||||
const connectionStatus = useCanvasStore((s) =>
|
||||
selectConnectionStatusForEdge(s, source, target)
|
||||
)
|
||||
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,
|
||||
sourceY,
|
||||
@@ -122,7 +96,7 @@ export function AnimatedEdge({
|
||||
className={`animated-edge-path${statusClass ? ` ${statusClass}` : ''}`}
|
||||
interactionWidth={interactionWidth}
|
||||
/>
|
||||
{label != null && (
|
||||
{displayLabel != null && displayLabel !== '' && (
|
||||
<g transform={`translate(${edgeLabelX}, ${edgeLabelY})`} className="nodrag nopan">
|
||||
<rect
|
||||
x={-32}
|
||||
@@ -139,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)
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { ComponentProps, ReactNode } from "react";
|
||||
import { NodeResizer } from "@xyflow/react";
|
||||
import { useContext } from "react";
|
||||
|
||||
import FlowContext from "@/lib/graph/flowContext";
|
||||
import { useConnectionPathRole } from "@/lib/graph/flowContext";
|
||||
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). */
|
||||
@@ -37,9 +37,9 @@ export function BaseNode({
|
||||
resizeConstraints,
|
||||
...props
|
||||
}: BaseNodeProps) {
|
||||
const flowContext = useContext(FlowContext);
|
||||
const isFullscreenInstance = Boolean(nodeId && flowContext?.fullscreenNodeId === nodeId);
|
||||
const connectionPathRole = useConnectionPathRole(nodeId);
|
||||
const flowUIContext = useContext(FlowUIContext);
|
||||
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
||||
const connectionPathRole = useConnectionPathRoleFromStore(nodeId);
|
||||
const hasSize =
|
||||
dimensions &&
|
||||
dimensions.width > 0 &&
|
||||
@@ -69,18 +69,20 @@ export function BaseNode({
|
||||
"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)]",
|
||||
connectionPathRole === "trigger" && "connection-path-trigger",
|
||||
connectionPathRole === "updating" && "connection-path-updating",
|
||||
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}
|
||||
@@ -97,7 +99,9 @@ export function BaseNode({
|
||||
handleClassName="base-node-resize-handle nodrag nopan"
|
||||
/>
|
||||
)}
|
||||
{!isFullscreenInstance && handles}
|
||||
{!isFullscreenInstance && (
|
||||
<div className={cn(!selected && "pointer-events-none")}>{handles}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -199,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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useContext, useEffect } from 'react'
|
||||
import { useReactFlow } from '@xyflow/react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNextNodeId, getDefaultDataForType } from '@/lib/graph/flowUtils'
|
||||
import { getDefaultStyle, getRegisteredNodeTypeIds } from '@/lib/graph/nodeRegistry'
|
||||
|
||||
@@ -14,11 +14,11 @@ function isMod(ev: KeyboardEvent) {
|
||||
/** Must be rendered inside ReactFlowProvider and FlowContext. Handles Escape, ⌘C, ⌘V, ⌘D, ⌘0. */
|
||||
export function FlowKeyboardShortcuts() {
|
||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setConnectionFrom = ctx?.setConnectionFrom
|
||||
const flowActionsRef = ctx?.flowActionsRef
|
||||
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
|
||||
@@ -79,6 +79,7 @@ export function FlowKeyboardShortcuts() {
|
||||
|
||||
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
|
||||
@@ -151,7 +152,7 @@ export function FlowKeyboardShortcuts() {
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [
|
||||
nodes,
|
||||
graphCtx?.graphRef,
|
||||
setNodes,
|
||||
setConnectionFrom,
|
||||
pasteAtViewportCenter,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useContext, useMemo } from 'react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { GraphContext } from '@/lib/graph/flowContext'
|
||||
import { ArrowDownLeft, ArrowUpRight } from 'lucide-react'
|
||||
import { NodeHelpPopover } from '@/components/graph/NodeHelpPopover'
|
||||
import { getNodeType, getNodeClassificationLabel } from '@/lib/graph/nodeRegistry'
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
}
|
||||
|
||||
export function NodeFooterEdgeIndicators({ nodeId, nodeType, children }: Props) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const ctx = useContext(GraphContext)
|
||||
const edges = ctx?.edges ?? []
|
||||
|
||||
const { inputs, outputs } = useMemo(() => {
|
||||
|
||||
@@ -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/graph/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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import type { AppNode } from '@/lib/graph/nodeTypes'
|
||||
import { replaceNodeIdInGraph } from '@/lib/graph/flowUtils'
|
||||
|
||||
@@ -9,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)
|
||||
@@ -31,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)
|
||||
@@ -45,7 +46,7 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* 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 FlowContext from '@/lib/graph/flowContext'
|
||||
import { GraphContext, FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
import {
|
||||
Menubar,
|
||||
@@ -38,12 +43,12 @@ type Props = {
|
||||
}
|
||||
|
||||
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) {
|
||||
const ctx = useContext(FlowContext)
|
||||
const nodes = ctx?.nodes ?? []
|
||||
const setNodes = ctx?.setNodes
|
||||
const setEdges = ctx?.setEdges
|
||||
|
||||
const edges = ctx?.edges ?? []
|
||||
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 ?? {}),
|
||||
@@ -59,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">
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ 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 FlowContext from '@/lib/graph/flowContext'
|
||||
import { FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
import { Bot } from 'lucide-react'
|
||||
@@ -34,8 +34,8 @@ export type AgentNodeData = {
|
||||
type Props = AbstractNodeProps<AgentNodeData>
|
||||
|
||||
function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ export function getAgentNodeDescriptor(): NodeTypeDescriptor {
|
||||
.idPrefix('agt_')
|
||||
.withInputOutput(true, true)
|
||||
.classification('archon')
|
||||
.allowedSourceTypes(['config', 'variable', 'data'])
|
||||
.allowedSourceTypes(['config', 'variable', 'data', 'render'])
|
||||
.allowedTargetTypes(['render'])
|
||||
.help(NODE_HELP.agent)
|
||||
.menu('Agent', <Bot className={ICON_CLASS} />)
|
||||
.connectionLabel('prompt/context')
|
||||
.connectionLabelByStatus({ default: 'prompt/context', updating: 'running', paused: 'pending', error: 'corrupted' })
|
||||
.withFullscreen()
|
||||
.sourceRenderingLogic(agentRenderingLogic)
|
||||
.outputMenuContent(() => null)
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import React, { 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 React, { useCallback, useContext, useId, useMemo } from 'react'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
getConnectedNodesByType,
|
||||
useAbstractNode,
|
||||
type FlowNode,
|
||||
} from '@/lib/graph/abstractNode'
|
||||
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
|
||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||
import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
|
||||
import { plantumlLanguage } from '@/lib/plantumlLanguage'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
|
||||
import {
|
||||
CONFIG_TYPES,
|
||||
getConfigTypes,
|
||||
getConfigContent,
|
||||
getConfigType,
|
||||
getConfigTypeId,
|
||||
@@ -29,7 +25,7 @@ import {
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '@/components/graph/BaseNode'
|
||||
import { Code2, Database, ScrollText, Variable } from 'lucide-react'
|
||||
import { Code2, Database, ScrollText, Sparkles, Variable } from 'lucide-react'
|
||||
import {
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
@@ -38,13 +34,14 @@ import {
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
} 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 FlowContext from '@/lib/graph/flowContext'
|
||||
import { FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
|
||||
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string }
|
||||
@@ -52,38 +49,46 @@ export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; titl
|
||||
type Props = AbstractNodeProps<ConfigNodeData>
|
||||
|
||||
function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
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 { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<unknown>(null)
|
||||
|
||||
const connectedConfigNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'config'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedDataNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'data'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0
|
||||
|
||||
const editorId = useId()
|
||||
const onChange = useCallback(
|
||||
(val: string) => updateData({ content: val, configType: configTypeId }),
|
||||
[updateData, configTypeId]
|
||||
)
|
||||
const insertAt = useSimpleEditorInsert(editorId, content, onChange)
|
||||
|
||||
const connectedConfigNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'config'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
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) => {
|
||||
@@ -96,36 +101,6 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
[configTypeId, data, updateData]
|
||||
)
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
const insertExtendsFromNode = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
|
||||
@@ -168,6 +143,18 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const insertRenderReference = useCallback(
|
||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
const isMarkdownImage =
|
||||
configTypeId === 'markdown' && (sourceNode.data?.outputMode ?? 'image') === 'image'
|
||||
const snippet = isMarkdownImage
|
||||
? ``
|
||||
: `{{ ${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(
|
||||
@@ -175,21 +162,8 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
[connectedConfigNodes],
|
||||
)
|
||||
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
|
||||
const extensions = useMemo(() => {
|
||||
const lang =
|
||||
configTypeId === 'wireframe'
|
||||
? javascript()
|
||||
: configType.language === 'plantuml'
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
return [
|
||||
lang,
|
||||
autocompletion({
|
||||
override: [nunjucksCompletionSource(variableIds, configTitles, functionIds, dataIds)],
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
]
|
||||
}, [configTypeId, configType.language, variableIds, functionIds, configTitles, dataIds])
|
||||
const highlightLang: HighlightLanguage =
|
||||
configTypeId === 'wireframe' ? 'javascript' : configType.language === 'plantuml' ? 'plantuml' : 'markdown'
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||
|
||||
const insertBlocksContent = useMemo(() => {
|
||||
@@ -261,7 +235,7 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONFIG_TYPES.map((t) => (
|
||||
{getConfigTypes().map((t) => (
|
||||
<SelectItem key={t.id} value={t.id} className="text-xs">
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
@@ -344,6 +318,40 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
<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>
|
||||
@@ -355,17 +363,17 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} 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>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { getResolvedContentForConfig } from './renderingLogic'
|
||||
import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry'
|
||||
import ConfigNode from './ConfigNode'
|
||||
import { createImageExportHandlers, type OutputMenuContext } from '@/components/nodes/render/outputMenuRegistry'
|
||||
import { createImageExportHandlers, type OutputMenuContext } from '@/components/nodes/render/outputMenuHandlers'
|
||||
|
||||
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||
|
||||
@@ -68,7 +68,7 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor {
|
||||
.idPrefix('cfg_')
|
||||
.withInputOutput(true, true)
|
||||
.classification('psyche')
|
||||
.allowedSourceTypes(['config', 'variable', 'function', 'data'])
|
||||
.allowedSourceTypes(['config', 'variable', 'function', 'data', 'render'])
|
||||
.allowedTargetTypes(['config', 'render', 'agent'])
|
||||
.help(NODE_HELP.config)
|
||||
.menu('Config', <ScrollText className={ICON_CLASS} />)
|
||||
@@ -82,7 +82,7 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor {
|
||||
content: '@startuml\n\n@enduml\n',
|
||||
title: nodeId ?? '',
|
||||
}))
|
||||
.connectionLabel('adding input')
|
||||
.connectionLabelByStatus({ default: 'adding input', updating: 'pushing new form', paused: 'pending', error: 'corrupted' })
|
||||
.withFullscreen()
|
||||
.sourceRenderingLogic({
|
||||
defaultUpdateMode: 'auto',
|
||||
|
||||
@@ -7,42 +7,14 @@
|
||||
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'
|
||||
|
||||
type Node = { id: string; type?: string; data?: unknown }
|
||||
type Edge = { id: string; source: string; target: string }
|
||||
|
||||
function isReachable(edges: Edge[], startId: string, targetId: string): boolean {
|
||||
const q: string[] = [startId]
|
||||
const seen = new Set<string>([startId])
|
||||
while (q.length) {
|
||||
const cur = q.shift()!
|
||||
if (cur === targetId) return true
|
||||
for (const e of edges) {
|
||||
if (e.source === cur && !seen.has(e.target)) {
|
||||
seen.add(e.target)
|
||||
q.push(e.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveExtendsRef(nodes: Node[], name: string): string {
|
||||
const refName = name.replace(/\.(puml|html)$/, '').trim()
|
||||
return nodes.find((n) => n.id === refName || (n.data as Record<string, unknown>)?.title === refName)?.id ?? refName
|
||||
}
|
||||
|
||||
function getTemplateRefs(content: string): string[] {
|
||||
const refs: string[] = []
|
||||
const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/)
|
||||
if (extendMatch) refs.push(extendMatch[1].trim())
|
||||
const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g
|
||||
let m
|
||||
while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g
|
||||
while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
return refs
|
||||
}
|
||||
// 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
|
||||
@@ -85,7 +57,7 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
||||
}
|
||||
|
||||
const nunjucksContext = Object.create(null) as Record<string, unknown>
|
||||
const setVarInContext = (src: Node) => {
|
||||
const setVarInContext = (src: NodeLike) => {
|
||||
const v = (src.data as Record<string, unknown>)?.value
|
||||
const str = v === undefined || v === null ? '' : String(v)
|
||||
nunjucksContext[src.id] =
|
||||
@@ -109,6 +81,10 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
||||
})
|
||||
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>()
|
||||
@@ -138,6 +114,24 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
||||
}
|
||||
}
|
||||
|
||||
// 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 => {
|
||||
@@ -279,7 +273,13 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
||||
return
|
||||
}
|
||||
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
|
||||
resolve({ resolved, outputTypeId })
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '@/components/graph/NodeMenubar'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
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'
|
||||
@@ -52,8 +52,8 @@ export type DataNodeData = {
|
||||
type Props = AbstractNodeProps<DataNodeData>
|
||||
|
||||
function DataNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('data')?.supportsFullscreen
|
||||
const { updateData } = useAbstractNode<DataNodeData>(id, data ?? {})
|
||||
const rows = data?.rows ?? []
|
||||
|
||||
@@ -20,7 +20,7 @@ export function getDataNodeDescriptor(): NodeTypeDescriptor {
|
||||
.allowedTargetTypes(['config', 'agent'])
|
||||
.help(NODE_HELP.data)
|
||||
.menu('Data', <Database className={ICON_CLASS} />)
|
||||
.connectionLabel('data source')
|
||||
.connectionLabelByStatus({ default: 'data source' })
|
||||
.withFullscreen()
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import React, { useCallback, useContext, useId, useMemo } from 'react'
|
||||
import {
|
||||
AbstractNodeProps,
|
||||
createAbstractNodeComponent,
|
||||
getConnectedNodesByType,
|
||||
useAbstractNode,
|
||||
type FlowNode,
|
||||
} from '@/lib/graph/abstractNode'
|
||||
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
|
||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import {
|
||||
BaseNode,
|
||||
BaseNodeContent,
|
||||
@@ -19,7 +18,7 @@ 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 FlowContext from '@/lib/graph/flowContext'
|
||||
import { FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
import { MenubarItem, MenubarShortcut } from '@/components/ui/menubar'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
@@ -29,50 +28,30 @@ export type FunctionNodeData = { body?: string }
|
||||
|
||||
type Props = AbstractNodeProps<FunctionNodeData>
|
||||
|
||||
const LANGUAGE: HighlightLanguage = 'javascript'
|
||||
|
||||
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
|
||||
const bodyValue = data?.body ?? ''
|
||||
const { theme } = useTheme()
|
||||
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
||||
const editorRef = useRef<unknown>(null)
|
||||
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => (nodes as FlowNode[]).filter((n) => sourceIds.includes(n.id) && n.type === 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||
|
||||
const editorId = useId()
|
||||
const onChange = useCallback(
|
||||
(val: string) => updateData({ body: val }),
|
||||
[updateData]
|
||||
)
|
||||
const insertAt = useSimpleEditorInsert(editorId, bodyValue, onChange)
|
||||
|
||||
const insertAt = useCallback(
|
||||
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
|
||||
const ref = editorRef.current as { view: { state: { doc: { length: number }; 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 from = view.state.selection.main.from
|
||||
view.dispatch({ changes: { from, to: from, insert: insertText } })
|
||||
onChange(view.state.doc.toString())
|
||||
return
|
||||
}
|
||||
if (mode === 'prepend') onChange(insertText + bodyValue)
|
||||
else onChange(bodyValue + insertText)
|
||||
},
|
||||
[onChange, bodyValue]
|
||||
const connectedVariableNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const connectedFunctionNodes = useMemo(
|
||||
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
|
||||
[nodes, sourceIds]
|
||||
)
|
||||
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||
|
||||
const insertVariableAtCursor = useCallback(
|
||||
(variableNode: any) => {
|
||||
@@ -88,7 +67,6 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
[insertAt]
|
||||
)
|
||||
|
||||
const extensions = useMemo(() => [javascript()], [])
|
||||
const [editorHeight, editorContainerRef] = useResizeHeight(120)
|
||||
const dimensions =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
@@ -140,17 +118,17 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} 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-auto border-t border-input" style={{ minHeight: editorHeight }}>
|
||||
<CodeEditor
|
||||
textareaId={editorId}
|
||||
value={bodyValue}
|
||||
height={`${editorHeight}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onChange={onChange}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
||||
onValueChange={onChange}
|
||||
language="javascript"
|
||||
minHeight={editorHeight}
|
||||
style={{ fontSize: 12 }}
|
||||
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
|
||||
preClassName="text-xs nodrag nopan"
|
||||
className="min-h-0 w-full"
|
||||
/>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function getFunctionNodeDescriptor(): NodeTypeDescriptor {
|
||||
.help(NODE_HELP.function)
|
||||
.menu('Function', <Code2 className={ICON_CLASS} />)
|
||||
.getResetData(() => ({ body: '' }))
|
||||
.connectionLabel('adding input')
|
||||
.connectionLabelByStatus({ default: 'adding input', updating: 'running', paused: 'pending', error: 'corrupted' })
|
||||
.withFullscreen()
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
BaseNodeFooter,
|
||||
BaseNodeHeaderRow,
|
||||
} from '@/components/graph/BaseNode'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { FlowUIContext } from '@/lib/graph/flowContext'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators'
|
||||
import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
MenubarSubTrigger,
|
||||
} from '@/components/ui/menubar'
|
||||
import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react'
|
||||
import { InputHandle } from '@/components/graph/NodeHandles'
|
||||
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import {
|
||||
@@ -33,28 +33,31 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import {
|
||||
useRenderingNodeState,
|
||||
type RenderingNodeData,
|
||||
} from './useRenderingNodeState'
|
||||
import { ImageOutputView, MarkdownOutputView, RawOutputView } from './views'
|
||||
import {
|
||||
ImageOutputView,
|
||||
MarkdownJsxView,
|
||||
RawOutputView,
|
||||
StaticMarkdownHtmlView,
|
||||
StreamingMarkdownView,
|
||||
} from './views'
|
||||
|
||||
export type { RenderingNodeData }
|
||||
|
||||
type Props = AbstractNodeProps<RenderingNodeData>
|
||||
|
||||
type ViewMode = 'preview' | 'raw'
|
||||
|
||||
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const flowUIContext = useContext(FlowUIContext)
|
||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
||||
|
||||
const state = useRenderingNodeState(id, data)
|
||||
const outputMode = state.outputMode
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('preview')
|
||||
const [viewportFocused, setViewportFocused] = useState(false)
|
||||
|
||||
const dimensions =
|
||||
@@ -62,16 +65,8 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
? { width, height }
|
||||
: undefined
|
||||
|
||||
const status = state.loading
|
||||
? 'loading'
|
||||
: state.error
|
||||
? 'error'
|
||||
: state.renderedContent
|
||||
? 'success'
|
||||
: 'initial'
|
||||
|
||||
const { theme } = useTheme()
|
||||
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode])
|
||||
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [outputMode])
|
||||
|
||||
const showEmpty =
|
||||
state.incomingIds.length === 0 &&
|
||||
@@ -122,10 +117,19 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <MarkdownOutputView state={state} streaming={false} />
|
||||
const isConfigMarkdown =
|
||||
state.sourceNodeType === 'config' && state.rawLanguage === 'markdown'
|
||||
if (isConfigMarkdown) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
<MarkdownJsxView markdown={state.resolvedContent ?? ''} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <StaticMarkdownHtmlView state={state} />
|
||||
}
|
||||
if (state.loading && state.streamingMarkdown !== null) {
|
||||
return <MarkdownOutputView state={state} streaming={true} />
|
||||
return <StreamingMarkdownView state={state} />
|
||||
}
|
||||
if (state.loading) {
|
||||
return (
|
||||
@@ -138,14 +142,19 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeStatusIndicator status={status} variant="overlay" width={dimensions?.width} height={dimensions?.height}>
|
||||
<NodeStatusIndicator status={state.displayStatus} variant="overlay" width={dimensions?.width} height={dimensions?.height}>
|
||||
<BaseNode
|
||||
className="min-w-96 min-h-[320px]"
|
||||
dimensions={dimensions}
|
||||
resizable
|
||||
nodeId={id}
|
||||
selected={selected}
|
||||
handles={<InputHandle id="ain" nodeId={id} />}
|
||||
handles={
|
||||
<>
|
||||
<InputHandle id="ain" nodeId={id} />
|
||||
<OutputHandle id="out" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<BaseNodeHeaderRow
|
||||
icon={<Sparkles className="size-4" />}
|
||||
@@ -159,46 +168,51 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
state.incomingIds.length > 0 ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<ButtonGroup className="nodrag nopan">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={state.loading}
|
||||
className={cn(
|
||||
'h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs',
|
||||
state.effectiveUpdateMode === 'manual' &&
|
||||
state.hasPendingInputs &&
|
||||
'border-l-2 border-l-amber-500 bg-amber-500/10 hover:bg-amber-500/15 dark:bg-amber-500/15 dark:hover:bg-amber-500/20'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
state.incrementRunTrigger()
|
||||
}}
|
||||
title={
|
||||
state.effectiveUpdateMode === 'manual' && state.hasPendingInputs
|
||||
? 'Inputs changed — click to render'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{state.loading ? (
|
||||
{state.effectiveUpdateMode === 'manual' ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={state.loading || !state.hasPendingInputs}
|
||||
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
state.incrementRunTrigger()
|
||||
}}
|
||||
title={
|
||||
state.hasPendingInputs
|
||||
? 'Inputs changed — click to render'
|
||||
: !state.hasPendingInputs
|
||||
? 'No new data to render'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{state.loading ? (
|
||||
<Loader2 className="size-3.5 animate-spin shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Play className="size-3.5 shrink-0" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
) : state.loading ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
|
||||
aria-label="Updating"
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Play className="size-3.5 shrink-0" />
|
||||
)}
|
||||
Run
|
||||
</Button>
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-7 min-w-[4.5rem] gap-1 rounded-l-none pl-2 pr-1.5 text-xs font-normal',
|
||||
state.effectiveUpdateMode === 'manual' &&
|
||||
state.hasPendingInputs &&
|
||||
'bg-amber-500/10 hover:bg-amber-500/15 dark:bg-amber-500/15 dark:hover:bg-amber-500/20'
|
||||
)}
|
||||
className={`h-7 min-w-[4.5rem] gap-1 pl-2 pr-1.5 text-xs font-normal ${state.effectiveUpdateMode === 'manual' || state.loading ? 'rounded-l-none' : 'rounded-l-md'}`}
|
||||
aria-label="Update mode"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -253,17 +267,17 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
<>
|
||||
<MenubarCheckboxItem
|
||||
className="text-xs"
|
||||
checked={viewMode === 'preview'}
|
||||
onCheckedChange={(checked) => checked && setViewMode('preview')}
|
||||
checked={outputMode === 'image'}
|
||||
onCheckedChange={(checked) => checked && state.setOutputMode('image')}
|
||||
>
|
||||
Preview
|
||||
Image
|
||||
</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem
|
||||
className="text-xs"
|
||||
checked={viewMode === 'raw'}
|
||||
onCheckedChange={(checked) => checked && setViewMode('raw')}
|
||||
checked={outputMode === 'string'}
|
||||
onCheckedChange={(checked) => checked && state.setOutputMode('string')}
|
||||
>
|
||||
Raw
|
||||
String
|
||||
</MenubarCheckboxItem>
|
||||
{getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })}
|
||||
</>
|
||||
@@ -295,7 +309,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
</Empty>
|
||||
) : state.error ? (
|
||||
errorUi
|
||||
) : viewMode === 'raw' ? (
|
||||
) : outputMode === 'string' ? (
|
||||
<RawOutputView
|
||||
state={state}
|
||||
height={rawEditorHeight}
|
||||
@@ -310,12 +324,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
||||
{viewMode === 'raw'
|
||||
{outputMode === 'string'
|
||||
? state.rawDisplayContent
|
||||
? `Raw · ${state.rawDisplayContent.length} chars`
|
||||
? `String · ${state.rawDisplayContent.length} chars`
|
||||
: '—'
|
||||
: state.renderedContent
|
||||
? `${state.outputLabel} · ${state.renderedContent.length} chars`
|
||||
? `Image · ${state.renderedContent.length} chars`
|
||||
: state.error
|
||||
? 'Error'
|
||||
: '—'}
|
||||
|
||||
@@ -15,12 +15,13 @@ export function getRenderNodeDescriptor(): NodeTypeDescriptor {
|
||||
{ viewportWidth: 1200, viewportHeight: 800 }
|
||||
)
|
||||
.idPrefix('rnd_')
|
||||
.withInputOutput(true, false)
|
||||
.withInputOutput(true, true)
|
||||
.classification('pneuma')
|
||||
.allowedSourceTypes(['config', 'agent'])
|
||||
.allowedTargetTypes(['config', 'agent'])
|
||||
.help(NODE_HELP.render)
|
||||
.menu('Renderer', <Sparkles className={ICON_CLASS} />)
|
||||
.connectionLabel('rendering')
|
||||
.connectionLabelByStatus({ default: 'listening', updating: 'giving life', paused: 'pending', error: 'corrupted' })
|
||||
.withFullscreen()
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { default as RenderingNode, type RenderingNodeData } from './RenderingNode'
|
||||
export { getRenderNodeDescriptor } from './descriptor'
|
||||
export { createImageExportHandlers } from './outputMenuRegistry'
|
||||
export type { OutputMenuContext } from './outputMenuRegistry'
|
||||
export { createImageExportHandlers } from './outputMenuHandlers'
|
||||
export type { OutputMenuContext } from './outputMenuHandlers'
|
||||
|
||||
@@ -2,13 +2,66 @@
|
||||
* runs the resolve → render pipeline, manages streaming/cache,
|
||||
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
||||
* pipeline interface.
|
||||
*
|
||||
* ## Auto vs Manual Mode Decision Tree
|
||||
*
|
||||
* The hook supports two update modes: 'auto' and 'manual'. The mode is determined by:
|
||||
*
|
||||
* 1. **Node data override**: Check `data?.updateMode` first
|
||||
* 2. **Source logic default**: Fall back to `sourceLogic?.defaultUpdateMode`
|
||||
* 3. **Hardcoded default**: Default to 'auto' if neither is set
|
||||
*
|
||||
* ### Auto Mode Behavior
|
||||
* - Runs automatically when upstream data changes (sourceSignature changes)
|
||||
* - Debounces runs by 250ms to avoid excessive computation
|
||||
* - Skips runs when node becomes visible with cache (visibility grace period: 400ms)
|
||||
* - Updates path trigger on each run
|
||||
*
|
||||
* ### Manual Mode Behavior
|
||||
* - Only runs when `runTrigger` increments (user clicks "Run")
|
||||
* - Requires explicit user action to update output
|
||||
* - Caches last run signature to prevent re-runs on unrelated changes
|
||||
*
|
||||
* ## Effect Dependencies
|
||||
*
|
||||
* The main effect depends on:
|
||||
* - `id`: Node ID (re-run when node changes)
|
||||
* - `srcId`: Source node ID (re-run when connection changes)
|
||||
* - `srcNode?.type`: Source node type (re-run when source type changes)
|
||||
* - `effectiveUpdateMode`: Auto vs manual mode
|
||||
* - `runTrigger`: Run trigger counter (for manual mode)
|
||||
* - `sourceSignature`: Combined signature of all upstream nodes
|
||||
* - `viewportWidth/Height`: Viewport dimensions (affects rendering)
|
||||
* - `retryCount`: Retry counter (for error recovery)
|
||||
* - `incomingIds.length`: Number of incoming connections
|
||||
*
|
||||
* ## Rendering Pipeline
|
||||
*
|
||||
* 1. **Resolve**: Source node (config/agent) produces resolved content
|
||||
* 2. **Render**: Output type renderer (plantuml/markdown/wireframe) produces HTML/SVG
|
||||
* 3. **Cache**: Results are cached in node data for persistence
|
||||
* 4. **Display**: UI consumes the rendered content
|
||||
*
|
||||
* ## Streaming Support
|
||||
*
|
||||
* When the source node supports streaming (e.g., agent nodes):
|
||||
* - `onStreamingStart`: Called when streaming begins
|
||||
* - `onStreamingChunk`: Called for each chunk of markdown content
|
||||
* - Streaming content is parsed with marked.js for preview
|
||||
*
|
||||
* ## Error Handling
|
||||
*
|
||||
* Errors during resolve or render are caught and stored in the `error` state.
|
||||
* The error includes a `kind` (e.g., 'render', 'no-content') and `message`.
|
||||
*/
|
||||
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { useOptionalRecollectionActions } from '@/app/recollections/layout/RecollectionActionsContext'
|
||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||
import {
|
||||
@@ -20,24 +73,45 @@ import {
|
||||
processSvgDisplay,
|
||||
stripTemplateSyntax,
|
||||
} from '@/lib/graph/rendering'
|
||||
import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph/renderingSignatures'
|
||||
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
|
||||
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||
|
||||
export type OutputMode = 'image' | 'string'
|
||||
|
||||
export type RenderingNodeData = {
|
||||
viewportWidth?: number
|
||||
viewportHeight?: number
|
||||
updateMode?: 'auto' | 'manual'
|
||||
runTrigger?: number
|
||||
lastRunSourceSignature?: string
|
||||
/** Controls which view is shown and what the node emits to downstream (Image = markdown embed, String = resolved text). */
|
||||
outputMode?: OutputMode
|
||||
cachedRenderedContent?: string
|
||||
cachedResolvedContent?: string
|
||||
cachedReasoningContent?: string
|
||||
/** Value exposed to config/agent when they reference this node (e.g. {{ renderId }}). Set on pipeline completion. */
|
||||
cachedOutputValue?: string
|
||||
}
|
||||
|
||||
const DEFAULT_VIEWPORT_WIDTH = 1200
|
||||
const DEFAULT_VIEWPORT_HEIGHT = 800
|
||||
const RENDER_DEBOUNCE_MS = 250
|
||||
/** Skip auto-run when we have cache and effect runs soon after mount (node became visible). */
|
||||
const VISIBILITY_GRACE_MS = 400
|
||||
|
||||
/** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/error) stays in sync. */
|
||||
export type RenderingNodeLifecycle = {
|
||||
updating: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
export type RenderingNodeState = {
|
||||
/** For NodeStatusIndicator and empty/error UI. Derived from loading, error, renderedContent. */
|
||||
displayStatus: NodeDisplayStatus
|
||||
/** Pass to useSyncConnectionStatus so edges show correct status (blue/yellow/red). */
|
||||
lifecycle: RenderingNodeLifecycle
|
||||
|
||||
// Connection & run control
|
||||
incomingIds: string[]
|
||||
effectiveUpdateMode: 'auto' | 'manual'
|
||||
@@ -81,6 +155,10 @@ export type RenderingNodeState = {
|
||||
|
||||
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
|
||||
sourceNodeType: string | null
|
||||
|
||||
/** Output mode (Image vs String); controls view and emitted cachedOutputValue. */
|
||||
outputMode: OutputMode
|
||||
setOutputMode: (mode: OutputMode) => void
|
||||
}
|
||||
|
||||
export function useRenderingNodeState(
|
||||
@@ -88,22 +166,37 @@ export function useRenderingNodeState(
|
||||
data: RenderingNodeData | undefined
|
||||
): RenderingNodeState {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
nodes: contextNodes,
|
||||
edges: contextEdges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
sourceIds: incomingIds,
|
||||
updateData,
|
||||
} = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
||||
|
||||
// Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data).
|
||||
// Context only exposes a ref, so we wouldn't re-render when another node updates otherwise.
|
||||
// Single combined subscription (vs two separate) halves listener overhead; useShallow prevents
|
||||
// re-renders when non-graph slices (ui/path) change.
|
||||
const { storeNodes, storeEdges } = useCanvasStore(
|
||||
useShallow((s) => ({ storeNodes: s.graph.nodes, storeEdges: s.graph.edges }))
|
||||
)
|
||||
const nodes = storeNodes.length > 0 ? storeNodes : contextNodes
|
||||
const edges = storeEdges.length > 0 ? storeEdges : contextEdges
|
||||
|
||||
const nodesEdgesRef = useRef({ nodes, edges })
|
||||
nodesEdgesRef.current = { nodes, edges }
|
||||
|
||||
const { aiConnection } = usePlatform()
|
||||
const recollectionActions = useOptionalRecollectionActions()
|
||||
const recollectionActionsRef = useRef(recollectionActions)
|
||||
recollectionActionsRef.current = recollectionActions
|
||||
|
||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||
|
||||
const deferredNodes = useDeferredValue(nodes)
|
||||
const deferredEdges = useDeferredValue(edges)
|
||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||
const srcNode = useMemo(
|
||||
() => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null),
|
||||
@@ -116,6 +209,11 @@ export function useRenderingNodeState(
|
||||
)
|
||||
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual'
|
||||
const runTrigger = data?.runTrigger ?? 0
|
||||
const outputMode: OutputMode = (data?.outputMode ?? 'image') as OutputMode
|
||||
const setOutputMode = useCallback(
|
||||
(mode: OutputMode) => updateData({ outputMode: mode }),
|
||||
[updateData]
|
||||
)
|
||||
const isAgentSource = srcNode?.type === 'agent'
|
||||
const agentOutputMarkdown = isAgentSource
|
||||
? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '')
|
||||
@@ -135,133 +233,19 @@ export function useRenderingNodeState(
|
||||
? agentOutputMarkdown
|
||||
: ''
|
||||
|
||||
const connectedNodeIds = useMemo(() => {
|
||||
const out = new Set<string>()
|
||||
const isReachable = (startId: string, targetId: string) => {
|
||||
const q: string[] = [startId]
|
||||
const seen = new Set<string>([startId])
|
||||
while (q.length) {
|
||||
const cur = q.shift()!
|
||||
if (cur === targetId) return true
|
||||
for (const e of edges as { source: string; target: string }[]) {
|
||||
if (e.source === cur && !seen.has(e.target)) {
|
||||
seen.add(e.target)
|
||||
q.push(e.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const resolveRef = (name: string) => {
|
||||
const refName = name.replace(/\.(puml|html)$/, '').trim()
|
||||
return (nodes as { id: string; data?: { title?: string } }[]).find(
|
||||
(n) => n.id === refName || n.data?.title === refName
|
||||
)?.id ?? refName
|
||||
}
|
||||
const getTemplateRefs = (content: string): string[] => {
|
||||
const refs: string[] = []
|
||||
const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/)
|
||||
if (extendMatch) refs.push(extendMatch[1].trim())
|
||||
const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g
|
||||
let m
|
||||
while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g
|
||||
while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
return refs
|
||||
}
|
||||
const addConfigRefs = (nodeId: string, visited: Set<string>) => {
|
||||
if (visited.has(nodeId)) return
|
||||
const node = (nodes as { id: string; type?: string; data?: unknown }[]).find(
|
||||
(n) => n.id === nodeId && n.type === 'config'
|
||||
)
|
||||
if (!node) return
|
||||
visited.add(nodeId)
|
||||
out.add(nodeId)
|
||||
const content = getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined)
|
||||
for (const ref of getTemplateRefs(content)) {
|
||||
const refId = resolveRef(ref)
|
||||
if (
|
||||
refId &&
|
||||
(nodes as { id: string; type?: string }[]).some((n) => n.id === refId && n.type === 'config') &&
|
||||
isReachable(refId, id)
|
||||
) {
|
||||
addConfigRefs(refId, visited)
|
||||
}
|
||||
}
|
||||
}
|
||||
const configVisited = new Set<string>()
|
||||
for (const nid of incomingIds) {
|
||||
const node = (nodes as { id: string; type?: string }[]).find((n) => n.id === nid)
|
||||
if (node?.type === 'config') addConfigRefs(nid, configVisited)
|
||||
else out.add(nid)
|
||||
}
|
||||
for (const e of edges as { source: string; target: string }[]) {
|
||||
if (out.has(e.target)) out.add(e.source)
|
||||
}
|
||||
return out
|
||||
}, [nodes, edges, id, incomingIds])
|
||||
|
||||
const configSignature = useMemo(
|
||||
() =>
|
||||
(nodes as { id: string; type?: string; data?: unknown }[])
|
||||
.filter((n) => n.type === 'config' && connectedNodeIds.has(n.id))
|
||||
.map((n) => `${n.id}:${(n.data as { title?: string })?.title ?? ''}:${getConfigContent(n.data as Record<string, unknown>)}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
const edgesSignature = useMemo(
|
||||
() =>
|
||||
(edges as { source: string; target: string }[])
|
||||
.filter(
|
||||
(e) =>
|
||||
connectedNodeIds.has(e.source) &&
|
||||
(connectedNodeIds.has(e.target) || e.target === id)
|
||||
)
|
||||
.map((e) => `${e.source}->${e.target}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[edges, connectedNodeIds, id]
|
||||
)
|
||||
const variablesSignature = useMemo(
|
||||
() =>
|
||||
(nodes as { id: string; type?: string; data?: { value?: unknown } }[])
|
||||
.filter((n) => n.type === 'variable' && connectedNodeIds.has(n.id))
|
||||
.map((n) => `${n.id}:${n.data?.value}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
const functionsSignature = useMemo(
|
||||
() =>
|
||||
(nodes as { id: string; type?: string; data?: { body?: string } }[])
|
||||
.filter((n) => n.type === 'function' && connectedNodeIds.has(n.id))
|
||||
.map((n) => `${n.id}:${n.data?.body ?? ''}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
const dataSignature = useMemo(
|
||||
() =>
|
||||
(nodes as { id: string; type?: string; data?: { rows?: unknown[]; hiddenColumns?: unknown[] } }[])
|
||||
.filter((n) => n.type === 'data' && connectedNodeIds.has(n.id))
|
||||
.map((n) => `${n.id}:${JSON.stringify(n.data?.rows ?? [])}:${JSON.stringify(n.data?.hiddenColumns ?? [])}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
|
||||
const sourceSignature = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
configSignature,
|
||||
edgesSignature,
|
||||
variablesSignature,
|
||||
functionsSignature,
|
||||
dataSignature,
|
||||
}),
|
||||
[configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature]
|
||||
const signatures = useMemo(
|
||||
() => buildSourceSignatures(deferredNodes as NodeLike[], deferredEdges as EdgeLike[], id, incomingIds),
|
||||
[deferredNodes, deferredEdges, id, incomingIds]
|
||||
)
|
||||
const {
|
||||
connectedNodeIds,
|
||||
configSignature,
|
||||
edgesSignature,
|
||||
variablesSignature,
|
||||
functionsSignature,
|
||||
dataSignature,
|
||||
sourceSignature,
|
||||
} = signatures
|
||||
|
||||
const lastRunSourceSignature = data?.lastRunSourceSignature
|
||||
|
||||
@@ -286,19 +270,31 @@ export function useRenderingNodeState(
|
||||
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lastManualRunTriggerRef = useRef(0)
|
||||
const manualRunTriggerSyncedRef = useRef(false)
|
||||
/** In auto mode, skip running when we have cache and we're in the grace window after mount (node became visible). */
|
||||
const mountTimeRef = useRef<number>(0)
|
||||
|
||||
const flowContext = useContext(FlowContext)
|
||||
const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? []
|
||||
const updateDataRef = useRef(updateData)
|
||||
updateDataRef.current = updateData
|
||||
const outputModeRef = useRef(outputMode)
|
||||
outputModeRef.current = outputMode
|
||||
const setNodesRef = useRef(setNodes)
|
||||
setNodesRef.current = setNodes
|
||||
const aiConnectionRef = useRef(aiConnection)
|
||||
aiConnectionRef.current = aiConnection
|
||||
const hasPendingInputs =
|
||||
effectiveUpdateMode === 'manual' &&
|
||||
!loading &&
|
||||
triggerNodeIds.length > 0 &&
|
||||
incomingIds.length > 0 &&
|
||||
sourceSignature !== lastRunSourceSignature
|
||||
|
||||
useSyncConnectionStatus(id, { updating: loading, error: error != null, paused: hasPendingInputs })
|
||||
const lifecycle = useMemo<RenderingNodeLifecycle>(
|
||||
() => ({ updating: loading, error: error != null }),
|
||||
[loading, error]
|
||||
)
|
||||
useSyncConnectionStatus(id, lifecycle)
|
||||
|
||||
useEffect(() => {
|
||||
if (mountTimeRef.current === 0) mountTimeRef.current = Date.now()
|
||||
if (incomingIds.length === 0) {
|
||||
setRenderedContent(null)
|
||||
setResolvedContent(null)
|
||||
@@ -343,6 +339,30 @@ export function useRenderingNodeState(
|
||||
lastManualRunTriggerRef.current = runTrigger
|
||||
}
|
||||
|
||||
// Auto mode: do not run when node just became visible (remount with cache). No pipeline run, no path/addTrigger.
|
||||
if (effectiveUpdateMode === 'auto') {
|
||||
const hasCache = Boolean(
|
||||
(data?.cachedRenderedContent ?? data?.cachedResolvedContent) as string | undefined
|
||||
)
|
||||
const signatureUnchanged =
|
||||
data?.lastRunSourceSignature != null && data.lastRunSourceSignature === sourceSignature
|
||||
const withinVisibilityGrace =
|
||||
mountTimeRef.current > 0 && Date.now() - mountTimeRef.current < VISIBILITY_GRACE_MS
|
||||
if (hasCache && (signatureUnchanged || withinVisibilityGrace)) {
|
||||
// Keep Logos cache in sync with node's cached output so the picker shows the latest when not re-running.
|
||||
const cached = (data?.cachedRenderedContent as string | undefined) ?? ''
|
||||
const isSvgContent = Boolean(cached.trim() && /<svg[\s>]/i.test(cached.trim()))
|
||||
recollectionActionsRef.current?.upsertRenderOutputToLogos?.({
|
||||
nodeId: id,
|
||||
label: id,
|
||||
type: isSvgContent ? 'image' : 'html',
|
||||
content: cached,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
runIdRef.current += 1
|
||||
const thisRunId = runIdRef.current
|
||||
const signatureForThisRun = sourceSignature
|
||||
@@ -350,6 +370,7 @@ export function useRenderingNodeState(
|
||||
let cancelled = false
|
||||
|
||||
const run = async () => {
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||
loadingStartedAtRef.current = Date.now()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
@@ -357,10 +378,11 @@ export function useRenderingNodeState(
|
||||
setResolvedContent(null)
|
||||
setStreamingMarkdown(null)
|
||||
setReasoningContent('')
|
||||
updateData({
|
||||
updateDataRef.current({
|
||||
cachedRenderedContent: undefined,
|
||||
cachedResolvedContent: undefined,
|
||||
cachedReasoningContent: undefined,
|
||||
cachedOutputValue: undefined,
|
||||
})
|
||||
try {
|
||||
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
|
||||
@@ -372,8 +394,8 @@ export function useRenderingNodeState(
|
||||
renderNodeId: id,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
setNodes: setNodes ?? undefined,
|
||||
aiConnection,
|
||||
setNodes: setNodesRef.current ?? undefined,
|
||||
aiConnection: aiConnectionRef.current,
|
||||
...(isAgentSource && {
|
||||
onStreamingStart: () => setStreamingMarkdown(''),
|
||||
onStreamingChunk: (chunk: string) =>
|
||||
@@ -391,10 +413,29 @@ export function useRenderingNodeState(
|
||||
if (thisRunId !== runIdRef.current) return
|
||||
setRenderedContent(htmlOrSvg)
|
||||
setError(null)
|
||||
updateData({
|
||||
// Use the rendering node's id as the label (same as the node name shown in Flux header).
|
||||
// Infer image from SVG content so we never store 'html' for diagram output.
|
||||
const isSvgContent = Boolean(htmlOrSvg?.trim() && /<svg[\s>]/i.test(htmlOrSvg.trim()))
|
||||
const cacheType = typeRenderer.outputType === 'image' || isSvgContent ? 'image' : 'html'
|
||||
recollectionActionsRef.current?.upsertRenderOutputToLogos?.({
|
||||
nodeId: id,
|
||||
label: id,
|
||||
type: cacheType,
|
||||
content: htmlOrSvg ?? '',
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
const mode = outputModeRef.current
|
||||
const cachedOutputValue =
|
||||
mode === 'string'
|
||||
? resolved
|
||||
: mode === 'image' && htmlOrSvg?.trim() && /<svg[\s>]/i.test(htmlOrSvg.trim())
|
||||
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(htmlOrSvg)}`
|
||||
: ''
|
||||
updateDataRef.current({
|
||||
cachedRenderedContent: htmlOrSvg,
|
||||
cachedResolvedContent: resolved,
|
||||
cachedReasoningContent: reasoning ?? '',
|
||||
cachedOutputValue,
|
||||
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
@@ -445,26 +486,20 @@ export function useRenderingNodeState(
|
||||
minLoadingTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
// Content updates only when connected-node data changes (sourceSignature) or explicit run/viewport.
|
||||
// React Flow updates (position, selection, context ref churn) do not trigger re-runs.
|
||||
// recollectionActions is intentionally omitted: we use recollectionActionsRef so slot/context
|
||||
// identity changes (e.g. after setFluxSlot) do not re-trigger this effect and cause an auto-run loop.
|
||||
}, [
|
||||
id,
|
||||
srcId,
|
||||
srcNode?.type,
|
||||
effectiveUpdateMode,
|
||||
runTrigger,
|
||||
sourceContent,
|
||||
sourceSignature,
|
||||
configSignature,
|
||||
edgesSignature,
|
||||
variablesSignature,
|
||||
functionsSignature,
|
||||
dataSignature,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
retryCount,
|
||||
updateData,
|
||||
setNodes,
|
||||
aiConnection,
|
||||
isAgentSource,
|
||||
incomingIds.length,
|
||||
])
|
||||
|
||||
@@ -585,7 +620,14 @@ export function useRenderingNodeState(
|
||||
const sourceData = useMemo(() => (srcNode?.data as Record<string, unknown>) ?? {}, [srcNode?.data])
|
||||
const sourceNodeType = (srcNode?.type as string) ?? null
|
||||
|
||||
const displayStatus = useMemo<NodeDisplayStatus>(
|
||||
() => getNodeDisplayStatus({ loading, error, hasContent: !!renderedContent }),
|
||||
[loading, error, renderedContent]
|
||||
)
|
||||
|
||||
return {
|
||||
displayStatus,
|
||||
lifecycle,
|
||||
incomingIds,
|
||||
effectiveUpdateMode,
|
||||
runTrigger,
|
||||
@@ -614,5 +656,7 @@ export function useRenderingNodeState(
|
||||
emptyStateAction,
|
||||
sourceData,
|
||||
sourceNodeType,
|
||||
outputMode,
|
||||
setOutputMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import React from 'react'
|
||||
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'
|
||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
|
||||
export type ImageOutputViewProps = {
|
||||
@@ -13,8 +11,6 @@ export type ImageOutputViewProps = {
|
||||
|
||||
export function ImageOutputView({
|
||||
state,
|
||||
selected,
|
||||
viewportFocused,
|
||||
onViewportFocus,
|
||||
onViewportBlur,
|
||||
}: ImageOutputViewProps) {
|
||||
@@ -25,59 +21,10 @@ export function ImageOutputView({
|
||||
onFocus={onViewportFocus}
|
||||
onBlur={onViewportBlur}
|
||||
>
|
||||
<TransformWrapper
|
||||
initialScale={1}
|
||||
initialPositionX={0}
|
||||
initialPositionY={0}
|
||||
minScale={0.2}
|
||||
maxScale={4}
|
||||
centerOnInit={false}
|
||||
panning={{ disabled: !selected && !viewportFocused }}
|
||||
wheel={{ disabled: !selected && !viewportFocused }}
|
||||
doubleClick={{ disabled: !selected && !viewportFocused }}
|
||||
>
|
||||
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||
<>
|
||||
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomIn()}
|
||||
className="react-flow__controls-button"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomOut()}
|
||||
className="react-flow__controls-button"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetTransform()}
|
||||
className="react-flow__controls-button"
|
||||
title="Reset view (fit all)"
|
||||
>
|
||||
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute inset-0 nodrag nopan overflow-hidden [&_.react-transform-component]:!w-full [&_.react-transform-component]:!h-full [&_.react-transform-wrapper]:!w-full [&_.react-transform-wrapper]:!h-full">
|
||||
<TransformComponent
|
||||
wrapperClass="!w-full !h-full"
|
||||
contentClass="nodrag nopan !w-full !h-full !block !min-h-0"
|
||||
>
|
||||
<div
|
||||
className="rendering-diagram absolute inset-0 w-full h-full min-w-0 min-h-0 nodrag nopan"
|
||||
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
<div
|
||||
className="absolute inset-0 nodrag nopan overflow-auto"
|
||||
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Renders markdown as React (JSX) using markdown-to-jsx.
|
||||
* Used for config node markdown preview to avoid dangerouslySetInnerHTML and support
|
||||
* safe, composable rendering. See https://markdown-to-jsx.quantizor.dev/
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import Markdown from 'markdown-to-jsx/react'
|
||||
|
||||
const MARKDOWN_PREVIEW_CLASS =
|
||||
'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm nodrag nopan overflow-auto [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
|
||||
|
||||
export type MarkdownJsxViewProps = {
|
||||
/** Raw markdown string (e.g. resolved content from config node). */
|
||||
markdown: string
|
||||
/** Optional wrapper className (defaults to MARKDOWN_PREVIEW_CLASS). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MarkdownJsxView({ markdown, className = MARKDOWN_PREVIEW_CLASS }: MarkdownJsxViewProps) {
|
||||
if (!markdown.trim()) {
|
||||
return <div className={className} />
|
||||
}
|
||||
return (
|
||||
<div className={className}>
|
||||
<Markdown>{markdown}</Markdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
|
||||
const MARKDOWN_CLASS = 'rendering-markdown p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
|
||||
const MARKDOWN_MAIN_CLASS = 'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
|
||||
|
||||
export type MarkdownOutputViewProps = {
|
||||
/** Final content: reasoning + think + main */
|
||||
state: RenderingNodeState
|
||||
/** When true, show streaming content (streamingThinkSplit, streamingPreviewHtml, streamingMarkdown) instead of final */
|
||||
streaming: boolean
|
||||
}
|
||||
|
||||
export function MarkdownOutputView({ state, streaming }: MarkdownOutputViewProps) {
|
||||
if (streaming) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{state.streamingThinkSplit.think ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Thinking
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{state.streamingPreviewHtml ? (
|
||||
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.think }} />
|
||||
) : (
|
||||
<pre className="rendering-markdown max-h-48 overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans text-muted-foreground">
|
||||
{state.streamingThinkSplit.think}
|
||||
</pre>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{state.streamingPreviewHtml ? (
|
||||
<div className={MARKDOWN_MAIN_CLASS} dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.main || state.streamingPreviewHtml }} />
|
||||
) : (
|
||||
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
|
||||
{state.streamingThinkSplit.main || state.streamingMarkdown}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{state.reasoningHtml ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Reasoning
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.reasoningHtml }} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{state.renderedThinkSplit.think ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Thinking
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.think }} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<div className={MARKDOWN_MAIN_CLASS} dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.main }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { javascript } from '@codemirror/lang-javascript'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
import { Copy } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { plantumlLanguage } from '@/lib/plantumlLanguage'
|
||||
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -15,21 +13,19 @@ export type RawOutputViewProps = {
|
||||
theme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
export function RawOutputView({ state, height, containerRef, theme }: RawOutputViewProps) {
|
||||
const extensions = useMemo(() => {
|
||||
const lang =
|
||||
state.rawLanguage === 'wireframe'
|
||||
? javascript()
|
||||
: state.rawLanguage === 'plantuml'
|
||||
? plantumlLanguage.extension
|
||||
: markdown()
|
||||
return [lang]
|
||||
}, [state.rawLanguage])
|
||||
function languageForRaw(rawLanguage: string): HighlightLanguage {
|
||||
if (rawLanguage === 'wireframe') return 'javascript'
|
||||
if (rawLanguage === 'plantuml') return 'plantuml'
|
||||
return 'markdown'
|
||||
}
|
||||
|
||||
export function RawOutputView({ state, height, containerRef }: RawOutputViewProps) {
|
||||
const language = useMemo(() => languageForRaw(state.rawLanguage), [state.rawLanguage])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input"
|
||||
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-auto border-t border-input"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -50,15 +46,16 @@ export function RawOutputView({ state, height, containerRef, theme }: RawOutputV
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
<CodeMirror
|
||||
<CodeEditor
|
||||
value={state.rawDisplayContent}
|
||||
height={`${height}px`}
|
||||
theme={theme}
|
||||
extensions={extensions}
|
||||
onValueChange={() => {}}
|
||||
language={language}
|
||||
readOnly
|
||||
editable={false}
|
||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
|
||||
minHeight={height}
|
||||
style={{ fontSize: 12 }}
|
||||
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
|
||||
preClassName="text-xs nodrag nopan min-h-0"
|
||||
className="min-h-0 w-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Renders final (non-streaming) markdown as HTML: reasoning + think collapsibles + main.
|
||||
* Used for agent final output and any other source that uses the marked → HTML pipeline.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
|
||||
const MARKDOWN_CLASS =
|
||||
'rendering-markdown p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
|
||||
const MARKDOWN_MAIN_CLASS =
|
||||
'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
|
||||
|
||||
export type StaticMarkdownHtmlViewProps = {
|
||||
state: RenderingNodeState
|
||||
}
|
||||
|
||||
export function StaticMarkdownHtmlView({ state }: StaticMarkdownHtmlViewProps) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{state.reasoningHtml ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Reasoning
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'}
|
||||
dangerouslySetInnerHTML={{ __html: state.reasoningHtml }}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{state.renderedThinkSplit.think ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Thinking
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'}
|
||||
dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.think }}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<div
|
||||
className={MARKDOWN_MAIN_CLASS}
|
||||
dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.main }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Renders streaming markdown from the agent node.
|
||||
* Uses marked → HTML for live preview during stream; think/reasoning in a collapsible.
|
||||
* Kept separate from config-node markdown preview (MarkdownJsxView) so streaming (agent)
|
||||
* and static markdown (config) are distinct code paths.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||
|
||||
const MARKDOWN_CLASS =
|
||||
'rendering-markdown p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
|
||||
const MARKDOWN_MAIN_CLASS =
|
||||
'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
|
||||
|
||||
export type StreamingMarkdownViewProps = {
|
||||
state: RenderingNodeState
|
||||
}
|
||||
|
||||
export function StreamingMarkdownView({ state }: StreamingMarkdownViewProps) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{state.streamingThinkSplit.think ? (
|
||||
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||
Thinking
|
||||
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{state.streamingPreviewHtml ? (
|
||||
<div
|
||||
className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'}
|
||||
dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.think }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="rendering-markdown max-h-48 overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans text-muted-foreground">
|
||||
{state.streamingThinkSplit.think}
|
||||
</pre>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{state.streamingPreviewHtml ? (
|
||||
<div
|
||||
className={MARKDOWN_MAIN_CLASS}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: state.streamingThinkSplit.main || state.streamingPreviewHtml,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
|
||||
{state.streamingThinkSplit.main || state.streamingMarkdown}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
/**
|
||||
* Output view components: "Display" step of the pipeline (see lib/graph/rendering.ts).
|
||||
* Which view is used comes from the source's outputType ('image' | 'html').
|
||||
*
|
||||
* - Streaming (agent): StreamingMarkdownView
|
||||
* - Config markdown preview: MarkdownJsxView (markdown-to-jsx)
|
||||
* - Agent final / other HTML: StaticMarkdownHtmlView
|
||||
* RenderingNode chooses the view directly; no shared dispatcher.
|
||||
*/
|
||||
|
||||
export { ImageOutputView } from './ImageOutputView'
|
||||
export { MarkdownOutputView } from './MarkdownOutputView'
|
||||
export { MarkdownJsxView } from './MarkdownJsxView'
|
||||
export { RawOutputView } from './RawOutputView'
|
||||
export { StaticMarkdownHtmlView } from './StaticMarkdownHtmlView'
|
||||
export { StreamingMarkdownView } from './StreamingMarkdownView'
|
||||
|
||||
export type { ImageOutputViewProps } from './ImageOutputView'
|
||||
export type { MarkdownOutputViewProps } from './MarkdownOutputView'
|
||||
export type { MarkdownJsxViewProps } from './MarkdownJsxView'
|
||||
export type { RawOutputViewProps } from './RawOutputView'
|
||||
export type { StaticMarkdownHtmlViewProps } from './StaticMarkdownHtmlView'
|
||||
export type { StreamingMarkdownViewProps } from './StreamingMarkdownView'
|
||||
|
||||
@@ -758,6 +758,7 @@ export {
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
sidebarMenuButtonVariants,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
|
||||
@@ -1,15 +1,159 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
/**
|
||||
* In-memory graph state. History is a past/future stack of inverse deltas (patches).
|
||||
* setNodes/setEdges push an inverse delta to past so undo restores only what changed.
|
||||
* setNodesSilent/setEdgesSilent update without history. setStateImmediate clears history.
|
||||
*/
|
||||
export type GraphState = { nodes: AppNode[]; edges: AppEdge[] }
|
||||
|
||||
/** Clone a single node (shallow clone with one-level data copy). */
|
||||
function cloneNode(n: AppNode): AppNode {
|
||||
return {
|
||||
...n,
|
||||
data:
|
||||
n.data && typeof n.data === 'object'
|
||||
? { ...(n.data as Record<string, unknown>) }
|
||||
: n.data,
|
||||
}
|
||||
}
|
||||
|
||||
/** Clone a single edge. */
|
||||
function cloneEdge(e: AppEdge): AppEdge {
|
||||
return { ...e }
|
||||
}
|
||||
|
||||
/** Full state clone (used for drag snapshot and when delta would be larger than full state). */
|
||||
function cloneState(state: GraphState): GraphState {
|
||||
return {
|
||||
nodes: state.nodes.map((n) => ({ ...n, data: n.data && typeof n.data === 'object' ? { ...n.data } : n.data })),
|
||||
edges: state.edges.map((e) => ({ ...e })),
|
||||
nodes: state.nodes.map(cloneNode),
|
||||
edges: state.edges.map(cloneEdge),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse delta: what to restore on undo (from next back to prev).
|
||||
* We only store nodes/edges that were removed or modified, not the full graph.
|
||||
*/
|
||||
export type HistoryDelta = {
|
||||
addedNodeIds: string[]
|
||||
restoredNodes: AppNode[]
|
||||
addedEdgeIds: string[]
|
||||
restoredEdges: AppEdge[]
|
||||
}
|
||||
|
||||
function nodeEquals(a: AppNode, b: AppNode): boolean {
|
||||
if (a.id !== b.id) return false
|
||||
if (a.type !== b.type) return false
|
||||
if (a.position?.x !== b.position?.x || a.position?.y !== b.position?.y) return false
|
||||
if (a.data !== b.data) return false
|
||||
if (JSON.stringify(a.style) !== JSON.stringify(b.style)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function edgeEquals(a: AppEdge, b: AppEdge): boolean {
|
||||
return a.id === b.id && a.source === b.source && a.target === b.target
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute inverse delta from prev to next: what to store so that
|
||||
* applyInverseDelta(next, delta) restores prev. Restored nodes/edges are cloned.
|
||||
*/
|
||||
function computeInverseDelta(prev: GraphState, next: GraphState): HistoryDelta {
|
||||
const prevNodeIds = new Set(prev.nodes.map((n) => n.id))
|
||||
const nextNodeIds = new Set(next.nodes.map((n) => n.id))
|
||||
const nextNodesById = new Map(next.nodes.map((n) => [n.id, n]))
|
||||
|
||||
const addedNodeIds: string[] = []
|
||||
const restoredNodes: AppNode[] = []
|
||||
|
||||
for (const id of nextNodeIds) {
|
||||
if (!prevNodeIds.has(id)) addedNodeIds.push(id)
|
||||
}
|
||||
for (const pNode of prev.nodes) {
|
||||
const nNode = nextNodesById.get(pNode.id)
|
||||
if (!nNode) {
|
||||
restoredNodes.push(cloneNode(pNode))
|
||||
} else if (!nodeEquals(pNode, nNode)) {
|
||||
restoredNodes.push(cloneNode(pNode))
|
||||
}
|
||||
}
|
||||
|
||||
const prevEdgeIds = new Set(prev.edges.map((e) => e.id))
|
||||
const nextEdgeIds = new Set(next.edges.map((e) => e.id))
|
||||
const nextEdgesById = new Map(next.edges.map((e) => [e.id, e]))
|
||||
|
||||
const addedEdgeIds: string[] = []
|
||||
const restoredEdges: AppEdge[] = []
|
||||
|
||||
for (const id of nextEdgeIds) {
|
||||
if (!prevEdgeIds.has(id)) addedEdgeIds.push(id)
|
||||
}
|
||||
for (const pEdge of prev.edges) {
|
||||
const nEdge = nextEdgesById.get(pEdge.id)
|
||||
if (!nEdge) {
|
||||
restoredEdges.push(cloneEdge(pEdge))
|
||||
} else if (!edgeEquals(pEdge, nEdge)) {
|
||||
restoredEdges.push(cloneEdge(pEdge))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
addedNodeIds,
|
||||
restoredNodes,
|
||||
addedEdgeIds,
|
||||
restoredEdges,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an inverse delta to current state to get the previous state (undo).
|
||||
*/
|
||||
function applyInverseDelta(current: GraphState, delta: HistoryDelta): GraphState {
|
||||
const addedNodeIdsSet = new Set(delta.addedNodeIds)
|
||||
const restoredNodesById = new Map(delta.restoredNodes.map((n) => [n.id, n]))
|
||||
|
||||
const baseNodes = current.nodes.filter((n) => !addedNodeIdsSet.has(n.id))
|
||||
const nodeOrder = baseNodes.map((n) => n.id)
|
||||
const byId = new Map(baseNodes.map((n) => [n.id, n]))
|
||||
for (const r of delta.restoredNodes) {
|
||||
byId.set(r.id, r)
|
||||
}
|
||||
const nodes: AppNode[] = nodeOrder
|
||||
.map((id) => byId.get(id))
|
||||
.filter((n): n is AppNode => n != null)
|
||||
const restoredIdsInBase = new Set(nodes.map((n) => n.id))
|
||||
for (const r of delta.restoredNodes) {
|
||||
if (!restoredIdsInBase.has(r.id)) {
|
||||
nodes.push(r)
|
||||
restoredIdsInBase.add(r.id)
|
||||
}
|
||||
}
|
||||
|
||||
const addedEdgeIdsSet = new Set(delta.addedEdgeIds)
|
||||
const restoredEdgesById = new Map(delta.restoredEdges.map((e) => [e.id, e]))
|
||||
|
||||
const baseEdges = current.edges.filter((e) => !addedEdgeIdsSet.has(e.id))
|
||||
const edgeOrder = baseEdges.map((e) => e.id)
|
||||
const edgesById = new Map(baseEdges.map((e) => [e.id, e]))
|
||||
for (const r of delta.restoredEdges) {
|
||||
edgesById.set(r.id, r)
|
||||
}
|
||||
const edges: AppEdge[] = edgeOrder
|
||||
.map((id) => edgesById.get(id))
|
||||
.filter((e): e is AppEdge => e != null)
|
||||
const restoredEdgeIdsInBase = new Set(edges.map((e) => e.id))
|
||||
for (const r of delta.restoredEdges) {
|
||||
if (!restoredEdgeIdsInBase.has(r.id)) {
|
||||
edges.push(r)
|
||||
restoredEdgeIdsInBase.add(r.id)
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 100
|
||||
|
||||
export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges: AppEdge[]) {
|
||||
@@ -17,30 +161,44 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
|
||||
const [edges, setEdgesState] = useState<AppEdge[]>(initialEdges)
|
||||
const [historySizes, setHistorySizes] = useState({ past: 0, future: 0 })
|
||||
|
||||
const pastRef = useRef<GraphState[]>([])
|
||||
const futureRef = useRef<GraphState[]>([])
|
||||
const pastRef = useRef<HistoryDelta[]>([])
|
||||
const futureRef = useRef<HistoryDelta[]>([])
|
||||
const preDragRef = useRef<GraphState | null>(null)
|
||||
const nodesRef = useRef(nodes)
|
||||
const edgesRef = useRef(edges)
|
||||
nodesRef.current = nodes
|
||||
edgesRef.current = edges
|
||||
|
||||
const pushToPast = useCallback((state: GraphState) => {
|
||||
const pushDeltaToPast = useCallback((delta: HistoryDelta) => {
|
||||
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
||||
pastRef.current.push(cloneState(state))
|
||||
pastRef.current.push(delta)
|
||||
futureRef.current = []
|
||||
setHistorySizes({ past: pastRef.current.length, future: 0 })
|
||||
}, [])
|
||||
|
||||
const setNodes = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [pushToPast])
|
||||
const setNodes = useCallback(
|
||||
(updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
const nextNodes = typeof updater === 'function' ? updater(prev.nodes) : updater
|
||||
const next = { nodes: nextNodes, edges: prev.edges }
|
||||
const delta = computeInverseDelta(prev, next)
|
||||
pushDeltaToPast(delta)
|
||||
setNodesState(nextNodes)
|
||||
},
|
||||
[pushDeltaToPast]
|
||||
)
|
||||
|
||||
const setEdges = useCallback((updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [pushToPast])
|
||||
const setEdges = useCallback(
|
||||
(updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => {
|
||||
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
const nextEdges = typeof updater === 'function' ? updater(prev.edges) : updater
|
||||
const next = { nodes: prev.nodes, edges: nextEdges }
|
||||
const delta = computeInverseDelta(prev, next)
|
||||
pushDeltaToPast(delta)
|
||||
setEdgesState(nextEdges)
|
||||
},
|
||||
[pushDeltaToPast]
|
||||
)
|
||||
|
||||
const setNodesSilent = useCallback((updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => {
|
||||
setNodesState(typeof updater === 'function' ? updater : () => updater)
|
||||
@@ -50,12 +208,17 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
|
||||
setEdgesState(typeof updater === 'function' ? updater : () => updater)
|
||||
}, [])
|
||||
|
||||
const applyGraph = useCallback((updater: (state: GraphState) => GraphState) => {
|
||||
pushToPast({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
const next = updater({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
setNodesState(next.nodes)
|
||||
setEdgesState(next.edges)
|
||||
}, [pushToPast])
|
||||
const applyGraph = useCallback(
|
||||
(updater: (state: GraphState) => GraphState) => {
|
||||
const prev = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
const next = updater(prev)
|
||||
const delta = computeInverseDelta(prev, next)
|
||||
pushDeltaToPast(delta)
|
||||
setNodesState(next.nodes)
|
||||
setEdgesState(next.edges)
|
||||
},
|
||||
[pushDeltaToPast]
|
||||
)
|
||||
|
||||
const saveForDragEnd = useCallback(() => {
|
||||
preDragRef.current = cloneState({ nodes: nodesRef.current, edges: edgesRef.current })
|
||||
@@ -63,29 +226,36 @@ export function useGraphStateWithHistory(initialNodes: AppNode[], initialEdges:
|
||||
|
||||
const commitDragEnd = useCallback(() => {
|
||||
if (preDragRef.current) {
|
||||
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
||||
pastRef.current.push(preDragRef.current)
|
||||
futureRef.current = []
|
||||
const snapshot = preDragRef.current
|
||||
preDragRef.current = null
|
||||
const current = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
const delta = computeInverseDelta(snapshot, current)
|
||||
pastRef.current = pastRef.current.slice(-(MAX_HISTORY - 1))
|
||||
pastRef.current.push(delta)
|
||||
futureRef.current = []
|
||||
setHistorySizes({ past: pastRef.current.length, future: 0 })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (pastRef.current.length === 0) return
|
||||
const prev = pastRef.current.pop()!
|
||||
futureRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
|
||||
setNodesState(prev.nodes)
|
||||
setEdgesState(prev.edges)
|
||||
const delta = pastRef.current.pop()!
|
||||
const current = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
const prevState = applyInverseDelta(current, delta)
|
||||
futureRef.current = futureRef.current.slice(-(MAX_HISTORY - 1))
|
||||
futureRef.current.push(cloneState(current))
|
||||
setNodesState(prevState.nodes)
|
||||
setEdgesState(prevState.edges)
|
||||
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
|
||||
}, [])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (futureRef.current.length === 0) return
|
||||
const next = futureRef.current.pop()!
|
||||
pastRef.current.push(cloneState({ nodes: nodesRef.current, edges: edgesRef.current }))
|
||||
setNodesState(next.nodes)
|
||||
setEdgesState(next.edges)
|
||||
const nextState = futureRef.current.pop()!
|
||||
const current = { nodes: nodesRef.current, edges: edgesRef.current }
|
||||
pastRef.current.push(computeInverseDelta(nextState, current))
|
||||
setNodesState(nextState.nodes)
|
||||
setEdgesState(nextState.edges)
|
||||
setHistorySizes({ past: pastRef.current.length, future: futureRef.current.length })
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { observeResize } from '@/lib/sharedResizeObserver'
|
||||
|
||||
/**
|
||||
* Returns the height of the observed element, updating when it resizes (e.g. node resize).
|
||||
* Used to give CodeMirror and other components an explicit height that tracks their container.
|
||||
* Uses a shared ResizeObserver to reduce memory (one observer for all elements).
|
||||
* @param defaultHeight - Height used until the element is measured.
|
||||
* @param deps - Optional dependency array; when the ref is attached to a conditionally mounted element, pass deps (e.g. [viewMode]) so the effect re-runs when the element appears.
|
||||
*/
|
||||
export function useResizeHeight(
|
||||
defaultHeight: number,
|
||||
deps?: React.DependencyList
|
||||
defaultHeight: number,
|
||||
deps?: React.DependencyList
|
||||
): [number, React.RefObject<HTMLDivElement | null>] {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const [height, setHeight] = useState(defaultHeight)
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const [height, setHeight] = useState(defaultHeight)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry?.contentRect.height != null && entry.contentRect.height > 0) {
|
||||
setHeight(entry.contentRect.height)
|
||||
}
|
||||
})
|
||||
ro.observe(el)
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
return () => ro.disconnect()
|
||||
}, deps ?? [])
|
||||
const unobserve = observeResize(el, (size) => {
|
||||
if (size.height > 0) setHeight(size.height)
|
||||
})
|
||||
// Rely on ResizeObserver for initial size to avoid forced synchronous layout (getBoundingClientRect).
|
||||
return unobserve
|
||||
}, deps ?? [])
|
||||
|
||||
return [height, ref]
|
||||
return [height, ref]
|
||||
}
|
||||
|
||||
52
frontend/src/hooks/useSimpleEditorInsert.ts
Normal file
52
frontend/src/hooks/useSimpleEditorInsert.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
export type InsertPosition = 'prepend' | 'append' | 'cursor'
|
||||
|
||||
/**
|
||||
* Returns a stable insertAt(insertText, mode) that inserts text into the simple code editor
|
||||
* (textarea identified by textareaId) at the given position, then calls onChange with the new content.
|
||||
* Cursor is restored after the next render via a scheduled effect.
|
||||
*/
|
||||
export function useSimpleEditorInsert(
|
||||
textareaId: string,
|
||||
currentContent: string,
|
||||
onChange: (value: string) => void
|
||||
): (insertText: string, mode: InsertPosition) => void {
|
||||
const pendingCursorRef = useRef<number | null>(null)
|
||||
|
||||
const insertAt = useCallback(
|
||||
(insertText: string, mode: InsertPosition) => {
|
||||
const ta = document.getElementById(textareaId) as HTMLTextAreaElement | null
|
||||
let start: number
|
||||
let end: number
|
||||
if (ta) {
|
||||
start = mode === 'cursor' ? ta.selectionStart : mode === 'prepend' ? 0 : ta.value.length
|
||||
end = mode === 'cursor' ? ta.selectionEnd : start
|
||||
} else {
|
||||
start = mode === 'prepend' ? 0 : currentContent.length
|
||||
end = start
|
||||
}
|
||||
const newValue =
|
||||
currentContent.slice(0, start) + insertText + currentContent.slice(end)
|
||||
const nextCursor = start + insertText.length
|
||||
pendingCursorRef.current = nextCursor
|
||||
onChange(newValue)
|
||||
// Restore cursor after React re-renders
|
||||
if (ta) {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(textareaId) as HTMLTextAreaElement | null
|
||||
if (el && pendingCursorRef.current !== null) {
|
||||
el.focus()
|
||||
el.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current)
|
||||
pendingCursorRef.current = null
|
||||
}
|
||||
})
|
||||
} else {
|
||||
pendingCursorRef.current = null
|
||||
}
|
||||
},
|
||||
[textareaId, currentContent, onChange]
|
||||
)
|
||||
|
||||
return insertAt
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user