Compare commits
46 Commits
a0bf9c6b70
...
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 |
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
|
# 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
|
## Project layout
|
||||||
|
|
||||||
```
|
```text
|
||||||
my-app/
|
zui/
|
||||||
├── frontend/ # React app (Vite, TypeScript)
|
├── frontend/ # React SPA (Vite, TypeScript)
|
||||||
│ ├── Dockerfile # Multi-stage for prod (build → Nginx)
|
|
||||||
│ ├── Dockerfile.dev # Dev with hot reload
|
|
||||||
│ ├── src/
|
│ ├── 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
|
│ ├── nginx.conf
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── backend/ # Node.js/Express API
|
├── backend/ # Node.js/Express API
|
||||||
│ ├── Dockerfile
|
|
||||||
│ ├── src/
|
│ ├── 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
|
│ └── package.json
|
||||||
|
├── docs/
|
||||||
|
│ ├── ARCHITECTURE.md
|
||||||
|
│ ├── PERFORMANCE.md
|
||||||
|
│ ├── NODE_TYPE_EXTENSIBILITY_PROPOSAL.md
|
||||||
|
│ └── CODE_REVIEW_CHECKLIST.md
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── .dockerignore
|
|
||||||
└── .gitignore
|
└── .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)
|
## Run locally (dev)
|
||||||
|
|
||||||
**Frontend only:**
|
**Frontend only** (no AI agent):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
# → http://localhost:3000
|
# → http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend + backend** (so the app can show “Backend API: N todos”):
|
**Frontend + backend** (for AI agent and health check):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 – backend
|
# Terminal 1 – backend
|
||||||
@@ -45,7 +64,7 @@ cd backend && npm install && npm run dev
|
|||||||
|
|
||||||
# Terminal 2 – frontend
|
# Terminal 2 – frontend
|
||||||
cd frontend && npm install && npm run dev
|
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
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Frontend**: http://localhost:3000 (Nginx; `/api/*` proxied to backend).
|
- **Frontend**: <http://localhost:3000> (Nginx; `/api/*` proxied to backend).
|
||||||
- **Backend**: http://localhost:8080 (Express).
|
- **Backend**: <http://localhost:8080> (Express).
|
||||||
|
|
||||||
Environment variables (backend service):
|
Environment variables (backend service):
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|-------------|----------------------------|-------------|
|
|------------------|---------------------------|--------------------------------------------|
|
||||||
| `PORT` | `8080` | Backend listen port. |
|
| `PORT` | `8080` | Backend listen port. |
|
||||||
| `CORS_ORIGIN` | `http://localhost:3000` | Allowed origin for CORS. |
|
| `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.
|
For self-hosting (e.g., Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL and put Caddy or Nginx in front for TLS.
|
||||||
|
|
||||||
**Dev with Docker (frontend hot reload):** use `frontend/Dockerfile.dev` and mount `./frontend` as a volume, or run `cd frontend && npm run dev` locally.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|--------|-------------|
|
|--------------------------------------|----------------------------------------|
|
||||||
| `cd frontend && npm run dev` | Vite dev server. |
|
| `cd frontend && npm run dev` | Vite dev server (port 3000). |
|
||||||
| `cd frontend && npm run build` | Build frontend for production. |
|
| `cd frontend && npm run build` | Build frontend for production. |
|
||||||
| `cd frontend && npm run preview` | Preview production build. |
|
| `cd frontend && npm run preview` | Preview production build. |
|
||||||
| `cd backend && npm run dev` | Backend with `--watch`. |
|
| `cd frontend && npm run test` | Run tests in watch mode (Vitest). |
|
||||||
| `cd backend && npm start` | Backend production run. |
|
| `cd frontend && npm run test:run` | Run tests once (CI). |
|
||||||
| `docker compose up --build` | Run frontend + backend in Docker. |
|
| `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 |
|
| Method | Path | Body / Response |
|
||||||
|--------|------|-------------|
|
|--------|-----------------------|---------------------------------------------------------------------------------|
|
||||||
| GET | `/api/todos` | List all todos. |
|
| GET | `/health` | `{ ok: true, timestamp: number }` — health check for Docker/orchestration. |
|
||||||
| GET | `/api/todos/:id` | Get one todo. |
|
| POST | `/api/agent` | Body `{ prompt, context?, contextNodes? }` → `{ markdown }` (one-shot). |
|
||||||
| POST | `/api/todos` | Create (`{ "title": "...", "completed": false }`). |
|
| POST | `/api/agent/stream` | Body `{ prompt, context?, contextNodes? }` → SSE text stream (streaming). |
|
||||||
| 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Agent node (local LLM or OpenAI)
|
## 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.
|
### Local LLM (e.g. LM Studio)
|
||||||
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:
|
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
|
```bash
|
||||||
export AI_BASE_URL=http://localhost:1234/v1
|
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 # optional; matches the name shown in LM Studio
|
||||||
export AI_MODEL=your-model-name
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 |
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — architecture overview, key patterns, module map
|
||||||
|----------|--------------|-------------|
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — coding standards, branch workflow, test commands
|
||||||
| `AI_BASE_URL` | Local LLM (LM Studio, Ollama, etc.) | OpenAI-compatible base URL, e.g. `http://localhost:1234/v1`. |
|
- [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) — bottleneck analysis and improvement plan
|
||||||
| `AI_MODEL` | Optional | Model id (for local: use the name shown in LM Studio; for OpenAI: e.g. `gpt-4o-mini`). |
|
- [docs/CODE_REVIEW_CHECKLIST.md](docs/CODE_REVIEW_CHECKLIST.md) — PR review checklist
|
||||||
| `OPENAI_API_KEY` | OpenAI only | Your OpenAI API key. Not required when using `AI_BASE_URL` only. |
|
- [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",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0"
|
"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": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
@@ -103,6 +110,448 @@
|
|||||||
"zod": "^3.23.8"
|
"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": {
|
"node_modules/@opentelemetry/api": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
@@ -112,12 +561,120 @@
|
|||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/@types/diff-match-patch": {
|
||||||
"version": "1.0.36",
|
"version": "1.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
|
||||||
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
|
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -392,6 +949,48 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -489,6 +1088,21 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -535,6 +1149,19 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -853,6 +1480,16 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -1051,6 +1688,26 @@
|
|||||||
"node": ">=0.6"
|
"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": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -1064,6 +1721,27 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
"name": "zui-backend",
|
"name": "zui-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Minimal Express API for Zui (todos CRUD, no DB)",
|
"description": "Minimal Express API for Zui (agent, health; no DB)",
|
||||||
"main": "src/index.js",
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.ts",
|
||||||
"dev": "node --watch src/index.js"
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -16,5 +18,12 @@
|
|||||||
"@ai-sdk/openai": "^1.0.0",
|
"@ai-sdk/openai": "^1.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0"
|
"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.*
|
||||||
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.
|
||||||
@@ -57,7 +57,7 @@ This doc summarizes recent improvements and suggested next steps for readability
|
|||||||
- **StoredGraphState** – shape for save/load (version + nodes + edges).
|
- **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.
|
- **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.
|
- **useGraphStateWithHistory**: JSDoc explains history (past/future), setNodes vs setNodesSilent, setStateImmediate.
|
||||||
- **projectGraphStorage**: Uses **StoredGraphState** from state.ts; re-exports type. Doc references state flow.
|
- **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.
|
- **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.
|
- **RenderingNode**: Uses **state.displayStatus** for NodeStatusIndicator instead of computing status locally.
|
||||||
- **nodeLifecycle** and **connectionStatus**: Docs reference state.ts for overall state flow.
|
- **nodeLifecycle** and **connectionStatus**: Docs reference state.ts for overall state flow.
|
||||||
|
|||||||
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>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>React Flow + shadcn Canvas</title>
|
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
||||||
</head>
|
<title>ZOË | Kosmos</title>
|
||||||
<body>
|
</head>
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<body>
|
||||||
</body>
|
<div id="root"></div>
|
||||||
</html>
|
<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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@blocknote/core": "0.36.1",
|
||||||
"@codemirror/lang-markdown": "^6.5.0",
|
"@blocknote/react": "0.36.1",
|
||||||
|
"@blocknote/shadcn": "0.36.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
@@ -25,24 +28,32 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@uiw/react-codemirror": "^4.25.7",
|
"@types/prismjs": "^1.26.6",
|
||||||
"@wireweave/core": "^2.6.0",
|
"@wireweave/core": "^2.6.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"graphology": "^0.26.0",
|
||||||
|
"graphology-traversal": "^0.3.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
"markdown-to-jsx": "^9.7.9",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nunjucks": "^3.2.4",
|
"nunjucks": "^3.2.4",
|
||||||
|
"prism-react-renderer": "^2.4.1",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-arborist": "^3.4.3",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-router-dom": "^6.28.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",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
@@ -52,6 +63,7 @@
|
|||||||
"shadcn": "^4.0.0",
|
"shadcn": "^4.0.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.0.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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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.
|
* 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'
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||||
@@ -31,11 +31,13 @@ import {
|
|||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
|
import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
|
||||||
import { getExampleGraph } from '@/app/canvas/canvasGraphUtils'
|
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
|
||||||
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
||||||
import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
|
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
|
||||||
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
|
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 { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||||||
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
||||||
@@ -65,7 +67,7 @@ import {
|
|||||||
} from '@/lib/graph/nodeRegistry'
|
} from '@/lib/graph/nodeRegistry'
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { 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 SNAP_GRID: [number, number] = [15, 15]
|
||||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||||
@@ -74,12 +76,60 @@ const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
|||||||
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
||||||
})
|
})
|
||||||
|
|
||||||
function FlowFitViewOnLoad() {
|
function FlowFitViewOnLoad({ disabled }: { disabled?: boolean }) {
|
||||||
const nodesInitialized = useNodesInitialized()
|
const nodesInitialized = useNodesInitialized()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (nodesInitialized) fitView?.({ duration: 200 })
|
if (disabled || !nodesInitialized) return
|
||||||
}, [nodesInitialized, fitView])
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,11 +198,13 @@ function FullscreenNodeContent({ node }: { node: AppNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CanvasPageProps = {
|
export type CanvasPageProps = {
|
||||||
/** Optional project id for future per-project graph loading */
|
/** Optional recollection id for per-recollection graph loading */
|
||||||
projectId?: string
|
recollectionId?: string
|
||||||
|
/** Optional node id to focus when the canvas loads (e.g. from Katalogos artifacts). */
|
||||||
|
focusNodeId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasPage({ projectId }: CanvasPageProps) {
|
export function CanvasPage({ recollectionId, focusNodeId }: CanvasPageProps) {
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const { showMinimap } = usePlatform()
|
const { showMinimap } = usePlatform()
|
||||||
const {
|
const {
|
||||||
@@ -169,9 +221,10 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
setStateImmediate,
|
setStateImmediate,
|
||||||
} = useCanvasGraph(projectId)
|
save,
|
||||||
|
saveStatus,
|
||||||
|
} = useCanvasGraph(recollectionId)
|
||||||
|
|
||||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
|
||||||
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
const [rfInstance, setRfInstance] = React.useState<unknown>(null)
|
||||||
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
const [renamingNodeId, setRenamingNodeId] = React.useState<string | null>(null)
|
||||||
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
|
const [connectionFrom, setConnectionFrom] = React.useState<{ nodeId: string; sourceHandle?: string } | null>(null)
|
||||||
@@ -185,20 +238,30 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const [isSelecting, setIsSelecting] = React.useState(false)
|
const [isSelecting, setIsSelecting] = React.useState(false)
|
||||||
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||||||
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
|
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
|
||||||
const connectionPath = useCanvasConnectionPath(edges)
|
|
||||||
|
const graphApplyTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const GRAPH_APPLY_DEBOUNCE_MS = 300
|
||||||
|
useEffect(() => {
|
||||||
|
if (graphApplyTimeoutRef.current) clearTimeout(graphApplyTimeoutRef.current)
|
||||||
|
graphApplyTimeoutRef.current = setTimeout(() => {
|
||||||
|
graphApplyTimeoutRef.current = null
|
||||||
|
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
|
||||||
|
}, GRAPH_APPLY_DEBOUNCE_MS)
|
||||||
|
return () => {
|
||||||
|
if (graphApplyTimeoutRef.current) {
|
||||||
|
clearTimeout(graphApplyTimeoutRef.current)
|
||||||
|
graphApplyTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [nodes, edges])
|
||||||
|
|
||||||
|
const connectionPath = useCanvasConnectionPathFromStore()
|
||||||
|
|
||||||
const nodesRef = useRef(nodes)
|
const nodesRef = useRef(nodes)
|
||||||
nodesRef.current = nodes
|
nodesRef.current = nodes
|
||||||
|
const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] })
|
||||||
const [apiTodosCount, setApiTodosCount] = React.useState<number | null>(null)
|
graphRef.current.nodes = nodes
|
||||||
React.useEffect(() => {
|
graphRef.current.edges = edges
|
||||||
fetch('/api/todos')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data: unknown) => {
|
|
||||||
if (Array.isArray(data)) setApiTodosCount(data.length)
|
|
||||||
})
|
|
||||||
.catch(() => setApiTodosCount(-1))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
|
const pendingChangesRef = useRef<NodeChange<Node>[]>([])
|
||||||
const rafRef = useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
@@ -218,7 +281,22 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
rafRef.current = requestAnimationFrame(() => {
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
rafRef.current = null
|
rafRef.current = null
|
||||||
const toApply = pendingChangesRef.current.splice(0, pendingChangesRef.current.length)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -244,7 +322,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onConnect = useCallback(
|
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]
|
[setEdges]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -290,55 +373,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
|
const onNodeDragStart = useCallback(() => saveForDragEnd(), [saveForDragEnd])
|
||||||
const onNodeDragStop = useCallback(() => commitDragEnd(), [commitDragEnd])
|
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 handleLoadExample = useCallback(() => {
|
||||||
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
const { nodes: exampleNodes, edges: exampleEdges } = getExampleGraph()
|
||||||
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
|
setStateImmediate({ nodes: exampleNodes, edges: exampleEdges })
|
||||||
toast.success('Example loaded')
|
toast.success('Example loaded')
|
||||||
}, [setStateImmediate])
|
}, [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(
|
const selectedNodes = useMemo(
|
||||||
() => nodes.filter((n) => (n as Node & { selected?: boolean }).selected),
|
() => nodes.filter((n) => (n as Node & { selected?: boolean }).selected),
|
||||||
[nodes]
|
[nodes]
|
||||||
@@ -392,9 +432,60 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
flowActionsRef.current?.pasteAtViewportCenter?.()
|
flowActionsRef.current?.pasteAtViewportCenter?.()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const { setFluxSlot, onImport } = useRecollectionActions()
|
||||||
|
|
||||||
|
const onRefreshFromStore = useCallback(
|
||||||
|
(graph: StoredGraphState) => {
|
||||||
|
const nodes = graph.nodes as AppNode[]
|
||||||
|
const edges = backfillEdgeTargetTypes(nodes, graph.edges as AppEdge[])
|
||||||
|
setStateImmediate({ nodes, edges })
|
||||||
|
},
|
||||||
|
[setStateImmediate]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const slot = {
|
||||||
|
saveStatus,
|
||||||
|
onSave: recollectionId
|
||||||
|
? () => {
|
||||||
|
save()
|
||||||
|
toast.success('Saved')
|
||||||
|
}
|
||||||
|
: () => {},
|
||||||
|
canSave: Boolean(recollectionId),
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
onRefreshFromStore,
|
||||||
|
onDuplicate: handleDuplicate,
|
||||||
|
onCopy: handleCopy,
|
||||||
|
onPaste: handlePaste,
|
||||||
|
canDuplicate: selectedNodes.length > 0,
|
||||||
|
canCopy: selectedNodes.length === 1,
|
||||||
|
onFitView: () => flowActionsRef.current?.fitView?.(),
|
||||||
|
}
|
||||||
|
setFluxSlot(slot)
|
||||||
|
return () => setFluxSlot(null)
|
||||||
|
}, [
|
||||||
|
setFluxSlot,
|
||||||
|
saveStatus,
|
||||||
|
recollectionId,
|
||||||
|
save,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
onRefreshFromStore,
|
||||||
|
handleDuplicate,
|
||||||
|
handleCopy,
|
||||||
|
handlePaste,
|
||||||
|
selectedNodes.length,
|
||||||
|
])
|
||||||
|
|
||||||
const graphContextValue = useMemo(
|
const graphContextValue = useMemo(
|
||||||
() => ({ nodes, setNodes, edges, setEdges }),
|
() => ({ setNodes, setEdges, graphRef, edges }),
|
||||||
[nodes, setNodes, edges, setEdges]
|
[setNodes, setEdges, edges]
|
||||||
)
|
)
|
||||||
const connectionPathContextValue = useMemo(
|
const connectionPathContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -438,18 +529,36 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const nodesForFlow = useMemo(
|
const prevNodesRef = useRef<AppNode[]>([])
|
||||||
() =>
|
const prevNodesForFlowRef = useRef<Node[]>([])
|
||||||
nodes.map((n) => ({
|
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,
|
...n,
|
||||||
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
|
className: [n.className, 'nowheel'].filter(Boolean).join(' '),
|
||||||
})),
|
}
|
||||||
[nodes]
|
})
|
||||||
)
|
prevNodesRef.current = nodes
|
||||||
|
prevNodesForFlowRef.current = result
|
||||||
|
return result
|
||||||
|
}, [nodes])
|
||||||
const edgesForFlow = useMemo(
|
const edgesForFlow = useMemo(
|
||||||
() =>
|
() =>
|
||||||
edges.map((e) => {
|
edges.map((e) => {
|
||||||
const targetType = nodes.find((nd) => nd.id === e.target)?.type ?? ''
|
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 connectionLabel = getConnectionLabelForTarget(targetType)
|
||||||
const baseData =
|
const baseData =
|
||||||
typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
|
typeof e.data === 'object' && e.data !== null ? (e.data as Record<string, unknown>) : {}
|
||||||
@@ -458,7 +567,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
data: { ...baseData, connectionLabel },
|
data: { ...baseData, connectionLabel },
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[edges, nodes]
|
[edges]
|
||||||
)
|
)
|
||||||
|
|
||||||
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
|
const onContextMenuCapture = useCallback((ev: React.MouseEvent) => {
|
||||||
@@ -481,8 +590,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const clientX = click?.clientX ?? window.innerWidth / 2
|
const clientX = click?.clientX ?? window.innerWidth / 2
|
||||||
const clientY = click?.clientY ?? window.innerHeight / 2
|
const clientY = click?.clientY ?? window.innerHeight / 2
|
||||||
try {
|
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 } }
|
type ScreenToFlow = (p: { x: number; y: number }) => { x: number; y: number }
|
||||||
const screenToFlow = inst.screenToFlowPosition ?? inst.project
|
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 })
|
const p = screenToFlow?.call(rfInstance, { x: clientX, y: clientY })
|
||||||
return p ? snapToGrid(p.x, p.y) : null
|
return p ? snapToGrid(p.x, p.y) : null
|
||||||
} catch {
|
} catch {
|
||||||
@@ -584,33 +694,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
<div role="status" aria-live="polite" aria-atomic className="sr-only">
|
<div role="status" aria-live="polite" aria-atomic className="sr-only">
|
||||||
{ariaAnnouncement}
|
{ariaAnnouncement}
|
||||||
</div>
|
</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 relative flex flex-col">
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
<GraphContext.Provider value={graphContextValue}>
|
<GraphContext.Provider value={graphContextValue}>
|
||||||
@@ -636,11 +719,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
<EmptyTitle>Start adding a new node!</EmptyTitle>
|
<EmptyTitle>Start adding a new node!</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
Right‑click to add nodes. <br />
|
Right‑click to add nodes. <br />
|
||||||
Import a project or paste a node.
|
Import a recollection or paste a node.
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
<EmptyContent className="flex-row flex-wrap justify-center gap-2">
|
<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" />
|
<FolderOpen className="size-4" />
|
||||||
Import…
|
Import…
|
||||||
</Button>
|
</Button>
|
||||||
@@ -655,7 +738,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
)}
|
)}
|
||||||
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
<ReactFlowProvider initialNodes={nodes} initialEdges={edges} fitView>
|
||||||
<ViewportDisplayProvider>
|
<ViewportDisplayProvider>
|
||||||
<FlowFitViewOnLoad />
|
<FlowFitViewOnLoad disabled={Boolean(focusNodeId)} />
|
||||||
|
<FocusNodeOnLoad focusNodeId={focusNodeId} />
|
||||||
<FlowKeyboardShortcuts />
|
<FlowKeyboardShortcuts />
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
className={isPanning ? 'react-flow--panning' : isSelecting ? 'react-flow--selecting' : undefined}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -17,25 +17,34 @@ export { ViewportDisplayContext }
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Must be rendered inside ReactFlowProvider. Subscribes to viewport once,
|
* Must be rendered inside ReactFlowProvider. Subscribes to viewport once,
|
||||||
* maps zoom to displayMode with hysteresis, and provides it to descendants.
|
* 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 }) {
|
export function ViewportDisplayProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { zoom } = useViewport()
|
const { zoom } = useViewport()
|
||||||
const [displayMode, setDisplayMode] = useState<ViewportDisplayMode>(() =>
|
const [displayMode, setDisplayMode] = useState<ViewportDisplayMode>(() =>
|
||||||
zoom <= CONTEXTUAL_ZOOM_THRESHOLD ? 'compact' : 'full'
|
zoom <= CONTEXTUAL_ZOOM_THRESHOLD ? 'compact' : 'full'
|
||||||
)
|
)
|
||||||
const lastRef = useRef(displayMode)
|
const lastModeRef = useRef(displayMode)
|
||||||
|
const zoomRef = useRef(zoom)
|
||||||
|
const rafRef = useRef<number | null>(null)
|
||||||
|
zoomRef.current = zoom
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
|
if (rafRef.current !== null) return
|
||||||
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
let next: ViewportDisplayMode = lastRef.current
|
rafRef.current = null
|
||||||
if (zoom <= low) next = 'compact'
|
const z = zoomRef.current
|
||||||
else if (zoom >= high) next = 'full'
|
const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS
|
||||||
if (next !== lastRef.current) {
|
const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS
|
||||||
lastRef.current = next
|
let next: ViewportDisplayMode = lastModeRef.current
|
||||||
setDisplayMode(next)
|
if (z <= low) next = 'compact'
|
||||||
}
|
else if (z >= high) next = 'full'
|
||||||
|
if (next !== lastModeRef.current) {
|
||||||
|
lastModeRef.current = next
|
||||||
|
setDisplayMode(next)
|
||||||
|
}
|
||||||
|
})
|
||||||
}, [zoom])
|
}, [zoom])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,7 +5,21 @@
|
|||||||
|
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils'
|
||||||
import { loadGraphFromStorage } from '@/app/pleroma/projectGraphStorage'
|
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 NODE_GAP = 150
|
||||||
|
|
||||||
@@ -45,20 +59,24 @@ const EXAMPLE_EDGES: AppEdge[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||||
return {
|
const nodes = EXAMPLE_NODES.map((n) => ({
|
||||||
nodes: EXAMPLE_NODES.map((n) => ({
|
...n,
|
||||||
...n,
|
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
|
||||||
data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data,
|
}))
|
||||||
})),
|
const edges = backfillEdgeTargetTypes(
|
||||||
edges: EXAMPLE_EDGES.map((e) => ({ ...e })),
|
nodes,
|
||||||
}
|
EXAMPLE_EDGES.map((e) => ({ ...e }))
|
||||||
|
)
|
||||||
|
return { nodes, edges }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
export function getInitialGraph(recollectionId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } {
|
||||||
if (projectId) {
|
if (recollectionId) {
|
||||||
const stored = loadGraphFromStorage(projectId)
|
const stored = loadGraphFromStorage(recollectionId)
|
||||||
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) {
|
||||||
return { nodes: stored.nodes as AppNode[], edges: stored.edges as AppEdge[] }
|
const nodes = stored.nodes as AppNode[]
|
||||||
|
const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[])
|
||||||
|
return { nodes, edges }
|
||||||
}
|
}
|
||||||
return { nodes: [], edges: [] }
|
return { nodes: [], edges: [] }
|
||||||
}
|
}
|
||||||
|
|||||||
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 }
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
import { getPathNodeIds, getPathToUpdatingSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||||
|
|
||||||
/** Serialize set to a stable string for equality. */
|
/** Serialize set to a stable string for equality. */
|
||||||
function setToStableKey(s: Set<string>): string {
|
function setToStableKey(s: Set<string>): string {
|
||||||
@@ -14,8 +14,7 @@ function setToStableKey(s: Set<string>): string {
|
|||||||
|
|
||||||
export type EdgeLike = { source: string; target: string }
|
export type EdgeLike = { source: string; target: string }
|
||||||
|
|
||||||
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
|
const PULSE_MS = 1500
|
||||||
const CONNECTION_PATH_UPDATE_MIN_MS = 1500
|
|
||||||
|
|
||||||
export type UseCanvasConnectionPathResult = {
|
export type UseCanvasConnectionPathResult = {
|
||||||
connectionPathUpdatingNodeIds: string[]
|
connectionPathUpdatingNodeIds: string[]
|
||||||
@@ -35,60 +34,13 @@ export type UseCanvasConnectionPathResult = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult {
|
export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult {
|
||||||
const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = useState<string[]>([])
|
|
||||||
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState<string[]>([])
|
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState<string[]>([])
|
||||||
const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = useState<string[]>([])
|
const [pulseEndsAt, setPulseEndsAt] = useState<number | null>(null)
|
||||||
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
|
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
|
||||||
|
|
||||||
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
|
|
||||||
const pathUpdateStartTimeRef = useRef<number | null>(null)
|
|
||||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
|
|
||||||
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
|
|
||||||
|
|
||||||
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
|
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
|
||||||
const pathTriggerScheduledRef = useRef(false)
|
const pathTriggerScheduledRef = useRef(false)
|
||||||
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
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 addConnectionPathTrigger = useCallback((nodeId: string) => {
|
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||||
pathTriggerBatchRef.current.add(nodeId)
|
pathTriggerBatchRef.current.add(nodeId)
|
||||||
@@ -104,27 +56,31 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
batch.forEach((id) => next.add(id))
|
batch.forEach((id) => next.add(id))
|
||||||
return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next)
|
return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next)
|
||||||
})
|
})
|
||||||
|
setPulseEndsAt(Date.now() + PULSE_MS)
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => {
|
||||||
() => () => {
|
if (pulseEndsAt == null) return
|
||||||
if (pathUpdateEndTimeoutRef.current != null) {
|
const delay = Math.max(0, pulseEndsAt - Date.now())
|
||||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
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(
|
const connectionPathNodeIdsRaw = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getPathNodeIds(
|
getPathNodeIds(edges, [], connectionPathTriggerNodeIds, undefined),
|
||||||
edges,
|
[edges, connectionPathTriggerNodeIds]
|
||||||
connectionPathUpdatingNodeIds,
|
|
||||||
connectionPathTriggerNodeIds,
|
|
||||||
connectionPathPausedNodeIds
|
|
||||||
),
|
|
||||||
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
|
||||||
)
|
)
|
||||||
const connectionPathNodeIdsRef = useRef<Set<string>>(connectionPathNodeIdsRaw)
|
const connectionPathNodeIdsRef = useRef<Set<string>>(connectionPathNodeIdsRaw)
|
||||||
const connectionPathNodeIdsKeyRef = useRef<string>('')
|
const connectionPathNodeIdsKeyRef = useRef<string>('')
|
||||||
@@ -137,37 +93,19 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
return connectionPathNodeIdsRaw
|
return connectionPathNodeIdsRaw
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const connectionPathPausedSegmentNodeIdsRaw = useMemo(
|
const EMPTY_PAUSED_SEGMENT = useMemo(() => new Set<string>(), [])
|
||||||
() =>
|
const connectionPathPausedSegmentNodeIds = EMPTY_PAUSED_SEGMENT
|
||||||
getPausedSegmentNodeIds(
|
|
||||||
edges,
|
|
||||||
connectionPathNodeIds,
|
|
||||||
connectionPathTriggerNodeIds,
|
|
||||||
connectionPathPausedNodeIds
|
|
||||||
),
|
|
||||||
[edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
|
||||||
)
|
|
||||||
const connectionPathPausedSegmentNodeIdsRef = useRef<Set<string>>(
|
|
||||||
connectionPathPausedSegmentNodeIdsRaw
|
|
||||||
)
|
|
||||||
const connectionPathPausedSegmentNodeIdsKeyRef = useRef<string>('')
|
|
||||||
const connectionPathPausedSegmentNodeIds =
|
|
||||||
setToStableKey(connectionPathPausedSegmentNodeIdsRaw) ===
|
|
||||||
connectionPathPausedSegmentNodeIdsKeyRef.current
|
|
||||||
? connectionPathPausedSegmentNodeIdsRef.current
|
|
||||||
: (() => {
|
|
||||||
connectionPathPausedSegmentNodeIdsKeyRef.current = setToStableKey(
|
|
||||||
connectionPathPausedSegmentNodeIdsRaw
|
|
||||||
)
|
|
||||||
connectionPathPausedSegmentNodeIdsRef.current = connectionPathPausedSegmentNodeIdsRaw
|
|
||||||
return connectionPathPausedSegmentNodeIdsRaw
|
|
||||||
})()
|
|
||||||
|
|
||||||
const connectionPathActiveSegmentNodeIdsRaw = useMemo(() => {
|
const pulseActive = pulseEndsAt != null
|
||||||
const active = new Set(connectionPathNodeIds)
|
const connectionPathActiveSegmentNodeIdsRaw = useMemo(
|
||||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
() =>
|
||||||
return active
|
getPathToUpdatingSegmentNodeIds(
|
||||||
}, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds])
|
edges,
|
||||||
|
connectionPathTriggerNodeIds,
|
||||||
|
pulseActive
|
||||||
|
),
|
||||||
|
[edges, connectionPathTriggerNodeIds, pulseActive]
|
||||||
|
)
|
||||||
const connectionPathActiveSegmentNodeIdsRef = useRef<Set<string>>(
|
const connectionPathActiveSegmentNodeIdsRef = useRef<Set<string>>(
|
||||||
connectionPathActiveSegmentNodeIdsRaw
|
connectionPathActiveSegmentNodeIdsRaw
|
||||||
)
|
)
|
||||||
@@ -184,13 +122,11 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
return connectionPathActiveSegmentNodeIdsRaw
|
return connectionPathActiveSegmentNodeIdsRaw
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
const addConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
|
||||||
setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
const removeConnectionPathPausedNode = useCallback((_nodeId: string) => {}, [])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
|
const startConnectionPathUpdate = useCallback((_nodeId: string) => {}, [])
|
||||||
setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId))
|
const endConnectionPathUpdate = useCallback((_nodeId: string) => {}, [])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const addConnectionPathError = useCallback((nodeId: string) => {
|
const addConnectionPathError = useCallback((nodeId: string) => {
|
||||||
setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
||||||
@@ -201,9 +137,9 @@ export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionP
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionPathUpdatingNodeIds,
|
connectionPathUpdatingNodeIds: [],
|
||||||
connectionPathTriggerNodeIds,
|
connectionPathTriggerNodeIds,
|
||||||
connectionPathPausedNodeIds,
|
connectionPathPausedNodeIds: [],
|
||||||
connectionPathErrorNodeIds,
|
connectionPathErrorNodeIds,
|
||||||
connectionPathNodeIds,
|
connectionPathNodeIds,
|
||||||
connectionPathPausedSegmentNodeIds,
|
connectionPathPausedSegmentNodeIds,
|
||||||
|
|||||||
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,78 +1,74 @@
|
|||||||
/**
|
/**
|
||||||
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
|
* Hook for canvas graph state and persistence. Wraps useGraphStateWithHistory
|
||||||
* with initial graph from project storage (or example) and debounced + idle-based save.
|
* with initial graph from recollection storage (or example). Save is explicit via save().
|
||||||
* Keeps CanvasPage focused on composition and layout.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||||
import { saveGraphToStorage, PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||||
|
|
||||||
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory>
|
export type SaveStatus = 'saved' | 'unsaved' | 'saving'
|
||||||
|
|
||||||
/** Debounce delay (ms) before we schedule a save. */
|
export type UseCanvasGraphResult = ReturnType<typeof useGraphStateWithHistory> & {
|
||||||
const SAVE_DEBOUNCE_MS = 800
|
/** Persist current nodes/edges to storage. No-op when recollectionId is missing. */
|
||||||
/** Max wait (ms) for requestIdleCallback before falling back to setTimeout. */
|
save: () => void
|
||||||
const SAVE_IDLE_TIMEOUT_MS = 2000
|
/** For menubar: show "Unsaved changes" | "Saving…" | "All changes saved". */
|
||||||
|
saveStatus: SaveStatus
|
||||||
|
}
|
||||||
|
|
||||||
export function useCanvasGraph(projectId: string | undefined): UseCanvasGraphResult {
|
export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGraphResult {
|
||||||
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
const initialGraph = useMemo(() => getInitialGraph(recollectionId), [recollectionId])
|
||||||
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
const result = useGraphStateWithHistory(initialGraph.nodes, initialGraph.edges)
|
||||||
const { nodes, edges } = result
|
const { nodes, edges } = result
|
||||||
|
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const nodesRef = useRef(nodes)
|
||||||
const idleCallbackRef = useRef<number | null>(null)
|
const edgesRef = useRef(edges)
|
||||||
const pendingSaveRef = useRef<{ projectId: string; nodes: AppNode[]; edges: AppEdge[] } | null>(
|
nodesRef.current = nodes
|
||||||
null
|
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(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return
|
if (!isDirty || !recollectionId) return
|
||||||
|
const timer = setTimeout(() => saveRef.current(), AUTO_SAVE_DELAY_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [isDirty, recollectionId, currentSerialized])
|
||||||
|
|
||||||
const scheduleSave = () => {
|
return { ...result, save, saveStatus }
|
||||||
pendingSaveRef.current = { projectId, nodes, edges }
|
|
||||||
|
|
||||||
const doSave = () => {
|
|
||||||
const pending = pendingSaveRef.current
|
|
||||||
pendingSaveRef.current = null
|
|
||||||
if (pending && pending.projectId === projectId) {
|
|
||||||
saveGraphToStorage(pending.projectId, {
|
|
||||||
version: PROJECT_VERSION,
|
|
||||||
nodes: pending.nodes,
|
|
||||||
edges: pending.edges,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof requestIdleCallback !== 'undefined') {
|
|
||||||
idleCallbackRef.current = requestIdleCallback(doSave, {
|
|
||||||
timeout: SAVE_IDLE_TIMEOUT_MS,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
idleCallbackRef.current = window.setTimeout(doSave, 0) as unknown as number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
|
|
||||||
saveTimeoutRef.current = setTimeout(scheduleSave, SAVE_DEBOUNCE_MS)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (saveTimeoutRef.current) {
|
|
||||||
clearTimeout(saveTimeoutRef.current)
|
|
||||||
saveTimeoutRef.current = null
|
|
||||||
}
|
|
||||||
if (idleCallbackRef.current != null) {
|
|
||||||
if (typeof cancelIdleCallback !== 'undefined') {
|
|
||||||
cancelIdleCallback(idleCallbackRef.current)
|
|
||||||
} else {
|
|
||||||
clearTimeout(idleCallbackRef.current)
|
|
||||||
}
|
|
||||||
idleCallbackRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [projectId, nodes, edges])
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* Platform context: recollections list and handlers for create/delete/rename/updateLastEdited/reorder/restore.
|
||||||
* Used by AppSidebar, ProjectsTablePage, and canvas route.
|
* Used by AppSidebar, RecollectionsPage, and canvas route.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||||
import type { Project } from './types'
|
import type { Recollection } from './types'
|
||||||
import { saveGraphToStorage } from '@/app/pleroma/projectGraphStorage'
|
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
import { PROJECT_VERSION } from '@/app/pleroma/projectGraphStorage'
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'zui_platform_projects'
|
const STORAGE_KEY = 'zui_platform_recollections'
|
||||||
const ORDER_STORAGE_KEY = 'zui_platform_project_order'
|
const ORDER_STORAGE_KEY = 'zui_platform_recollection_order'
|
||||||
const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids'
|
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 CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap'
|
||||||
const AI_CONNECTION_KEY = 'zui_ai_connection'
|
const AI_CONNECTION_KEY = 'zui_ai_connection'
|
||||||
const RECENT_MAX = 5
|
const RECENT_MAX = 5
|
||||||
@@ -72,8 +75,15 @@ function saveShowMinimap(value: boolean) {
|
|||||||
|
|
||||||
function loadOrder(): string[] {
|
function loadOrder(): string[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(ORDER_STORAGE_KEY)
|
let raw = localStorage.getItem(ORDER_STORAGE_KEY)
|
||||||
if (!raw) return []
|
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
|
const parsed = JSON.parse(raw) as unknown
|
||||||
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
|
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
|
||||||
} catch {
|
} catch {
|
||||||
@@ -87,8 +97,15 @@ function saveOrder(ids: string[]) {
|
|||||||
|
|
||||||
function loadRecentIds(): string[] {
|
function loadRecentIds(): string[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(RECENT_STORAGE_KEY)
|
let raw = localStorage.getItem(RECENT_STORAGE_KEY)
|
||||||
if (!raw) return []
|
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
|
const parsed = JSON.parse(raw) as unknown
|
||||||
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MAX) : []
|
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MAX) : []
|
||||||
} catch {
|
} catch {
|
||||||
@@ -100,41 +117,48 @@ function saveRecentIds(ids: string[]) {
|
|||||||
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids))
|
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(ids))
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProjects(): Project[] {
|
function loadRecollections(): Recollection[] {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
let raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (!raw) return []
|
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
|
const parsed = JSON.parse(raw) as unknown
|
||||||
if (!Array.isArray(parsed)) return []
|
if (!Array.isArray(parsed)) return []
|
||||||
return parsed
|
return parsed
|
||||||
.filter(
|
.filter(
|
||||||
(p): p is Project =>
|
(p): p is Recollection =>
|
||||||
p &&
|
p &&
|
||||||
typeof p === 'object' &&
|
typeof p === 'object' &&
|
||||||
typeof (p as Project).id === 'string' &&
|
typeof (p as Recollection).id === 'string' &&
|
||||||
typeof (p as Project).name === 'string' &&
|
typeof (p as Recollection).name === 'string' &&
|
||||||
typeof (p as Project).iconId === 'string' &&
|
typeof (p as Recollection).iconId === 'string' &&
|
||||||
typeof (p as Project).createdAt === 'number'
|
typeof (p as Recollection).createdAt === 'number'
|
||||||
)
|
)
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
...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 {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveProjects(projects: Project[]) {
|
function saveRecollections(recollections: Recollection[]) {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(recollections))
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] }
|
export type GraphSnapshot = { nodes: unknown[]; edges: unknown[] }
|
||||||
|
|
||||||
/** Projects sorted by projectOrder, then by lastEditedAt desc for any not in order */
|
/** Recollections sorted by recollectionOrder, then by lastEditedAt desc for any not in order */
|
||||||
export function sortProjectsByOrder(projects: Project[], order: string[]): Project[] {
|
export function sortRecollectionsByOrder(recollections: Recollection[], order: string[]): Recollection[] {
|
||||||
const byId = new Map(projects.map((p) => [p.id, p]))
|
const byId = new Map(recollections.map((p) => [p.id, p]))
|
||||||
const ordered: Project[] = []
|
const ordered: Recollection[] = []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
for (const id of order) {
|
for (const id of order) {
|
||||||
const p = byId.get(id)
|
const p = byId.get(id)
|
||||||
@@ -143,27 +167,27 @@ export function sortProjectsByOrder(projects: Project[], order: string[]): Proje
|
|||||||
seen.add(id)
|
seen.add(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rest = projects
|
const rest = recollections
|
||||||
.filter((p) => !seen.has(p.id))
|
.filter((p) => !seen.has(p.id))
|
||||||
.sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
|
.sort((a, b) => (b.lastEditedAt ?? b.createdAt) - (a.lastEditedAt ?? a.createdAt))
|
||||||
return [...ordered, ...rest]
|
return [...ordered, ...rest]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type KosmosContextValue = {
|
export type KosmosContextValue = {
|
||||||
projects: Project[]
|
recollections: Recollection[]
|
||||||
projectOrder: string[]
|
recollectionOrder: string[]
|
||||||
/** Projects in display order (sidebar order, then lastEditedAt) */
|
/** Recollections in display order (sidebar order, then lastEditedAt) */
|
||||||
orderedProjects: Project[]
|
orderedRecollections: Recollection[]
|
||||||
/** Last RECENT_MAX accessed project IDs (most recent first) */
|
/** Last RECENT_MAX accessed recollection IDs (most recent first) */
|
||||||
recentProjectIds: string[]
|
recentRecollectionIds: string[]
|
||||||
recordProjectAccess: (id: string) => void
|
recordRecollectionAccess: (id: string) => void
|
||||||
persist: (next: Project[]) => void
|
persist: (next: Recollection[]) => void
|
||||||
createProject: (project: Project) => void
|
createRecollection: (recollection: Recollection) => void
|
||||||
deleteProject: (id: string) => void
|
deleteRecollection: (id: string) => void
|
||||||
renameProject: (id: string, name: string) => void
|
renameRecollection: (id: string, name: string) => void
|
||||||
updateLastEdited: (id: string) => void
|
updateLastEdited: (id: string) => void
|
||||||
reorderProjects: (orderedIds: string[]) => void
|
reorderRecollections: (orderedIds: string[]) => void
|
||||||
restoreProject: (project: Project, graphSnapshot: GraphSnapshot | null) => void
|
restoreRecollection: (recollection: Recollection, graphSnapshot: GraphSnapshot | null) => void
|
||||||
/** Canvas: show React Flow minimap (persisted) */
|
/** Canvas: show React Flow minimap (persisted) */
|
||||||
showMinimap: boolean
|
showMinimap: boolean
|
||||||
setShowMinimap: (value: boolean) => void
|
setShowMinimap: (value: boolean) => void
|
||||||
@@ -175,9 +199,9 @@ export type KosmosContextValue = {
|
|||||||
const KosmosContext = createContext<KosmosContextValue | null>(null)
|
const KosmosContext = createContext<KosmosContextValue | null>(null)
|
||||||
|
|
||||||
export function KosmosProvider({ children }: { children: React.ReactNode }) {
|
export function KosmosProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [projects, setProjects] = useState<Project[]>(loadProjects)
|
const [recollections, setRecollections] = useState<Recollection[]>(loadRecollections)
|
||||||
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
|
const [recollectionOrder, setRecollectionOrder] = useState<string[]>(loadOrder)
|
||||||
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
|
const [recentRecollectionIds, setRecentRecollectionIds] = useState<string[]>(loadRecentIds)
|
||||||
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
|
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
|
||||||
const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection)
|
const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection)
|
||||||
|
|
||||||
@@ -191,128 +215,128 @@ export function KosmosProvider({ children }: { children: React.ReactNode }) {
|
|||||||
saveAiConnection(value)
|
saveAiConnection(value)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const persist = useCallback((next: Project[]) => {
|
const persist = useCallback((next: Recollection[]) => {
|
||||||
setProjects(next)
|
setRecollections(next)
|
||||||
saveProjects(next)
|
saveRecollections(next)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const createProject = useCallback(
|
const createRecollection = useCallback(
|
||||||
(project: Project) => {
|
(recollection: Recollection) => {
|
||||||
const withEdited = { ...project, lastEditedAt: project.createdAt }
|
const withEdited = { ...recollection, lastEditedAt: recollection.createdAt }
|
||||||
persist([...projects, withEdited])
|
persist([...recollections, withEdited])
|
||||||
setProjectOrder((prev) => {
|
setRecollectionOrder((prev) => {
|
||||||
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
|
const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev]
|
||||||
saveOrder(next)
|
saveOrder(next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[recollections, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteProject = useCallback(
|
const deleteRecollection = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const next = projects.filter((p) => p.id !== id)
|
const next = recollections.filter((p) => p.id !== id)
|
||||||
persist(next)
|
persist(next)
|
||||||
setProjectOrder((prev) => {
|
setRecollectionOrder((prev) => {
|
||||||
const nextOrder = prev.filter((oid) => oid !== id)
|
const nextOrder = prev.filter((oid) => oid !== id)
|
||||||
saveOrder(nextOrder)
|
saveOrder(nextOrder)
|
||||||
return nextOrder
|
return nextOrder
|
||||||
})
|
})
|
||||||
setRecentProjectIds((prev) => {
|
setRecentRecollectionIds((prev) => {
|
||||||
const next = prev.filter((oid) => oid !== id)
|
const next = prev.filter((oid) => oid !== id)
|
||||||
saveRecentIds(next)
|
saveRecentIds(next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[recollections, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const renameProject = useCallback(
|
const renameRecollection = useCallback(
|
||||||
(id: string, name: string) => {
|
(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)
|
persist(next)
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[recollections, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateLastEdited = useCallback(
|
const updateLastEdited = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const now = Date.now()
|
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)
|
persist(next)
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[recollections, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const reorderProjects = useCallback((orderedIds: string[]) => {
|
const reorderRecollections = useCallback((orderedIds: string[]) => {
|
||||||
setProjectOrder(orderedIds)
|
setRecollectionOrder(orderedIds)
|
||||||
saveOrder(orderedIds)
|
saveOrder(orderedIds)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const recordProjectAccess = useCallback((id: string) => {
|
const recordRecollectionAccess = useCallback((id: string) => {
|
||||||
setRecentProjectIds((prev) => {
|
setRecentRecollectionIds((prev) => {
|
||||||
const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX)
|
const next = [id, ...prev.filter((x) => x !== id)].slice(0, RECENT_MAX)
|
||||||
saveRecentIds(next)
|
saveRecentIds(next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const restoreProject = useCallback(
|
const restoreRecollection = useCallback(
|
||||||
(project: Project, graphSnapshot: GraphSnapshot | null) => {
|
(recollection: Recollection, graphSnapshot: GraphSnapshot | null) => {
|
||||||
persist([...projects, project])
|
persist([...recollections, recollection])
|
||||||
setProjectOrder((prev) => {
|
setRecollectionOrder((prev) => {
|
||||||
const next = prev.includes(project.id) ? prev : [project.id, ...prev]
|
const next = prev.includes(recollection.id) ? prev : [recollection.id, ...prev]
|
||||||
saveOrder(next)
|
saveOrder(next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
if (graphSnapshot) {
|
if (graphSnapshot) {
|
||||||
saveGraphToStorage(project.id, {
|
saveGraphToStorage(recollection.id, {
|
||||||
version: PROJECT_VERSION,
|
version: RECOLLECTION_VERSION,
|
||||||
nodes: graphSnapshot.nodes,
|
nodes: graphSnapshot.nodes,
|
||||||
edges: graphSnapshot.edges,
|
edges: graphSnapshot.edges,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projects, persist]
|
[recollections, persist]
|
||||||
)
|
)
|
||||||
|
|
||||||
const orderedProjects = useMemo(
|
const orderedRecollections = useMemo(
|
||||||
() => sortProjectsByOrder(projects, projectOrder),
|
() => sortRecollectionsByOrder(recollections, recollectionOrder),
|
||||||
[projects, projectOrder]
|
[recollections, recollectionOrder]
|
||||||
)
|
)
|
||||||
|
|
||||||
const value: KosmosContextValue = useMemo(
|
const value: KosmosContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
projects,
|
recollections,
|
||||||
projectOrder,
|
recollectionOrder,
|
||||||
orderedProjects,
|
orderedRecollections,
|
||||||
recentProjectIds,
|
recentRecollectionIds,
|
||||||
recordProjectAccess,
|
recordRecollectionAccess,
|
||||||
persist,
|
persist,
|
||||||
createProject,
|
createRecollection,
|
||||||
deleteProject,
|
deleteRecollection,
|
||||||
renameProject,
|
renameRecollection,
|
||||||
updateLastEdited,
|
updateLastEdited,
|
||||||
reorderProjects,
|
reorderRecollections,
|
||||||
restoreProject,
|
restoreRecollection,
|
||||||
showMinimap,
|
showMinimap,
|
||||||
setShowMinimap,
|
setShowMinimap,
|
||||||
aiConnection,
|
aiConnection,
|
||||||
setAiConnection,
|
setAiConnection,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
projects,
|
recollections,
|
||||||
projectOrder,
|
recollectionOrder,
|
||||||
orderedProjects,
|
orderedRecollections,
|
||||||
recentProjectIds,
|
recentRecollectionIds,
|
||||||
recordProjectAccess,
|
recordRecollectionAccess,
|
||||||
persist,
|
persist,
|
||||||
createProject,
|
createRecollection,
|
||||||
deleteProject,
|
deleteRecollection,
|
||||||
renameProject,
|
renameRecollection,
|
||||||
updateLastEdited,
|
updateLastEdited,
|
||||||
reorderProjects,
|
reorderRecollections,
|
||||||
restoreProject,
|
restoreRecollection,
|
||||||
showMinimap,
|
showMinimap,
|
||||||
setShowMinimap,
|
setShowMinimap,
|
||||||
aiConnection,
|
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'
|
import React, { useEffect, useState } from 'react'
|
||||||
@@ -9,17 +9,19 @@ import { KosmosProvider, usePlatform } from './KosmosContext'
|
|||||||
import { KosmosSidebar } from './KosmosSidebar'
|
import { KosmosSidebar } from './KosmosSidebar'
|
||||||
|
|
||||||
function KosmosLayoutInner() {
|
function KosmosLayoutInner() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const { recordProjectAccess } = usePlatform()
|
const { recordRecollectionAccess } = usePlatform()
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) recordProjectAccess(projectId)
|
if (recollectionId) recordRecollectionAccess(recollectionId)
|
||||||
}, [projectId, recordProjectAccess])
|
}, [recollectionId, recordRecollectionAccess])
|
||||||
|
|
||||||
|
const showSidebar = !recollectionId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
<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="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">
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
<Outlet />
|
<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'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
@@ -17,36 +17,34 @@ import {
|
|||||||
SidebarRail,
|
SidebarRail,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from '@/components/ui/sidebar'
|
} 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useSidebar } from '@/components/ui/sidebar'
|
import { useSidebar } from '@/components/ui/sidebar'
|
||||||
import { usePlatform } from './KosmosContext'
|
import { usePlatform } from './KosmosContext'
|
||||||
import { getProjectIcon } from '@/lib/iconMap'
|
import { getRecollectionIcon } from '@/lib/iconMap'
|
||||||
import { NewProjectDialog } from './NewProjectDialog'
|
import { RecollectionsIcon } from '@/lib/icons'
|
||||||
|
import { NewRecollectionDialog } from './NewRecollectionDialog'
|
||||||
import { SettingsDialog } from './SettingsDialog'
|
import { SettingsDialog } from './SettingsDialog'
|
||||||
import type { Project } from './types'
|
import type { Recollection } from './types'
|
||||||
|
|
||||||
export function KosmosSidebar() {
|
export function KosmosSidebar() {
|
||||||
const { state, setOpen } = useSidebar()
|
const { state, setOpen } = useSidebar()
|
||||||
const isCollapsed = state === 'collapsed'
|
const isCollapsed = state === 'collapsed'
|
||||||
const { orderedProjects, createProject, recentProjectIds } = usePlatform()
|
const { orderedRecollections, createRecollection, recentRecollectionIds } = usePlatform()
|
||||||
const recentProjects = useMemo(() => {
|
const recentRecollections = useMemo(() => {
|
||||||
const byId = new Map(orderedProjects.map((p) => [p.id, p]))
|
const byId = new Map(orderedRecollections.map((p) => [p.id, p]))
|
||||||
return recentProjectIds.map((id) => byId.get(id)).filter((p): p is Project => p != null)
|
return recentRecollectionIds.map((id) => byId.get(id)).filter((p): p is Recollection => p != null)
|
||||||
}, [orderedProjects, recentProjectIds])
|
}, [orderedRecollections, recentRecollectionIds])
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { projectId: selectedProjectId } = useParams<{ projectId: string }>()
|
const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const isKeroma = location.pathname === '/keroma'
|
|
||||||
|
|
||||||
const handleSelectProject = useCallback((id: string) => navigate(`/projects/${id}`), [navigate])
|
const handleCreateRecollection = useCallback(
|
||||||
|
(recollection: Parameters<typeof createRecollection>[0]) => {
|
||||||
const handleCreateProject = useCallback(
|
createRecollection(recollection)
|
||||||
(project: Parameters<typeof createProject>[0]) => {
|
navigate(`/recollections/${recollection.id}`)
|
||||||
createProject(project)
|
|
||||||
navigate(`/projects/${project.id}`)
|
|
||||||
},
|
},
|
||||||
[createProject, navigate]
|
[createRecollection, navigate]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -99,8 +97,8 @@ export function KosmosSidebar() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
|
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
|
||||||
<Link to="/projects">
|
<Link to="/recollections">
|
||||||
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground dark:bg-white dark:text-black">
|
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg dark:bg-white dark:text-black">
|
||||||
<span
|
<span
|
||||||
className="size-6 shrink-0 rounded-[2px] opacity-90"
|
className="size-6 shrink-0 rounded-[2px] opacity-90"
|
||||||
style={{
|
style={{
|
||||||
@@ -132,39 +130,29 @@ export function KosmosSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton asChild tooltip="All projects" isActive={location.pathname === '/projects' && !selectedProjectId}>
|
<SidebarMenuButton asChild tooltip="Recollections" isActive={location.pathname === '/recollections' && !selectedRecollectionId}>
|
||||||
<Link to="/projects">
|
<Link to="/recollections">
|
||||||
<Triangle className="size-4" />
|
<RecollectionsIcon className="size-4 shrink-0" />
|
||||||
<span>Pleroma</span>
|
<span>Recollections</span>
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton asChild tooltip="Keroma" isActive={isKeroma}>
|
|
||||||
<Link to="/keroma">
|
|
||||||
<Triangle className="size-4 rotate-180" />
|
|
||||||
<span>Keroma</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
{recentProjects.length > 0 && (
|
{recentRecollections.length > 0 && (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
|
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">Recently used</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{recentProjects.map((project) => {
|
{recentRecollections.map((recollection) => {
|
||||||
const Icon = getProjectIcon(project.iconId)
|
const Icon = getRecollectionIcon(recollection.iconId)
|
||||||
const isActive = selectedProjectId === project.id
|
const isActive = selectedRecollectionId === recollection.id
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={project.id}>
|
<SidebarMenuItem key={recollection.id}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton asChild tooltip={recollection.name} isActive={isActive}>
|
||||||
tooltip={project.name}
|
<Link to={`/recollections/${recollection.id}`}>
|
||||||
isActive={isActive}
|
<Icon className="size-4" />
|
||||||
onClick={() => handleSelectProject(project.id)}
|
<span>{recollection.name}</span>
|
||||||
>
|
</Link>
|
||||||
<Icon className="size-4" />
|
|
||||||
<span>{project.name}</span>
|
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
)
|
)
|
||||||
@@ -175,13 +163,13 @@ export function KosmosSidebar() {
|
|||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<NewProjectDialog
|
<NewRecollectionDialog
|
||||||
onCreate={handleCreateProject}
|
onCreate={handleCreateRecollection}
|
||||||
existingNames={orderedProjects.map((p) => p.name)}
|
existingNames={orderedRecollections.map((p) => p.name)}
|
||||||
trigger={
|
trigger={
|
||||||
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
|
<SidebarMenuButton className="text-sidebar-foreground/70 w-full cursor-pointer">
|
||||||
<Plus className="size-4" />
|
<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>
|
</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'
|
import React, { useState } from 'react'
|
||||||
@@ -16,20 +16,20 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import { PROJECT_ICON_IDS, type Project, type ProjectIconId } from './types'
|
import { RECOLLECTION_ICON_IDS, type Recollection, type RecollectionIconId } from './types'
|
||||||
import { getProjectIcon } from '@/lib/iconMap'
|
import { getRecollectionIcon } from '@/lib/iconMap'
|
||||||
|
|
||||||
type NewProjectDialogProps = {
|
type NewRecollectionDialogProps = {
|
||||||
onCreate: (project: Project) => void
|
onCreate: (recollection: Recollection) => void
|
||||||
trigger?: React.ReactNode
|
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[]
|
existingNames?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewProjectDialogProps) {
|
export function NewRecollectionDialog({ onCreate, trigger, existingNames = [] }: NewRecollectionDialogProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [iconId, setIconId] = useState<ProjectIconId>('cat')
|
const [iconId, setIconId] = useState<RecollectionIconId>('cat')
|
||||||
|
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
const isDuplicate = trimmed.length > 0 && existingNames.some((n) => n.toLowerCase() === trimmed.toLowerCase())
|
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) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
const project: Project = {
|
const recollection: Recollection = {
|
||||||
id: `proj_${Date.now()}`,
|
id: `recollection_${Date.now()}`,
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
iconId,
|
iconId,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
}
|
}
|
||||||
onCreate(project)
|
onCreate(recollection)
|
||||||
setName('')
|
setName('')
|
||||||
setIconId('cat')
|
setIconId('cat')
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -55,41 +55,41 @@ export function NewProjectDialog({ onCreate, trigger, existingNames = [] }: NewP
|
|||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
|
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New project
|
New recollection
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New project</DialogTitle>
|
<DialogTitle>New recollection</DialogTitle>
|
||||||
<DialogDescription>Create a project to start editing a graph canvas.</DialogDescription>
|
<DialogDescription>Create a recollection to start editing a graph canvas.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label htmlFor="project-name" className="text-sm font-medium">
|
<label htmlFor="recollection-name" className="text-sm font-medium">
|
||||||
Name
|
Name
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="project-name"
|
id="recollection-name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="My project"
|
placeholder="My recollection"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
{isDuplicate && (
|
{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>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="text-sm font-medium">Icon</label>
|
<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>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{PROJECT_ICON_IDS.map((id) => {
|
{RECOLLECTION_ICON_IDS.map((id) => {
|
||||||
const Icon = getProjectIcon(id)
|
const Icon = getRecollectionIcon(id)
|
||||||
return (
|
return (
|
||||||
<SelectItem key={id} value={id}>
|
<SelectItem key={id} value={id}>
|
||||||
<span className="flex items-center gap-2">
|
<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'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
export type Project = {
|
export type Recollection = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
/** Icon identifier: key of PROJECT_ICONS map */
|
/** Icon identifier: key of RECOLLECTION_ICONS map */
|
||||||
iconId: string
|
iconId: string
|
||||||
createdAt: number
|
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
|
lastEditedAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Project icon ids: Lucide "Animals" category only */
|
/** Recollection icon ids: Lucide "Animals" category only */
|
||||||
export const PROJECT_ICON_IDS = [
|
export const RECOLLECTION_ICON_IDS = [
|
||||||
'bird',
|
'bird',
|
||||||
'bug',
|
'bug',
|
||||||
'cat',
|
'cat',
|
||||||
@@ -30,4 +30,4 @@ export const PROJECT_ICON_IDS = [
|
|||||||
'egg'
|
'egg'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type ProjectIconId = (typeof PROJECT_ICON_IDS)[number]
|
export type RecollectionIconId = (typeof RECOLLECTION_ICON_IDS)[number]
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
/**
|
|
||||||
* Per-project graph persistence (localStorage).
|
|
||||||
* Saves and loads StoredGraphState (version + nodes + edges). Used by useCanvasGraph and export.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { StoredGraphState } from '@/lib/graph/state'
|
|
||||||
|
|
||||||
export type { StoredGraphState }
|
|
||||||
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 function loadGraphFromStorage(projectId: string): StoredGraphState | 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 StoredGraphState).nodes) || !Array.isArray((data as StoredGraphState).edges))
|
|
||||||
return null
|
|
||||||
return data as StoredGraphState
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveGraphToStorage(projectId: string, state: StoredGraphState): 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.
|
* Sort by last edited (default), name, or created; configurable page size; export toast; undo delete.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -56,18 +56,20 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { getProjectIcon } from '@/lib/iconMap'
|
import { getRecollectionIcon } from '@/lib/iconMap'
|
||||||
import {
|
import {
|
||||||
loadGraphFromStorage,
|
loadGraphFromStorage,
|
||||||
saveGraphToStorage,
|
saveGraphToStorage,
|
||||||
removeGraphFromStorage,
|
removeGraphFromStorage,
|
||||||
PROJECT_FILE_EXT,
|
RECOLLECTION_FILE_EXT,
|
||||||
PROJECT_VERSION,
|
RECOLLECTION_VERSION,
|
||||||
} from './projectGraphStorage'
|
} from './state/recollectionGraphStorage'
|
||||||
|
import { getLogosContent, setLogosContent } from './state/recollectionStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { NewProjectDialog } from '@/app/kosmos/NewProjectDialog'
|
import { NewRecollectionDialog } from '@/app/kosmos/NewRecollectionDialog'
|
||||||
import { ProjectsPageBackground } from './ProjectsPageBackground'
|
import { RecollectionsPageBackground } from './layout/RecollectionsPageBackground'
|
||||||
import type { Project } from '@/app/kosmos/types'
|
import { RenameRecollectionDialog } from './layout/RenameRecollectionDialog'
|
||||||
|
import type { Recollection } from '@/app/kosmos/types'
|
||||||
|
|
||||||
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
const PAGE_SIZE_OPTIONS = [10, 25, 50] as const
|
||||||
type SortKey = 'lastEdited' | 'name' | 'created'
|
type SortKey = 'lastEdited' | 'name' | 'created'
|
||||||
@@ -98,8 +100,8 @@ function getRelativeTime(ts: number): string {
|
|||||||
return 'Just now'
|
return 'Just now'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGraphCounts(projectId: string): { nodes: number; edges: number } {
|
function getGraphCounts(recollectionId: string): { nodes: number; edges: number } {
|
||||||
const stored = loadGraphFromStorage(projectId)
|
const stored = loadGraphFromStorage(recollectionId)
|
||||||
if (!stored) return { nodes: 0, edges: 0 }
|
if (!stored) return { nodes: 0, edges: 0 }
|
||||||
return {
|
return {
|
||||||
nodes: Array.isArray(stored.nodes) ? stored.nodes.length : 0,
|
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 NodeLike = { id: string; position?: { x: number; y: number } }
|
||||||
type EdgeLike = { id?: string; source: string; target: string }
|
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 = {
|
const THUMBNAIL_GRID = {
|
||||||
baseFill: 'hsl(var(--muted) / 0.4)',
|
baseFill: 'hsl(var(--muted) / 0.4)',
|
||||||
dotFill: 'hsl(var(--muted-foreground) / 0.06)',
|
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. */
|
/** Renders a minimal SVG preview of the graph from storage, or a placeholder. */
|
||||||
function GraphThumbnail({ projectId, className }: { projectId: string; className?: string }) {
|
function GraphThumbnail({ recollectionId, className }: { recollectionId: string; className?: string }) {
|
||||||
const stored = loadGraphFromStorage(projectId)
|
const stored = loadGraphFromStorage(recollectionId)
|
||||||
const nodes = (stored?.nodes ?? []) as NodeLike[]
|
const nodes = (stored?.nodes ?? []) as NodeLike[]
|
||||||
const edges = (stored?.edges ?? []) as EdgeLike[]
|
const edges = (stored?.edges ?? []) as EdgeLike[]
|
||||||
const withPos = nodes.filter((n) => n.position && typeof n.position.x === 'number' && typeof n.position.y === 'number')
|
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) {
|
if (withPos.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -207,47 +209,47 @@ function GraphThumbnail({ projectId, className }: { projectId: string; className
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectActionsMenuProps = {
|
type RecollectionActionsMenuProps = {
|
||||||
project: Project
|
recollection: Recollection
|
||||||
onOpen: (id: string) => void
|
onOpen: (id: string) => void
|
||||||
onRenameOpen: (project: Project) => void
|
onRenameOpen: (recollection: Recollection) => void
|
||||||
onDuplicateOpen: (project: Project) => void
|
onDuplicateOpen: (recollection: Recollection) => void
|
||||||
onExport: (project: Project) => void
|
onExport: (recollection: Recollection) => void
|
||||||
onDeleteOpen: (project: Project) => void
|
onDeleteOpen: (recollection: Recollection) => void
|
||||||
trigger: React.ReactNode
|
trigger: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectActionsMenu({
|
function RecollectionActionsMenu({
|
||||||
project,
|
recollection,
|
||||||
onOpen,
|
onOpen,
|
||||||
onRenameOpen,
|
onRenameOpen,
|
||||||
onDuplicateOpen,
|
onDuplicateOpen,
|
||||||
onExport,
|
onExport,
|
||||||
onDeleteOpen,
|
onDeleteOpen,
|
||||||
trigger,
|
trigger,
|
||||||
}: ProjectActionsMenuProps) {
|
}: RecollectionActionsMenuProps) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => onOpen(project.id)}>
|
<DropdownMenuItem onClick={() => onOpen(recollection.id)}>
|
||||||
<FolderOpen className="size-4" />
|
<FolderOpen className="size-4" />
|
||||||
Open
|
Open
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onRenameOpen(project)}>
|
<DropdownMenuItem onClick={() => onRenameOpen(recollection)}>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
Rename
|
Rename
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onDuplicateOpen(project)}>
|
<DropdownMenuItem onClick={() => onDuplicateOpen(recollection)}>
|
||||||
<Copy className="size-4" />
|
<Copy className="size-4" />
|
||||||
Duplicate
|
Duplicate
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onExport(project)}>
|
<DropdownMenuItem onClick={() => onExport(recollection)}>
|
||||||
<Download className="size-4" />
|
<Download className="size-4" />
|
||||||
Export
|
Export
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<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" />
|
<Trash2 className="size-4" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -258,8 +260,11 @@ function ProjectActionsMenu({
|
|||||||
|
|
||||||
export type ViewMode = 'table' | 'cards'
|
export type ViewMode = 'table' | 'cards'
|
||||||
|
|
||||||
export function ProjectsPage() {
|
const RECOLLECTIONS_SCROLL_KEY = 'recollections-list-scroll'
|
||||||
const { orderedProjects, deleteProject, renameProject, createProject, restoreProject } = usePlatform()
|
|
||||||
|
export function RecollectionsPage() {
|
||||||
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -267,21 +272,19 @@ export function ProjectsPage() {
|
|||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('lastEdited')
|
const [sortKey, setSortKey] = useState<SortKey>('lastEdited')
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||||
const [renameTarget, setRenameTarget] = useState<Project | null>(null)
|
const [renameTarget, setRenameTarget] = useState<Recollection | null>(null)
|
||||||
const [renameValue, setRenameValue] = useState('')
|
const [deleteTarget, setDeleteTarget] = useState<Recollection | null>(null)
|
||||||
const renameInputRef = React.useRef<HTMLInputElement>(null)
|
const [duplicateTarget, setDuplicateTarget] = useState<Recollection | null>(null)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
|
|
||||||
const [duplicateTarget, setDuplicateTarget] = useState<Project | null>(null)
|
|
||||||
const [duplicateName, setDuplicateName] = useState('')
|
const [duplicateName, setDuplicateName] = useState('')
|
||||||
const duplicateInputRef = React.useRef<HTMLInputElement>(null)
|
const duplicateInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
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 filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
if (!q) return orderedProjects
|
if (!q) return orderedRecollections
|
||||||
return orderedProjects.filter((p) => p.name.toLowerCase().includes(q))
|
return orderedRecollections.filter((p) => p.name.toLowerCase().includes(q))
|
||||||
}, [orderedProjects, search])
|
}, [orderedRecollections, search])
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const arr = [...filtered]
|
const arr = [...filtered]
|
||||||
@@ -312,24 +315,16 @@ export function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpen = useCallback(
|
const handleOpen = useCallback(
|
||||||
(projectId: string) => {
|
(recollectionId: string) => {
|
||||||
navigate(`/projects/${projectId}`)
|
navigate(`/recollections/${recollectionId}`)
|
||||||
},
|
},
|
||||||
[navigate]
|
[navigate]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleRenameOpen = useCallback((project: Project) => {
|
const handleRenameOpen = useCallback((recollection: Recollection) => {
|
||||||
setRenameTarget(project)
|
setRenameTarget(recollection)
|
||||||
setRenameValue(project.name)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (renameTarget) {
|
|
||||||
const t = setTimeout(() => renameInputRef.current?.focus(), 0)
|
|
||||||
return () => clearTimeout(t)
|
|
||||||
}
|
|
||||||
}, [renameTarget])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (duplicateTarget) {
|
if (duplicateTarget) {
|
||||||
const t = setTimeout(() => duplicateInputRef.current?.focus(), 0)
|
const t = setTimeout(() => duplicateInputRef.current?.focus(), 0)
|
||||||
@@ -337,49 +332,63 @@ export function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
}, [duplicateTarget])
|
}, [duplicateTarget])
|
||||||
|
|
||||||
const handleRenameSubmit = useCallback(() => {
|
// Restore scroll position when returning to the list
|
||||||
if (renameTarget && renameValue.trim()) {
|
React.useEffect(() => {
|
||||||
renameProject(renameTarget.id, renameValue.trim())
|
const el = scrollContainerRef.current
|
||||||
setRenameTarget(null)
|
if (!el) return
|
||||||
setRenameValue('')
|
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) => {
|
const handleDeleteOpen = useCallback((recollection: Recollection) => {
|
||||||
setDeleteTarget(project)
|
setDeleteTarget(recollection)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDeleteConfirm = useCallback(() => {
|
const handleDeleteConfirm = useCallback(() => {
|
||||||
if (!deleteTarget) return
|
if (!deleteTarget) return
|
||||||
const project = deleteTarget
|
const recollection = deleteTarget
|
||||||
const graphSnapshot = loadGraphFromStorage(project.id)
|
const graphSnapshot = loadGraphFromStorage(recollection.id)
|
||||||
const snapshot =
|
const snapshot =
|
||||||
graphSnapshot && (graphSnapshot.nodes.length > 0 || graphSnapshot.edges.length > 0)
|
graphSnapshot && (graphSnapshot.nodes.length > 0 || graphSnapshot.edges.length > 0)
|
||||||
? { nodes: graphSnapshot.nodes, edges: graphSnapshot.edges }
|
? { nodes: graphSnapshot.nodes, edges: graphSnapshot.edges }
|
||||||
: null
|
: null
|
||||||
removeGraphFromStorage(project.id)
|
removeGraphFromStorage(recollection.id)
|
||||||
deleteProject(project.id)
|
deleteRecollection(recollection.id)
|
||||||
setDeleteTarget(null)
|
setDeleteTarget(null)
|
||||||
navigate('/projects', { replace: true })
|
navigate('/recollections', { replace: true })
|
||||||
toast(`"${project.name}" deleted`, {
|
toast(`"${recollection.name}" deleted`, {
|
||||||
action: {
|
action: {
|
||||||
label: 'Undo',
|
label: 'Undo',
|
||||||
onClick: () => restoreProject(project, snapshot),
|
onClick: () => restoreRecollection(recollection, snapshot),
|
||||||
},
|
},
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
})
|
})
|
||||||
}, [deleteTarget, deleteProject, navigate, restoreProject])
|
}, [deleteTarget, deleteRecollection, navigate, restoreRecollection])
|
||||||
|
|
||||||
const handleExport = useCallback(
|
const handleExport = useCallback(
|
||||||
(project: Project) => {
|
(recollection: Recollection) => {
|
||||||
const stored = loadGraphFromStorage(project.id)
|
const stored = loadGraphFromStorage(recollection.id)
|
||||||
const state = stored
|
const state = stored
|
||||||
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
? { version: RECOLLECTION_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||||
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
: { version: RECOLLECTION_VERSION, nodes: [], edges: [] }
|
||||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
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.href = url
|
||||||
a.download = filename
|
a.download = filename
|
||||||
a.click()
|
a.click()
|
||||||
@@ -389,40 +398,44 @@ export function ProjectsPage() {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCreateProject = useCallback(
|
const handleCreateRecollection = useCallback(
|
||||||
(project: Project) => {
|
(recollection: Recollection) => {
|
||||||
createProject(project)
|
createRecollection(recollection)
|
||||||
navigate(`/projects/${project.id}`)
|
navigate(`/recollections/${recollection.id}`)
|
||||||
},
|
},
|
||||||
[createProject, navigate]
|
[createRecollection, navigate]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleDuplicateOpen = useCallback((project: Project) => {
|
const handleDuplicateOpen = useCallback((recollection: Recollection) => {
|
||||||
setDuplicateTarget(project)
|
setDuplicateTarget(recollection)
|
||||||
setDuplicateName(`${project.name} (copy)`)
|
setDuplicateName(`${recollection.name} (copy)`)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDuplicateConfirm = useCallback(() => {
|
const handleDuplicateConfirm = useCallback(() => {
|
||||||
if (!duplicateTarget || !duplicateName.trim()) return
|
if (!duplicateTarget || !duplicateName.trim()) return
|
||||||
const name = duplicateName.trim()
|
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 now = Date.now()
|
||||||
const newProject: Project = {
|
const newRecollection: Recollection = {
|
||||||
id: newId,
|
id: newId,
|
||||||
name,
|
name,
|
||||||
iconId: duplicateTarget.iconId,
|
iconId: duplicateTarget.iconId,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
lastEditedAt: now,
|
lastEditedAt: now,
|
||||||
}
|
}
|
||||||
createProject(newProject)
|
createRecollection(newRecollection)
|
||||||
const graph = loadGraphFromStorage(duplicateTarget.id)
|
const graph = loadGraphFromStorage(duplicateTarget.id)
|
||||||
if (graph && (graph.nodes.length > 0 || graph.edges.length > 0)) {
|
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)
|
setDuplicateTarget(null)
|
||||||
setDuplicateName('')
|
setDuplicateName('')
|
||||||
}, [duplicateTarget, duplicateName, createProject])
|
}, [duplicateTarget, duplicateName, createRecollection])
|
||||||
|
|
||||||
const allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id))
|
const allOnPageSelected = pageItems.length > 0 && pageItems.every((p) => selectedIds.has(p.id))
|
||||||
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
|
const someOnPageSelected = pageItems.some((p) => selectedIds.has(p.id))
|
||||||
@@ -462,33 +475,33 @@ export function ProjectsPage() {
|
|||||||
const handleBulkDeleteConfirm = useCallback(() => {
|
const handleBulkDeleteConfirm = useCallback(() => {
|
||||||
if (!bulkDeleteTargets || bulkDeleteTargets.length === 0) return
|
if (!bulkDeleteTargets || bulkDeleteTargets.length === 0) return
|
||||||
const count = bulkDeleteTargets.length
|
const count = bulkDeleteTargets.length
|
||||||
bulkDeleteTargets.forEach((project) => {
|
bulkDeleteTargets.forEach((recollection) => {
|
||||||
removeGraphFromStorage(project.id)
|
removeGraphFromStorage(recollection.id)
|
||||||
deleteProject(project.id)
|
deleteRecollection(recollection.id)
|
||||||
})
|
})
|
||||||
setBulkDeleteTargets(null)
|
setBulkDeleteTargets(null)
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
navigate('/projects', { replace: true })
|
navigate('/recollections', { replace: true })
|
||||||
toast(`${count} project${count === 1 ? '' : 's'} deleted`)
|
toast(`${count} recollection${count === 1 ? '' : 's'} deleted`)
|
||||||
}, [bulkDeleteTargets, deleteProject, navigate])
|
}, [bulkDeleteTargets, deleteRecollection, navigate])
|
||||||
|
|
||||||
const handleBulkExport = useCallback(() => {
|
const handleBulkExport = useCallback(() => {
|
||||||
const toExport = sorted.filter((p) => selectedIds.has(p.id))
|
const toExport = sorted.filter((p) => selectedIds.has(p.id))
|
||||||
toExport.forEach((project) => {
|
toExport.forEach((recollection) => {
|
||||||
const stored = loadGraphFromStorage(project.id)
|
const stored = loadGraphFromStorage(recollection.id)
|
||||||
const state = stored
|
const state = stored
|
||||||
? { version: PROJECT_VERSION, nodes: stored.nodes, edges: stored.edges }
|
? { version: RECOLLECTION_VERSION, nodes: stored.nodes, edges: stored.edges }
|
||||||
: { version: PROJECT_VERSION, nodes: [], edges: [] }
|
: { version: RECOLLECTION_VERSION, nodes: [], edges: [] }
|
||||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
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.href = url
|
||||||
a.download = filename
|
a.download = filename
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
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])
|
}, [sorted, selectedIds])
|
||||||
|
|
||||||
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
|
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
|
||||||
@@ -498,8 +511,8 @@ export function ProjectsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-1 flex-col min-h-0">
|
<div className="relative flex flex-1 flex-col min-h-0">
|
||||||
<ProjectsPageBackground className="absolute inset-0 pointer-events-none" />
|
<RecollectionsPageBackground className="absolute inset-0 pointer-events-none" />
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
<div ref={scrollContainerRef} className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 overflow-auto">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<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">
|
<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">
|
<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" />
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search projects"
|
placeholder="Search recollections"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value)
|
setSearch(e.target.value)
|
||||||
setPage(0)
|
setPage(0)
|
||||||
}}
|
}}
|
||||||
className="h-9 w-full pl-8"
|
className="h-9 w-full pl-8"
|
||||||
aria-label="Search projects by name"
|
aria-label="Search recollections by name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex h-9 items-center gap-2">
|
<div className="ml-auto flex h-9 items-center gap-2">
|
||||||
@@ -562,9 +575,9 @@ export function ProjectsPage() {
|
|||||||
|
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<NewProjectDialog
|
<NewRecollectionDialog
|
||||||
onCreate={handleCreateProject}
|
onCreate={handleCreateRecollection}
|
||||||
existingNames={orderedProjects.map((p) => p.name)}
|
existingNames={orderedRecollections.map((p) => p.name)}
|
||||||
trigger={
|
trigger={
|
||||||
<div
|
<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"
|
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()
|
; (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}>
|
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
|
||||||
<Plus className="size-12 text-muted-foreground" />
|
<Plus className="size-12 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -663,42 +676,42 @@ export function ProjectsPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody className="text-xs">
|
<TableBody className="text-xs">
|
||||||
{pageItems.map((project) => {
|
{pageItems.map((recollection) => {
|
||||||
const Icon = getProjectIcon(project.iconId)
|
const Icon = getRecollectionIcon(recollection.iconId)
|
||||||
const counts = getGraphCounts(project.id)
|
const counts = getGraphCounts(recollection.id)
|
||||||
const lastEdited = project.lastEditedAt ?? project.createdAt
|
const lastEdited = recollection.lastEditedAt ?? recollection.createdAt
|
||||||
const isSelected = selectedIds.has(project.id)
|
const isSelected = selectedIds.has(recollection.id)
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={project.id}
|
key={recollection.id}
|
||||||
className={`group cursor-pointer hover:bg-muted/50 h-8 ${isSelected ? 'bg-muted/70' : ''}`}
|
className={`group cursor-pointer hover:bg-muted/50 h-8 ${isSelected ? 'bg-muted/70' : ''}`}
|
||||||
onClick={() => handleOpen(project.id)}
|
onClick={() => handleOpen(recollection.id)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleOpen(project.id)
|
handleOpen(recollection.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
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()}>
|
<TableCell className="px-2 py-1.5 w-10 [&:has([role=checkbox])]:pr-0" onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={() => toggleSelection(project.id)}
|
onCheckedChange={() => toggleSelection(recollection.id)}
|
||||||
aria-label={`Select ${project.name}`}
|
aria-label={`Select ${recollection.name}`}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-2 py-1.5 min-w-0">
|
<TableCell className="px-2 py-1.5 min-w-0">
|
||||||
<div className="flex items-center gap-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" />
|
<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>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden sm:table-cell text-muted-foreground px-2 py-1.5 w-[120px] min-w-[100px]">
|
<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)}>
|
<span className="truncate block" title={formatDate(recollection.createdAt)}>
|
||||||
{formatDate(project.createdAt)}
|
{formatDate(recollection.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</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`}>
|
<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'}`}
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ProjectActionsMenu
|
<RecollectionActionsMenu
|
||||||
project={project}
|
recollection={recollection}
|
||||||
onOpen={handleOpen}
|
onOpen={handleOpen}
|
||||||
onRenameOpen={handleRenameOpen}
|
onRenameOpen={handleRenameOpen}
|
||||||
onDuplicateOpen={handleDuplicateOpen}
|
onDuplicateOpen={handleDuplicateOpen}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
onDeleteOpen={handleDeleteOpen}
|
onDeleteOpen={handleDeleteOpen}
|
||||||
trigger={
|
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" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -741,7 +754,7 @@ export function ProjectsPage() {
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Page {page + 1} of {totalPages}
|
Page {page + 1} of {totalPages}
|
||||||
{pageSize !== -1 && ` · ${sorted.length} projects`}
|
{pageSize !== -1 && ` · ${sorted.length} recollections`}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<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">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
<NewProjectDialog
|
<NewRecollectionDialog
|
||||||
onCreate={handleCreateProject}
|
onCreate={handleCreateRecollection}
|
||||||
existingNames={orderedProjects.map((p) => p.name)}
|
existingNames={orderedRecollections.map((p) => p.name)}
|
||||||
trigger={
|
trigger={
|
||||||
<div
|
<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"
|
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()
|
; (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}>
|
<div className="flex aspect-video w-full shrink-0 items-center justify-center" style={thumbnailGridStyle}>
|
||||||
<Plus className="size-12 text-muted-foreground" />
|
<Plus className="size-12 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
<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">Create a new project</span>
|
<span className="text-xs text-muted-foreground">Create a new recollection</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{pageItems.map((project) => {
|
{pageItems.map((recollection) => {
|
||||||
const Icon = getProjectIcon(project.iconId)
|
const Icon = getRecollectionIcon(recollection.iconId)
|
||||||
const lastEdited = project.lastEditedAt ?? project.createdAt
|
const lastEdited = recollection.lastEditedAt ?? recollection.createdAt
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
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) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleOpen(project.id)
|
handleOpen(recollection.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
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">
|
<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
|
<div
|
||||||
className="absolute right-1.5 top-1.5 z-10 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ProjectActionsMenu
|
<RecollectionActionsMenu
|
||||||
project={project}
|
recollection={recollection}
|
||||||
onOpen={handleOpen}
|
onOpen={handleOpen}
|
||||||
onRenameOpen={handleRenameOpen}
|
onRenameOpen={handleRenameOpen}
|
||||||
onDuplicateOpen={handleDuplicateOpen}
|
onDuplicateOpen={handleDuplicateOpen}
|
||||||
@@ -849,7 +862,7 @@ export function ProjectsPage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 rounded-full shadow-sm"
|
className="size-7 rounded-full shadow-sm"
|
||||||
aria-label={`Actions for ${project.name}`}
|
aria-label={`Actions for ${recollection.name}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<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 flex-1 flex-col gap-1 p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
<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>
|
</div>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -878,7 +891,7 @@ export function ProjectsPage() {
|
|||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Page {page + 1} of {totalPages}
|
Page {page + 1} of {totalPages}
|
||||||
{pageSize !== -1 && ` · ${sorted.length} projects`}
|
{pageSize !== -1 && ` · ${sorted.length} recollections`}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -924,44 +937,24 @@ export function ProjectsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rename dialog */}
|
<RenameRecollectionDialog
|
||||||
<Dialog open={!!renameTarget} onOpenChange={(open) => !open && setRenameTarget(null)}>
|
open={!!renameTarget}
|
||||||
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
onOpenChange={(open) => !open && setRenameTarget(null)}
|
||||||
<DialogHeader>
|
recollectionId={renameTarget?.id ?? ''}
|
||||||
<DialogTitle>Rename project</DialogTitle>
|
initialName={renameTarget?.name ?? ''}
|
||||||
<DialogDescription>Enter a new name for this project.</DialogDescription>
|
recollections={sorted}
|
||||||
</DialogHeader>
|
onRename={(id, newName) => {
|
||||||
<Input
|
renameRecollection(id, newName)
|
||||||
ref={renameInputRef}
|
setRenameTarget(null)
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Duplicate dialog */}
|
{/* Duplicate dialog */}
|
||||||
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>
|
<Dialog open={!!duplicateTarget} onOpenChange={(open) => { if (!open) { setDuplicateTarget(null); setDuplicateName('') } }}>
|
||||||
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
<DialogContent onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Duplicate project</DialogTitle>
|
<DialogTitle>Duplicate recollection</DialogTitle>
|
||||||
<DialogDescription>Enter a name for the duplicate project.</DialogDescription>
|
<DialogDescription>Enter a name for the duplicate recollection.</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Input
|
<Input
|
||||||
ref={duplicateInputRef}
|
ref={duplicateInputRef}
|
||||||
@@ -971,11 +964,11 @@ export function ProjectsPage() {
|
|||||||
if (e.key === 'Enter') handleDuplicateConfirm()
|
if (e.key === 'Enter') handleDuplicateConfirm()
|
||||||
if (e.key === 'Escape') setDuplicateTarget(null)
|
if (e.key === 'Escape') setDuplicateTarget(null)
|
||||||
}}
|
}}
|
||||||
placeholder="Project name"
|
placeholder="Recollection name"
|
||||||
aria-label="Duplicate project name"
|
aria-label="Duplicate recollection name"
|
||||||
/>
|
/>
|
||||||
{duplicateTarget && duplicateName.trim() && orderedProjects.some((p) => p.name.toLowerCase() === duplicateName.trim().toLowerCase()) && (
|
{duplicateTarget && duplicateName.trim() && orderedRecollections.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>
|
<p className="text-xs text-amber-600 dark:text-amber-500">An recollection with this name already exists.</p>
|
||||||
)}
|
)}
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => { setDuplicateTarget(null); setDuplicateName('') }}>
|
<Button variant="outline" onClick={() => { setDuplicateTarget(null); setDuplicateName('') }}>
|
||||||
@@ -992,7 +985,7 @@ export function ProjectsPage() {
|
|||||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete project</DialogTitle>
|
<DialogTitle>Delete recollection</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{deleteTarget
|
{deleteTarget
|
||||||
? `Are you sure you want to delete "${deleteTarget.name}"? You can undo this from the notification.`
|
? `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)}>
|
<Dialog open={bulkDeleteTargets !== null && bulkDeleteTargets.length > 0} onOpenChange={(open) => !open && setBulkDeleteTargets(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete {bulkDeleteTargets?.length ?? 0} projects</DialogTitle>
|
<DialogTitle>Delete {bulkDeleteTargets?.length ?? 0} recollections</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<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.
|
* 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)'
|
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 canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
const timeRef = useRef(0)
|
const timeRef = useRef(0)
|
||||||
const rafRef = useRef<number>(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,15 +1,16 @@
|
|||||||
import React, { useContext, useMemo, memo } from 'react'
|
import React, { memo } from 'react'
|
||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
getBezierPath,
|
getBezierPath,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
import { useCanvasStore } from '@/app/canvas/canvasStore'
|
||||||
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
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 EDGE_STROKE_WIDTH = 2
|
||||||
const DOT_MARKER_R = 1.5
|
const DOT_MARKER_R = 1.5
|
||||||
const EMPTY_PATH_NODE_IDS = new Set<string>()
|
|
||||||
|
|
||||||
function AnimatedEdgeInner({
|
function AnimatedEdgeInner({
|
||||||
id,
|
id,
|
||||||
@@ -26,38 +27,16 @@ function AnimatedEdgeInner({
|
|||||||
target,
|
target,
|
||||||
data,
|
data,
|
||||||
}: EdgeProps) {
|
}: EdgeProps) {
|
||||||
const ctx = useContext(ConnectionPathContext)
|
const connectionStatus = useCanvasStore((s) =>
|
||||||
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
|
selectConnectionStatusForEdge(s, source, target)
|
||||||
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 label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
|
|
||||||
|
|
||||||
const connectionStatus = useMemo(
|
|
||||||
() =>
|
|
||||||
getConnectionStatus({
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
pathNodeIds,
|
|
||||||
pausedSegmentNodeIds,
|
|
||||||
activeSegmentNodeIds,
|
|
||||||
errorTargetNodeIds,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
pathNodeIds,
|
|
||||||
pausedSegmentNodeIds,
|
|
||||||
activeSegmentNodeIds,
|
|
||||||
errorTargetNodeIds,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
|
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({
|
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
@@ -117,7 +96,7 @@ function AnimatedEdgeInner({
|
|||||||
className={`animated-edge-path${statusClass ? ` ${statusClass}` : ''}`}
|
className={`animated-edge-path${statusClass ? ` ${statusClass}` : ''}`}
|
||||||
interactionWidth={interactionWidth}
|
interactionWidth={interactionWidth}
|
||||||
/>
|
/>
|
||||||
{label != null && (
|
{displayLabel != null && displayLabel !== '' && (
|
||||||
<g transform={`translate(${edgeLabelX}, ${edgeLabelY})`} className="nodrag nopan">
|
<g transform={`translate(${edgeLabelX}, ${edgeLabelY})`} className="nodrag nopan">
|
||||||
<rect
|
<rect
|
||||||
x={-32}
|
x={-32}
|
||||||
@@ -134,14 +113,13 @@ function AnimatedEdgeInner({
|
|||||||
dominantBaseline="middle"
|
dominantBaseline="middle"
|
||||||
className="fill-foreground text-[10px] font-medium"
|
className="fill-foreground text-[10px] font-medium"
|
||||||
>
|
>
|
||||||
{label}
|
{displayLabel}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean {
|
function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean {
|
||||||
return (
|
return (
|
||||||
prev.id === next.id &&
|
prev.id === next.id &&
|
||||||
@@ -155,9 +133,12 @@ function edgePropsAreEqual(prev: EdgeProps, next: EdgeProps): boolean {
|
|||||||
prev.targetPosition === next.targetPosition &&
|
prev.targetPosition === next.targetPosition &&
|
||||||
prev.style === next.style &&
|
prev.style === next.style &&
|
||||||
prev.label === next.label &&
|
prev.label === next.label &&
|
||||||
(prev.data as { connectionLabel?: string } | undefined)?.connectionLabel ===
|
(prev.data as { connectionLabel?: string; targetType?: string } | undefined)?.connectionLabel ===
|
||||||
(next.data as { connectionLabel?: 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)
|
export const AnimatedEdge = memo(AnimatedEdgeInner, edgePropsAreEqual)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { ComponentProps, ReactNode } from "react";
|
|||||||
import { NodeResizer } from "@xyflow/react";
|
import { NodeResizer } from "@xyflow/react";
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
|
|
||||||
import { FlowUIContext, useConnectionPathRole } from "@/lib/graph/flowContext";
|
import { FlowUIContext } from "@/lib/graph/flowContext";
|
||||||
|
import { useConnectionPathRoleFromStore } from "@/app/canvas/useCanvasConnectionPathFromStore";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Default min size for resizable nodes (used by NodeResizer). */
|
/** Default min size for resizable nodes (used by NodeResizer). */
|
||||||
@@ -38,7 +39,7 @@ export function BaseNode({
|
|||||||
}: BaseNodeProps) {
|
}: BaseNodeProps) {
|
||||||
const flowUIContext = useContext(FlowUIContext);
|
const flowUIContext = useContext(FlowUIContext);
|
||||||
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
||||||
const connectionPathRole = useConnectionPathRole(nodeId);
|
const connectionPathRole = useConnectionPathRoleFromStore(nodeId);
|
||||||
const hasSize =
|
const hasSize =
|
||||||
dimensions &&
|
dimensions &&
|
||||||
dimensions.width > 0 &&
|
dimensions.width > 0 &&
|
||||||
@@ -68,18 +69,20 @@ export function BaseNode({
|
|||||||
"hover:ring-1",
|
"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)]",
|
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 === "trigger" && "connection-path-trigger",
|
||||||
connectionPathRole === "updating" && "connection-path-updating",
|
|
||||||
connectionPathRole === "on-path" && "connection-path-on-path",
|
connectionPathRole === "on-path" && "connection-path-on-path",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
data-selected={selected}
|
data-selected={selected}
|
||||||
data-path-role={connectionPathRole ?? undefined}
|
data-path-role={connectionPathRole ?? undefined}
|
||||||
style={appliedStyle}
|
style={appliedStyle}
|
||||||
tabIndex={0}
|
tabIndex={selected ? 0 : -1}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div
|
<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" }}
|
style={{ contain: "layout" }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -96,7 +99,9 @@ export function BaseNode({
|
|||||||
handleClassName="base-node-resize-handle nodrag nopan"
|
handleClassName="base-node-resize-handle nodrag nopan"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isFullscreenInstance && handles}
|
{!isFullscreenInstance && (
|
||||||
|
<div className={cn(!selected && "pointer-events-none")}>{handles}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,7 +203,7 @@ export function BaseNodeContent({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="base-node-content"
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export function FlowKeyboardShortcuts() {
|
|||||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||||
const graphCtx = useContext(GraphContext)
|
const graphCtx = useContext(GraphContext)
|
||||||
const uiCtx = useContext(FlowUIContext)
|
const uiCtx = useContext(FlowUIContext)
|
||||||
const nodes = graphCtx?.nodes ?? []
|
|
||||||
const setNodes = graphCtx?.setNodes
|
const setNodes = graphCtx?.setNodes
|
||||||
const setConnectionFrom = uiCtx?.setConnectionFrom
|
const setConnectionFrom = uiCtx?.setConnectionFrom
|
||||||
const flowActionsRef = uiCtx?.flowActionsRef
|
const flowActionsRef = uiCtx?.flowActionsRef
|
||||||
@@ -80,6 +79,7 @@ export function FlowKeyboardShortcuts() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (ev: KeyboardEvent) => {
|
const onKeyDown = (ev: KeyboardEvent) => {
|
||||||
|
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
const openDialog = document.querySelector('[role="dialog"]')
|
const openDialog = document.querySelector('[role="dialog"]')
|
||||||
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
|
if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return
|
||||||
@@ -152,7 +152,7 @@ export function FlowKeyboardShortcuts() {
|
|||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown, true)
|
return () => window.removeEventListener('keydown', onKeyDown, true)
|
||||||
}, [
|
}, [
|
||||||
nodes,
|
graphCtx?.graphRef,
|
||||||
setNodes,
|
setNodes,
|
||||||
setConnectionFrom,
|
setConnectionFrom,
|
||||||
pasteAtViewportCenter,
|
pasteAtViewportCenter,
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ type Props = {
|
|||||||
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
||||||
const graphCtx = useContext(GraphContext)
|
const graphCtx = useContext(GraphContext)
|
||||||
const uiCtx = useContext(FlowUIContext)
|
const uiCtx = useContext(FlowUIContext)
|
||||||
const nodes = graphCtx?.nodes ?? []
|
|
||||||
const setNodes = graphCtx?.setNodes
|
const setNodes = graphCtx?.setNodes
|
||||||
const edges = graphCtx?.edges ?? []
|
|
||||||
const setEdges = graphCtx?.setEdges
|
const setEdges = graphCtx?.setEdges
|
||||||
const renamingNodeId = uiCtx?.renamingNodeId ?? null
|
const renamingNodeId = uiCtx?.renamingNodeId ?? null
|
||||||
const setRenamingNodeId = uiCtx?.setRenamingNodeId
|
const setRenamingNodeId = uiCtx?.setRenamingNodeId
|
||||||
@@ -32,7 +30,9 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
|||||||
}, [isRenaming, nodeId])
|
}, [isRenaming, nodeId])
|
||||||
|
|
||||||
const applyRename = useCallback(() => {
|
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()
|
const newId = inputValue.trim()
|
||||||
if (!newId || newId === nodeId) {
|
if (!newId || newId === nodeId) {
|
||||||
setRenamingNodeId(null)
|
setRenamingNodeId(null)
|
||||||
@@ -46,7 +46,7 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) {
|
|||||||
setNodes(nextNodes as AppNode[])
|
setNodes(nextNodes as AppNode[])
|
||||||
setEdges(nextEdges)
|
setEdges(nextEdges)
|
||||||
setRenamingNodeId(null)
|
setRenamingNodeId(null)
|
||||||
}, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId])
|
}, [nodeId, inputValue, graphCtx?.graphRef, setNodes, setEdges, setRenamingNodeId])
|
||||||
|
|
||||||
const cancelRename = useCallback(() => {
|
const cancelRename = useCallback(() => {
|
||||||
setRenamingNodeId?.(null)
|
setRenamingNodeId?.(null)
|
||||||
|
|||||||
@@ -45,10 +45,9 @@ type Props = {
|
|||||||
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) {
|
export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) {
|
||||||
const graphCtx = useContext(GraphContext)
|
const graphCtx = useContext(GraphContext)
|
||||||
const uiCtx = useContext(FlowUIContext)
|
const uiCtx = useContext(FlowUIContext)
|
||||||
const nodes = graphCtx?.nodes ?? []
|
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||||
const setNodes = graphCtx?.setNodes
|
const setNodes = graphCtx?.setNodes
|
||||||
const setEdges = graphCtx?.setEdges
|
const setEdges = graphCtx?.setEdges
|
||||||
|
|
||||||
const edges = graphCtx?.edges ?? []
|
const edges = graphCtx?.edges ?? []
|
||||||
const node = nodes.find((n: any) => n.id === nodeId)
|
const node = nodes.find((n: any) => n.id === nodeId)
|
||||||
const nodeMenuExtraContent = useMemo(
|
const nodeMenuExtraContent = useMemo(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { observeResize } from '@/lib/sharedResizeObserver'
|
||||||
|
|
||||||
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
|
export type NodeStatus = 'loading' | 'success' | 'error' | 'initial'
|
||||||
|
|
||||||
@@ -58,27 +59,15 @@ function BorderLoadingIndicator({
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const container = containerRef.current
|
const container = containerRef.current
|
||||||
if (!container) return
|
if (!container) return
|
||||||
// Observe the first child (BaseNode) so we use its actual rendered size.
|
|
||||||
const target =
|
const target =
|
||||||
container.firstElementChild instanceof HTMLElement
|
container.firstElementChild instanceof HTMLElement
|
||||||
? container.firstElementChild
|
? container.firstElementChild
|
||||||
: container
|
: container
|
||||||
const syncMeasure = () => {
|
// Use ResizeObserver only to avoid forced synchronous layout (offsetWidth/offsetHeight).
|
||||||
const cw = (target as HTMLElement).offsetWidth
|
const unObserve = observeResize(target, ({ width: w, height: h }) => {
|
||||||
const ch = (target as HTMLElement).offsetHeight
|
if (w > 0 && h > 0) setMeasured({ w, h })
|
||||||
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) })
|
|
||||||
})
|
})
|
||||||
ro.observe(target)
|
return unObserve
|
||||||
return () => ro.disconnect()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ export function getAgentNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
.idPrefix('agt_')
|
.idPrefix('agt_')
|
||||||
.withInputOutput(true, true)
|
.withInputOutput(true, true)
|
||||||
.classification('archon')
|
.classification('archon')
|
||||||
.allowedSourceTypes(['config', 'variable', 'data'])
|
.allowedSourceTypes(['config', 'variable', 'data', 'render'])
|
||||||
.allowedTargetTypes(['render'])
|
.allowedTargetTypes(['render'])
|
||||||
.help(NODE_HELP.agent)
|
.help(NODE_HELP.agent)
|
||||||
.menu('Agent', <Bot className={ICON_CLASS} />)
|
.menu('Agent', <Bot className={ICON_CLASS} />)
|
||||||
.connectionLabel('prompt/context')
|
.connectionLabelByStatus({ default: 'prompt/context', updating: 'running', paused: 'pending', error: 'corrupted' })
|
||||||
.withFullscreen()
|
.withFullscreen()
|
||||||
.sourceRenderingLogic(agentRenderingLogic)
|
.sourceRenderingLogic(agentRenderingLogic)
|
||||||
.outputMenuContent(() => null)
|
.outputMenuContent(() => null)
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
import React, { useCallback, useContext, useId, useMemo } 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 {
|
import {
|
||||||
AbstractNodeProps,
|
AbstractNodeProps,
|
||||||
createAbstractNodeComponent,
|
createAbstractNodeComponent,
|
||||||
|
getConnectedNodesByType,
|
||||||
useAbstractNode,
|
useAbstractNode,
|
||||||
type FlowNode,
|
|
||||||
} from '@/lib/graph/abstractNode'
|
} from '@/lib/graph/abstractNode'
|
||||||
|
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||||
|
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
|
||||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||||
import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
|
import { type HighlightLanguage } from '@/lib/syntaxHighlight'
|
||||||
import { plantumlLanguage } from '@/lib/plantumlLanguage'
|
|
||||||
import { useTheme } from '@/lib/themeContext'
|
|
||||||
import {
|
import {
|
||||||
getConfigTypes,
|
getConfigTypes,
|
||||||
getConfigContent,
|
getConfigContent,
|
||||||
@@ -29,7 +25,7 @@ import {
|
|||||||
BaseNodeFooter,
|
BaseNodeFooter,
|
||||||
BaseNodeHeaderRow,
|
BaseNodeHeaderRow,
|
||||||
} from '@/components/graph/BaseNode'
|
} from '@/components/graph/BaseNode'
|
||||||
import { Code2, Database, ScrollText, Variable } from 'lucide-react'
|
import { Code2, Database, ScrollText, Sparkles, Variable } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
MenubarItem,
|
MenubarItem,
|
||||||
MenubarSeparator,
|
MenubarSeparator,
|
||||||
@@ -38,6 +34,7 @@ import {
|
|||||||
MenubarSubContent,
|
MenubarSubContent,
|
||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
} from '@/components/ui/menubar'
|
} from '@/components/ui/menubar'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { Kbd } from '@/components/ui/kbd'
|
import { Kbd } from '@/components/ui/kbd'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
|
||||||
@@ -58,32 +55,40 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
const configTypeId = getConfigTypeId(data ?? {})
|
const configTypeId = getConfigTypeId(data ?? {})
|
||||||
const configType = getConfigType(configTypeId)
|
const configType = getConfigType(configTypeId)
|
||||||
const content = getConfigContent(data ?? {})
|
const content = getConfigContent(data ?? {})
|
||||||
const { theme } = useTheme()
|
|
||||||
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
const { nodes, sourceIds, updateData } = useAbstractNode<ConfigNodeData>(id, data ?? {})
|
||||||
const editorRef = useRef<unknown>(null)
|
const editorId = useId()
|
||||||
|
|
||||||
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 onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(val: string) => updateData({ content: val, configType: configTypeId }),
|
(val: string) => updateData({ content: val, configType: configTypeId }),
|
||||||
[updateData, 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(
|
const setConfigType = useCallback(
|
||||||
(newTypeId: ConfigTypeId) => {
|
(newTypeId: ConfigTypeId) => {
|
||||||
@@ -96,36 +101,6 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
[configTypeId, data, updateData]
|
[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(
|
const insertExtendsFromNode = useCallback(
|
||||||
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
|
||||||
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
|
insertAt(`{% extends "${sourceNode.id}" %}\n`, mode)
|
||||||
@@ -168,6 +143,18 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
[insertAt]
|
[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 variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes])
|
||||||
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
|
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
|
||||||
const configTitles = useMemo(
|
const configTitles = useMemo(
|
||||||
@@ -175,21 +162,8 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
[connectedConfigNodes],
|
[connectedConfigNodes],
|
||||||
)
|
)
|
||||||
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
|
const dataIds = useMemo(() => connectedDataNodes.map((n: any) => n.id), [connectedDataNodes])
|
||||||
const extensions = useMemo(() => {
|
const highlightLang: HighlightLanguage =
|
||||||
const lang =
|
configTypeId === 'wireframe' ? 'javascript' : configType.language === 'plantuml' ? 'plantuml' : 'markdown'
|
||||||
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 [editorHeight, editorContainerRef] = useResizeHeight(180)
|
const [editorHeight, editorContainerRef] = useResizeHeight(180)
|
||||||
|
|
||||||
const insertBlocksContent = useMemo(() => {
|
const insertBlocksContent = useMemo(() => {
|
||||||
@@ -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>
|
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
|
||||||
</MenubarItem>
|
</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>
|
<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>
|
||||||
|
|
||||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
<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 }}>
|
||||||
<CodeMirror
|
<CodeEditor
|
||||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
textareaId={editorId}
|
||||||
ref={editorRef}
|
|
||||||
value={content}
|
value={content}
|
||||||
height={`${editorHeight}px`}
|
onValueChange={onChange}
|
||||||
theme={theme}
|
language={highlightLang}
|
||||||
extensions={extensions}
|
minHeight={editorHeight}
|
||||||
onChange={onChange}
|
style={{ fontSize: 14 }}
|
||||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
textareaClassName="text-sm outline-none border-0 resize-none nodrag nopan"
|
||||||
className="text-sm [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
preClassName="text-sm nodrag nopan"
|
||||||
|
className="min-h-0 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BaseNodeContent>
|
</BaseNodeContent>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
.idPrefix('cfg_')
|
.idPrefix('cfg_')
|
||||||
.withInputOutput(true, true)
|
.withInputOutput(true, true)
|
||||||
.classification('psyche')
|
.classification('psyche')
|
||||||
.allowedSourceTypes(['config', 'variable', 'function', 'data'])
|
.allowedSourceTypes(['config', 'variable', 'function', 'data', 'render'])
|
||||||
.allowedTargetTypes(['config', 'render', 'agent'])
|
.allowedTargetTypes(['config', 'render', 'agent'])
|
||||||
.help(NODE_HELP.config)
|
.help(NODE_HELP.config)
|
||||||
.menu('Config', <ScrollText className={ICON_CLASS} />)
|
.menu('Config', <ScrollText className={ICON_CLASS} />)
|
||||||
@@ -82,7 +82,7 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
content: '@startuml\n\n@enduml\n',
|
content: '@startuml\n\n@enduml\n',
|
||||||
title: nodeId ?? '',
|
title: nodeId ?? '',
|
||||||
}))
|
}))
|
||||||
.connectionLabel('adding input')
|
.connectionLabelByStatus({ default: 'adding input', updating: 'pushing new form', paused: 'pending', error: 'corrupted' })
|
||||||
.withFullscreen()
|
.withFullscreen()
|
||||||
.sourceRenderingLogic({
|
.sourceRenderingLogic({
|
||||||
defaultUpdateMode: 'auto',
|
defaultUpdateMode: 'auto',
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/g
|
|||||||
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
|
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
|
||||||
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike } from '@/lib/graph/templateRefs'
|
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike } from '@/lib/graph/templateRefs'
|
||||||
|
|
||||||
|
// Module-level cache for resolved Nunjucks output.
|
||||||
|
// Keyed by a fingerprint of all inputs (template contents + variable/data values + function bodies).
|
||||||
|
// Avoids re-running the Nunjucks environment when the same inputs are seen again (e.g. undo/redo,
|
||||||
|
// multiple rendering nodes sharing the same config, rapid edits cycling back to a prior value).
|
||||||
|
const MAX_RESOLVE_CACHE = 200
|
||||||
|
const resolveCache = new Map<string, ResolvedContentResult>()
|
||||||
|
|
||||||
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
|
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
|
||||||
const { nodes, edges, sourceNodeId, renderNodeId } = context
|
const { nodes, edges, sourceNodeId, renderNodeId } = context
|
||||||
const srcId = sourceNodeId
|
const srcId = sourceNodeId
|
||||||
@@ -74,6 +81,10 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
|||||||
})
|
})
|
||||||
nunjucksContext[src.id] = filteredRows
|
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>()
|
const functionIdsToRegister = new Set<string>()
|
||||||
@@ -103,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 env = new nunjucks.Environment([configLoader], { autoescape: false })
|
||||||
|
|
||||||
const formatFilterResult = (r: unknown): string => {
|
const formatFilterResult = (r: unknown): string => {
|
||||||
@@ -244,7 +273,13 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function getDataNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
.allowedTargetTypes(['config', 'agent'])
|
.allowedTargetTypes(['config', 'agent'])
|
||||||
.help(NODE_HELP.data)
|
.help(NODE_HELP.data)
|
||||||
.menu('Data', <Database className={ICON_CLASS} />)
|
.menu('Data', <Database className={ICON_CLASS} />)
|
||||||
.connectionLabel('data source')
|
.connectionLabelByStatus({ default: 'data source' })
|
||||||
.withFullscreen()
|
.withFullscreen()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
import React, { useCallback, useContext, useId, useMemo } from 'react'
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
|
||||||
import { javascript } from '@codemirror/lang-javascript'
|
|
||||||
import {
|
import {
|
||||||
AbstractNodeProps,
|
AbstractNodeProps,
|
||||||
createAbstractNodeComponent,
|
createAbstractNodeComponent,
|
||||||
|
getConnectedNodesByType,
|
||||||
useAbstractNode,
|
useAbstractNode,
|
||||||
type FlowNode,
|
|
||||||
} from '@/lib/graph/abstractNode'
|
} from '@/lib/graph/abstractNode'
|
||||||
|
import { CodeEditor } from '@/components/editor/CodeEditor'
|
||||||
|
import { useSimpleEditorInsert } from '@/hooks/useSimpleEditorInsert'
|
||||||
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||||
import { useTheme } from '@/lib/themeContext'
|
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
BaseNodeContent,
|
BaseNodeContent,
|
||||||
@@ -29,50 +28,30 @@ export type FunctionNodeData = { body?: string }
|
|||||||
|
|
||||||
type Props = AbstractNodeProps<FunctionNodeData>
|
type Props = AbstractNodeProps<FunctionNodeData>
|
||||||
|
|
||||||
|
const LANGUAGE: HighlightLanguage = 'javascript'
|
||||||
|
|
||||||
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
||||||
const flowUIContext = useContext(FlowUIContext)
|
const flowUIContext = useContext(FlowUIContext)
|
||||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||||
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
|
const supportsFullscreen = getNodeType('function')?.supportsFullscreen
|
||||||
const bodyValue = data?.body ?? ''
|
const bodyValue = data?.body ?? ''
|
||||||
const { theme } = useTheme()
|
|
||||||
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
const { nodes, sourceIds, updateData } = useAbstractNode<FunctionNodeData>(id, data ?? {})
|
||||||
const editorRef = useRef<unknown>(null)
|
const editorId = useId()
|
||||||
|
|
||||||
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 onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(val: string) => updateData({ body: val }),
|
(val: string) => updateData({ body: val }),
|
||||||
[updateData]
|
[updateData]
|
||||||
)
|
)
|
||||||
|
const insertAt = useSimpleEditorInsert(editorId, bodyValue, onChange)
|
||||||
|
|
||||||
const insertAt = useCallback(
|
const connectedVariableNodes = useMemo(
|
||||||
(insertText: string, mode: 'prepend' | 'append' | 'cursor') => {
|
() => getConnectedNodesByType(nodes, sourceIds, 'variable'),
|
||||||
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
|
[nodes, sourceIds]
|
||||||
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 connectedFunctionNodes = useMemo(
|
||||||
|
() => getConnectedNodesByType(nodes, sourceIds, 'function'),
|
||||||
|
[nodes, sourceIds]
|
||||||
|
)
|
||||||
|
const hasConnectedInputs = connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0
|
||||||
|
|
||||||
const insertVariableAtCursor = useCallback(
|
const insertVariableAtCursor = useCallback(
|
||||||
(variableNode: any) => {
|
(variableNode: any) => {
|
||||||
@@ -88,7 +67,6 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
[insertAt]
|
[insertAt]
|
||||||
)
|
)
|
||||||
|
|
||||||
const extensions = useMemo(() => [javascript()], [])
|
|
||||||
const [editorHeight, editorContainerRef] = useResizeHeight(120)
|
const [editorHeight, editorContainerRef] = useResizeHeight(120)
|
||||||
const dimensions =
|
const dimensions =
|
||||||
width != null && height != null && width > 0 && height > 0
|
width != null && height != null && width > 0 && height > 0
|
||||||
@@ -140,17 +118,17 @@ function FunctionNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div ref={editorContainerRef as React.RefObject<HTMLDivElement>} className="min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
<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 }}>
|
||||||
<CodeMirror
|
<CodeEditor
|
||||||
// @ts-expect-error ref is { view, state, editor }; package ref type not in our node_modules
|
textareaId={editorId}
|
||||||
ref={editorRef}
|
|
||||||
value={bodyValue}
|
value={bodyValue}
|
||||||
height={`${editorHeight}px`}
|
onValueChange={onChange}
|
||||||
theme={theme}
|
language="javascript"
|
||||||
extensions={extensions}
|
minHeight={editorHeight}
|
||||||
onChange={onChange}
|
style={{ fontSize: 12 }}
|
||||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
textareaClassName="text-xs outline-none border-0 resize-none nodrag nopan"
|
||||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0"
|
preClassName="text-xs nodrag nopan"
|
||||||
|
className="min-h-0 w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</BaseNodeContent>
|
</BaseNodeContent>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function getFunctionNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
.help(NODE_HELP.function)
|
.help(NODE_HELP.function)
|
||||||
.menu('Function', <Code2 className={ICON_CLASS} />)
|
.menu('Function', <Code2 className={ICON_CLASS} />)
|
||||||
.getResetData(() => ({ body: '' }))
|
.getResetData(() => ({ body: '' }))
|
||||||
.connectionLabel('adding input')
|
.connectionLabelByStatus({ default: 'adding input', updating: 'running', paused: 'pending', error: 'corrupted' })
|
||||||
.withFullscreen()
|
.withFullscreen()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
} from '@/components/ui/menubar'
|
} from '@/components/ui/menubar'
|
||||||
import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { ButtonGroup } from '@/components/ui/button-group'
|
import { ButtonGroup } from '@/components/ui/button-group'
|
||||||
import {
|
import {
|
||||||
@@ -33,28 +33,31 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useTheme } from '@/lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import {
|
import {
|
||||||
useRenderingNodeState,
|
useRenderingNodeState,
|
||||||
type RenderingNodeData,
|
type RenderingNodeData,
|
||||||
} from './useRenderingNodeState'
|
} from './useRenderingNodeState'
|
||||||
import { ImageOutputView, MarkdownOutputView, RawOutputView } from './views'
|
import {
|
||||||
|
ImageOutputView,
|
||||||
|
MarkdownJsxView,
|
||||||
|
RawOutputView,
|
||||||
|
StaticMarkdownHtmlView,
|
||||||
|
StreamingMarkdownView,
|
||||||
|
} from './views'
|
||||||
|
|
||||||
export type { RenderingNodeData }
|
export type { RenderingNodeData }
|
||||||
|
|
||||||
type Props = AbstractNodeProps<RenderingNodeData>
|
type Props = AbstractNodeProps<RenderingNodeData>
|
||||||
|
|
||||||
type ViewMode = 'preview' | 'raw'
|
|
||||||
|
|
||||||
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||||
const flowUIContext = useContext(FlowUIContext)
|
const flowUIContext = useContext(FlowUIContext)
|
||||||
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
|
||||||
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
||||||
|
|
||||||
const state = useRenderingNodeState(id, data)
|
const state = useRenderingNodeState(id, data)
|
||||||
|
const outputMode = state.outputMode
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('preview')
|
|
||||||
const [viewportFocused, setViewportFocused] = useState(false)
|
const [viewportFocused, setViewportFocused] = useState(false)
|
||||||
|
|
||||||
const dimensions =
|
const dimensions =
|
||||||
@@ -63,7 +66,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode])
|
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [outputMode])
|
||||||
|
|
||||||
const showEmpty =
|
const showEmpty =
|
||||||
state.incomingIds.length === 0 &&
|
state.incomingIds.length === 0 &&
|
||||||
@@ -114,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) {
|
if (state.loading && state.streamingMarkdown !== null) {
|
||||||
return <MarkdownOutputView state={state} streaming={true} />
|
return <StreamingMarkdownView state={state} />
|
||||||
}
|
}
|
||||||
if (state.loading) {
|
if (state.loading) {
|
||||||
return (
|
return (
|
||||||
@@ -137,7 +149,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
resizable
|
resizable
|
||||||
nodeId={id}
|
nodeId={id}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
handles={<InputHandle id="ain" nodeId={id} />}
|
handles={
|
||||||
|
<>
|
||||||
|
<InputHandle id="ain" nodeId={id} />
|
||||||
|
<OutputHandle id="out" />
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<BaseNodeHeaderRow
|
<BaseNodeHeaderRow
|
||||||
icon={<Sparkles className="size-4" />}
|
icon={<Sparkles className="size-4" />}
|
||||||
@@ -151,46 +168,51 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
state.incomingIds.length > 0 ? (
|
state.incomingIds.length > 0 ? (
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<ButtonGroup className="nodrag nopan">
|
<ButtonGroup className="nodrag nopan">
|
||||||
<Button
|
{state.effectiveUpdateMode === 'manual' ? (
|
||||||
type="button"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
variant="outline"
|
size="sm"
|
||||||
disabled={state.loading}
|
variant="outline"
|
||||||
className={cn(
|
disabled={state.loading || !state.hasPendingInputs}
|
||||||
'h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs',
|
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
|
||||||
state.effectiveUpdateMode === 'manual' &&
|
onClick={(e) => {
|
||||||
state.hasPendingInputs &&
|
e.stopPropagation()
|
||||||
'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'
|
state.incrementRunTrigger()
|
||||||
)}
|
}}
|
||||||
onClick={(e) => {
|
title={
|
||||||
e.stopPropagation()
|
state.hasPendingInputs
|
||||||
state.incrementRunTrigger()
|
? 'Inputs changed — click to render'
|
||||||
}}
|
: !state.hasPendingInputs
|
||||||
title={
|
? 'No new data to render'
|
||||||
state.effectiveUpdateMode === 'manual' && state.hasPendingInputs
|
: undefined
|
||||||
? 'Inputs changed — click to render'
|
}
|
||||||
: undefined
|
>
|
||||||
}
|
{state.loading ? (
|
||||||
>
|
<Loader2 className="size-3.5 animate-spin shrink-0" aria-hidden />
|
||||||
{state.loading ? (
|
) : (
|
||||||
|
<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 />
|
<Loader2 className="size-3.5 animate-spin shrink-0" aria-hidden />
|
||||||
) : (
|
</Button>
|
||||||
<Play className="size-3.5 shrink-0" />
|
) : null}
|
||||||
)}
|
|
||||||
Run
|
|
||||||
</Button>
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
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'}`}
|
||||||
'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'
|
|
||||||
)}
|
|
||||||
aria-label="Update mode"
|
aria-label="Update mode"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
@@ -245,17 +267,17 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
<>
|
<>
|
||||||
<MenubarCheckboxItem
|
<MenubarCheckboxItem
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
checked={viewMode === 'preview'}
|
checked={outputMode === 'image'}
|
||||||
onCheckedChange={(checked) => checked && setViewMode('preview')}
|
onCheckedChange={(checked) => checked && state.setOutputMode('image')}
|
||||||
>
|
>
|
||||||
Preview
|
Image
|
||||||
</MenubarCheckboxItem>
|
</MenubarCheckboxItem>
|
||||||
<MenubarCheckboxItem
|
<MenubarCheckboxItem
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
checked={viewMode === 'raw'}
|
checked={outputMode === 'string'}
|
||||||
onCheckedChange={(checked) => checked && setViewMode('raw')}
|
onCheckedChange={(checked) => checked && state.setOutputMode('string')}
|
||||||
>
|
>
|
||||||
Raw
|
String
|
||||||
</MenubarCheckboxItem>
|
</MenubarCheckboxItem>
|
||||||
{getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })}
|
{getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })}
|
||||||
</>
|
</>
|
||||||
@@ -287,7 +309,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
</Empty>
|
</Empty>
|
||||||
) : state.error ? (
|
) : state.error ? (
|
||||||
errorUi
|
errorUi
|
||||||
) : viewMode === 'raw' ? (
|
) : outputMode === 'string' ? (
|
||||||
<RawOutputView
|
<RawOutputView
|
||||||
state={state}
|
state={state}
|
||||||
height={rawEditorHeight}
|
height={rawEditorHeight}
|
||||||
@@ -302,12 +324,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
|
|
||||||
<BaseNodeFooter>
|
<BaseNodeFooter>
|
||||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
<NodeFooterEdgeIndicators nodeId={id} nodeType="render">
|
||||||
{viewMode === 'raw'
|
{outputMode === 'string'
|
||||||
? state.rawDisplayContent
|
? state.rawDisplayContent
|
||||||
? `Raw · ${state.rawDisplayContent.length} chars`
|
? `String · ${state.rawDisplayContent.length} chars`
|
||||||
: '—'
|
: '—'
|
||||||
: state.renderedContent
|
: state.renderedContent
|
||||||
? `${state.outputLabel} · ${state.renderedContent.length} chars`
|
? `Image · ${state.renderedContent.length} chars`
|
||||||
: state.error
|
: state.error
|
||||||
? 'Error'
|
? 'Error'
|
||||||
: '—'}
|
: '—'}
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ export function getRenderNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
{ viewportWidth: 1200, viewportHeight: 800 }
|
{ viewportWidth: 1200, viewportHeight: 800 }
|
||||||
)
|
)
|
||||||
.idPrefix('rnd_')
|
.idPrefix('rnd_')
|
||||||
.withInputOutput(true, false)
|
.withInputOutput(true, true)
|
||||||
.classification('pneuma')
|
.classification('pneuma')
|
||||||
.allowedSourceTypes(['config', 'agent'])
|
.allowedSourceTypes(['config', 'agent'])
|
||||||
|
.allowedTargetTypes(['config', 'agent'])
|
||||||
.help(NODE_HELP.render)
|
.help(NODE_HELP.render)
|
||||||
.menu('Renderer', <Sparkles className={ICON_CLASS} />)
|
.menu('Renderer', <Sparkles className={ICON_CLASS} />)
|
||||||
.connectionLabel('rendering')
|
.connectionLabelByStatus({ default: 'listening', updating: 'giving life', paused: 'pending', error: 'corrupted' })
|
||||||
.withFullscreen()
|
.withFullscreen()
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,66 @@
|
|||||||
* runs the resolve → render pipeline, manages streaming/cache,
|
* runs the resolve → render pipeline, manages streaming/cache,
|
||||||
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
||||||
* pipeline interface.
|
* 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 { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
|
||||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||||
|
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
|
import { useOptionalRecollectionActions } from '@/app/recollections/layout/RecollectionActionsContext'
|
||||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||||
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||||
import {
|
import {
|
||||||
@@ -24,26 +77,33 @@ import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph
|
|||||||
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
|
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
|
||||||
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||||
|
|
||||||
|
export type OutputMode = 'image' | 'string'
|
||||||
|
|
||||||
export type RenderingNodeData = {
|
export type RenderingNodeData = {
|
||||||
viewportWidth?: number
|
viewportWidth?: number
|
||||||
viewportHeight?: number
|
viewportHeight?: number
|
||||||
updateMode?: 'auto' | 'manual'
|
updateMode?: 'auto' | 'manual'
|
||||||
runTrigger?: number
|
runTrigger?: number
|
||||||
lastRunSourceSignature?: string
|
lastRunSourceSignature?: string
|
||||||
|
/** Controls which view is shown and what the node emits to downstream (Image = markdown embed, String = resolved text). */
|
||||||
|
outputMode?: OutputMode
|
||||||
cachedRenderedContent?: string
|
cachedRenderedContent?: string
|
||||||
cachedResolvedContent?: string
|
cachedResolvedContent?: string
|
||||||
cachedReasoningContent?: 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_WIDTH = 1200
|
||||||
const DEFAULT_VIEWPORT_HEIGHT = 800
|
const DEFAULT_VIEWPORT_HEIGHT = 800
|
||||||
const RENDER_DEBOUNCE_MS = 250
|
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/paused/error) stays in sync. */
|
/** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/error) stays in sync. */
|
||||||
export type RenderingNodeLifecycle = {
|
export type RenderingNodeLifecycle = {
|
||||||
updating: boolean
|
updating: boolean
|
||||||
error: boolean
|
error: boolean
|
||||||
paused: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RenderingNodeState = {
|
export type RenderingNodeState = {
|
||||||
@@ -95,6 +155,10 @@ export type RenderingNodeState = {
|
|||||||
|
|
||||||
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
|
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
|
||||||
sourceNodeType: string | null
|
sourceNodeType: string | null
|
||||||
|
|
||||||
|
/** Output mode (Image vs String); controls view and emitted cachedOutputValue. */
|
||||||
|
outputMode: OutputMode
|
||||||
|
setOutputMode: (mode: OutputMode) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRenderingNodeState(
|
export function useRenderingNodeState(
|
||||||
@@ -102,22 +166,37 @@ export function useRenderingNodeState(
|
|||||||
data: RenderingNodeData | undefined
|
data: RenderingNodeData | undefined
|
||||||
): RenderingNodeState {
|
): RenderingNodeState {
|
||||||
const {
|
const {
|
||||||
nodes,
|
nodes: contextNodes,
|
||||||
edges,
|
edges: contextEdges,
|
||||||
setNodes,
|
setNodes,
|
||||||
setEdges,
|
setEdges,
|
||||||
sourceIds: incomingIds,
|
sourceIds: incomingIds,
|
||||||
updateData,
|
updateData,
|
||||||
} = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
} = 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 })
|
const nodesEdgesRef = useRef({ nodes, edges })
|
||||||
nodesEdgesRef.current = { nodes, edges }
|
nodesEdgesRef.current = { nodes, edges }
|
||||||
|
|
||||||
const { aiConnection } = usePlatform()
|
const { aiConnection } = usePlatform()
|
||||||
|
const recollectionActions = useOptionalRecollectionActions()
|
||||||
|
const recollectionActionsRef = useRef(recollectionActions)
|
||||||
|
recollectionActionsRef.current = recollectionActions
|
||||||
|
|
||||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||||
|
|
||||||
|
const deferredNodes = useDeferredValue(nodes)
|
||||||
|
const deferredEdges = useDeferredValue(edges)
|
||||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||||
const srcNode = useMemo(
|
const srcNode = useMemo(
|
||||||
() => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null),
|
() => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null),
|
||||||
@@ -130,6 +209,11 @@ export function useRenderingNodeState(
|
|||||||
)
|
)
|
||||||
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual'
|
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual'
|
||||||
const runTrigger = data?.runTrigger ?? 0
|
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 isAgentSource = srcNode?.type === 'agent'
|
||||||
const agentOutputMarkdown = isAgentSource
|
const agentOutputMarkdown = isAgentSource
|
||||||
? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '')
|
? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '')
|
||||||
@@ -150,8 +234,8 @@ export function useRenderingNodeState(
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
const signatures = useMemo(
|
const signatures = useMemo(
|
||||||
() => buildSourceSignatures(nodes as NodeLike[], edges as EdgeLike[], id, incomingIds),
|
() => buildSourceSignatures(deferredNodes as NodeLike[], deferredEdges as EdgeLike[], id, incomingIds),
|
||||||
[nodes, edges, id, incomingIds]
|
[deferredNodes, deferredEdges, id, incomingIds]
|
||||||
)
|
)
|
||||||
const {
|
const {
|
||||||
connectedNodeIds,
|
connectedNodeIds,
|
||||||
@@ -186,23 +270,31 @@ export function useRenderingNodeState(
|
|||||||
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const lastManualRunTriggerRef = useRef(0)
|
const lastManualRunTriggerRef = useRef(0)
|
||||||
const manualRunTriggerSyncedRef = useRef(false)
|
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 pathCtx = useContext(ConnectionPathContext)
|
const updateDataRef = useRef(updateData)
|
||||||
const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? []
|
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 =
|
const hasPendingInputs =
|
||||||
effectiveUpdateMode === 'manual' &&
|
effectiveUpdateMode === 'manual' &&
|
||||||
!loading &&
|
!loading &&
|
||||||
triggerNodeIds.length > 0 &&
|
|
||||||
incomingIds.length > 0 &&
|
incomingIds.length > 0 &&
|
||||||
sourceSignature !== lastRunSourceSignature
|
sourceSignature !== lastRunSourceSignature
|
||||||
|
|
||||||
const lifecycle = useMemo<RenderingNodeLifecycle>(
|
const lifecycle = useMemo<RenderingNodeLifecycle>(
|
||||||
() => ({ updating: loading, error: error != null, paused: hasPendingInputs }),
|
() => ({ updating: loading, error: error != null }),
|
||||||
[loading, error, hasPendingInputs]
|
[loading, error]
|
||||||
)
|
)
|
||||||
useSyncConnectionStatus(id, lifecycle)
|
useSyncConnectionStatus(id, lifecycle)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mountTimeRef.current === 0) mountTimeRef.current = Date.now()
|
||||||
if (incomingIds.length === 0) {
|
if (incomingIds.length === 0) {
|
||||||
setRenderedContent(null)
|
setRenderedContent(null)
|
||||||
setResolvedContent(null)
|
setResolvedContent(null)
|
||||||
@@ -247,6 +339,30 @@ export function useRenderingNodeState(
|
|||||||
lastManualRunTriggerRef.current = runTrigger
|
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
|
runIdRef.current += 1
|
||||||
const thisRunId = runIdRef.current
|
const thisRunId = runIdRef.current
|
||||||
const signatureForThisRun = sourceSignature
|
const signatureForThisRun = sourceSignature
|
||||||
@@ -254,6 +370,7 @@ export function useRenderingNodeState(
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
|
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||||
loadingStartedAtRef.current = Date.now()
|
loadingStartedAtRef.current = Date.now()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -261,10 +378,11 @@ export function useRenderingNodeState(
|
|||||||
setResolvedContent(null)
|
setResolvedContent(null)
|
||||||
setStreamingMarkdown(null)
|
setStreamingMarkdown(null)
|
||||||
setReasoningContent('')
|
setReasoningContent('')
|
||||||
updateData({
|
updateDataRef.current({
|
||||||
cachedRenderedContent: undefined,
|
cachedRenderedContent: undefined,
|
||||||
cachedResolvedContent: undefined,
|
cachedResolvedContent: undefined,
|
||||||
cachedReasoningContent: undefined,
|
cachedReasoningContent: undefined,
|
||||||
|
cachedOutputValue: undefined,
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
|
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
|
||||||
@@ -276,8 +394,8 @@ export function useRenderingNodeState(
|
|||||||
renderNodeId: id,
|
renderNodeId: id,
|
||||||
viewportWidth,
|
viewportWidth,
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
setNodes: setNodes ?? undefined,
|
setNodes: setNodesRef.current ?? undefined,
|
||||||
aiConnection,
|
aiConnection: aiConnectionRef.current,
|
||||||
...(isAgentSource && {
|
...(isAgentSource && {
|
||||||
onStreamingStart: () => setStreamingMarkdown(''),
|
onStreamingStart: () => setStreamingMarkdown(''),
|
||||||
onStreamingChunk: (chunk: string) =>
|
onStreamingChunk: (chunk: string) =>
|
||||||
@@ -295,10 +413,29 @@ export function useRenderingNodeState(
|
|||||||
if (thisRunId !== runIdRef.current) return
|
if (thisRunId !== runIdRef.current) return
|
||||||
setRenderedContent(htmlOrSvg)
|
setRenderedContent(htmlOrSvg)
|
||||||
setError(null)
|
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,
|
cachedRenderedContent: htmlOrSvg,
|
||||||
cachedResolvedContent: resolved,
|
cachedResolvedContent: resolved,
|
||||||
cachedReasoningContent: reasoning ?? '',
|
cachedReasoningContent: reasoning ?? '',
|
||||||
|
cachedOutputValue,
|
||||||
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
|
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
|
||||||
})
|
})
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -349,26 +486,20 @@ export function useRenderingNodeState(
|
|||||||
minLoadingTimeoutRef.current = null
|
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,
|
id,
|
||||||
srcId,
|
srcId,
|
||||||
srcNode?.type,
|
srcNode?.type,
|
||||||
effectiveUpdateMode,
|
effectiveUpdateMode,
|
||||||
runTrigger,
|
runTrigger,
|
||||||
sourceContent,
|
|
||||||
sourceSignature,
|
sourceSignature,
|
||||||
configSignature,
|
|
||||||
edgesSignature,
|
|
||||||
variablesSignature,
|
|
||||||
functionsSignature,
|
|
||||||
dataSignature,
|
|
||||||
viewportWidth,
|
viewportWidth,
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
retryCount,
|
retryCount,
|
||||||
updateData,
|
|
||||||
setNodes,
|
|
||||||
aiConnection,
|
|
||||||
isAgentSource,
|
|
||||||
incomingIds.length,
|
incomingIds.length,
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -525,5 +656,7 @@ export function useRenderingNodeState(
|
|||||||
emptyStateAction,
|
emptyStateAction,
|
||||||
sourceData,
|
sourceData,
|
||||||
sourceNodeType,
|
sourceNodeType,
|
||||||
|
outputMode,
|
||||||
|
setOutputMode,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'
|
|
||||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
|
||||||
import type { RenderingNodeState } from '../useRenderingNodeState'
|
import type { RenderingNodeState } from '../useRenderingNodeState'
|
||||||
|
|
||||||
export type ImageOutputViewProps = {
|
export type ImageOutputViewProps = {
|
||||||
@@ -13,8 +11,6 @@ export type ImageOutputViewProps = {
|
|||||||
|
|
||||||
export function ImageOutputView({
|
export function ImageOutputView({
|
||||||
state,
|
state,
|
||||||
selected,
|
|
||||||
viewportFocused,
|
|
||||||
onViewportFocus,
|
onViewportFocus,
|
||||||
onViewportBlur,
|
onViewportBlur,
|
||||||
}: ImageOutputViewProps) {
|
}: ImageOutputViewProps) {
|
||||||
@@ -25,59 +21,10 @@ export function ImageOutputView({
|
|||||||
onFocus={onViewportFocus}
|
onFocus={onViewportFocus}
|
||||||
onBlur={onViewportBlur}
|
onBlur={onViewportBlur}
|
||||||
>
|
>
|
||||||
<TransformWrapper
|
<div
|
||||||
initialScale={1}
|
className="absolute inset-0 nodrag nopan overflow-auto"
|
||||||
initialPositionX={0}
|
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
|
||||||
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>
|
</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 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 { Copy } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
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 type { RenderingNodeState } from '../useRenderingNodeState'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -15,21 +13,19 @@ export type RawOutputViewProps = {
|
|||||||
theme: 'light' | 'dark'
|
theme: 'light' | 'dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RawOutputView({ state, height, containerRef, theme }: RawOutputViewProps) {
|
function languageForRaw(rawLanguage: string): HighlightLanguage {
|
||||||
const extensions = useMemo(() => {
|
if (rawLanguage === 'wireframe') return 'javascript'
|
||||||
const lang =
|
if (rawLanguage === 'plantuml') return 'plantuml'
|
||||||
state.rawLanguage === 'wireframe'
|
return 'markdown'
|
||||||
? javascript()
|
}
|
||||||
: state.rawLanguage === 'plantuml'
|
|
||||||
? plantumlLanguage.extension
|
export function RawOutputView({ state, height, containerRef }: RawOutputViewProps) {
|
||||||
: markdown()
|
const language = useMemo(() => languageForRaw(state.rawLanguage), [state.rawLanguage])
|
||||||
return [lang]
|
|
||||||
}, [state.rawLanguage])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -50,15 +46,16 @@ export function RawOutputView({ state, height, containerRef, theme }: RawOutputV
|
|||||||
>
|
>
|
||||||
<Copy className="size-3.5" />
|
<Copy className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<CodeMirror
|
<CodeEditor
|
||||||
value={state.rawDisplayContent}
|
value={state.rawDisplayContent}
|
||||||
height={`${height}px`}
|
onValueChange={() => {}}
|
||||||
theme={theme}
|
language={language}
|
||||||
extensions={extensions}
|
|
||||||
readOnly
|
readOnly
|
||||||
editable={false}
|
minHeight={height}
|
||||||
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
style={{ fontSize: 12 }}
|
||||||
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
|
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>
|
</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).
|
* 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').
|
* 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 { ImageOutputView } from './ImageOutputView'
|
||||||
export { MarkdownOutputView } from './MarkdownOutputView'
|
export { MarkdownJsxView } from './MarkdownJsxView'
|
||||||
export { RawOutputView } from './RawOutputView'
|
export { RawOutputView } from './RawOutputView'
|
||||||
|
export { StaticMarkdownHtmlView } from './StaticMarkdownHtmlView'
|
||||||
|
export { StreamingMarkdownView } from './StreamingMarkdownView'
|
||||||
|
|
||||||
export type { ImageOutputViewProps } from './ImageOutputView'
|
export type { ImageOutputViewProps } from './ImageOutputView'
|
||||||
export type { MarkdownOutputViewProps } from './MarkdownOutputView'
|
export type { MarkdownJsxViewProps } from './MarkdownJsxView'
|
||||||
export type { RawOutputViewProps } from './RawOutputView'
|
export type { RawOutputViewProps } from './RawOutputView'
|
||||||
|
export type { StaticMarkdownHtmlViewProps } from './StaticMarkdownHtmlView'
|
||||||
|
export type { StreamingMarkdownViewProps } from './StreamingMarkdownView'
|
||||||
|
|||||||
@@ -758,6 +758,7 @@ export {
|
|||||||
SidebarMenuAction,
|
SidebarMenuAction,
|
||||||
SidebarMenuBadge,
|
SidebarMenuBadge,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
|
sidebarMenuButtonVariants,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSkeleton,
|
SidebarMenuSkeleton,
|
||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
|
|||||||
@@ -1,32 +1,29 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
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).
|
* 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 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.
|
* @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(
|
export function useResizeHeight(
|
||||||
defaultHeight: number,
|
defaultHeight: number,
|
||||||
deps?: React.DependencyList
|
deps?: React.DependencyList
|
||||||
): [number, React.RefObject<HTMLDivElement | null>] {
|
): [number, React.RefObject<HTMLDivElement | null>] {
|
||||||
const ref = useRef<HTMLDivElement | null>(null)
|
const ref = useRef<HTMLDivElement | null>(null)
|
||||||
const [height, setHeight] = useState(defaultHeight)
|
const [height, setHeight] = useState(defaultHeight)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
const ro = new ResizeObserver((entries) => {
|
const unobserve = observeResize(el, (size) => {
|
||||||
const entry = entries[0]
|
if (size.height > 0) setHeight(size.height)
|
||||||
if (entry?.contentRect.height != null && entry.contentRect.height > 0) {
|
})
|
||||||
setHeight(entry.contentRect.height)
|
// Rely on ResizeObserver for initial size to avoid forced synchronous layout (getBoundingClientRect).
|
||||||
}
|
return unobserve
|
||||||
})
|
}, deps ?? [])
|
||||||
ro.observe(el)
|
|
||||||
setHeight(el.getBoundingClientRect().height)
|
|
||||||
return () => ro.disconnect()
|
|
||||||
}, 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
|
||||||
|
}
|
||||||
86
frontend/src/hooks/useStreamingContent.ts
Normal file
86
frontend/src/hooks/useStreamingContent.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Hook for parsing and streaming markdown content.
|
||||||
|
* Handles marked.js parsing with proper cleanup and cancellation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown content to HTML using marked.js.
|
||||||
|
* Returns parsed HTML string and handles cleanup on unmount.
|
||||||
|
*
|
||||||
|
* @param markdown - The markdown string to parse
|
||||||
|
* @returns HTML string from parsed markdown
|
||||||
|
*/
|
||||||
|
export function useStreamingContent(markdown: string | null): {
|
||||||
|
html: string
|
||||||
|
loading: boolean
|
||||||
|
} {
|
||||||
|
const [html, setHtml] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (markdown === null) {
|
||||||
|
setHtml('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
// Lazy load marked.js to avoid bundling if not needed
|
||||||
|
import('marked')
|
||||||
|
.then(async ({ marked }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed =
|
||||||
|
typeof marked.parse === 'function'
|
||||||
|
? await (marked.parse as (s: string) => Promise<string>)(markdown)
|
||||||
|
: (marked as (s: string) => string)(markdown)
|
||||||
|
const result = typeof parsed === 'string' ? parsed : String(parsed)
|
||||||
|
if (!cancelled) {
|
||||||
|
setHtml(result)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
// Fallback: use raw markdown if parsing fails
|
||||||
|
setHtml(markdown)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
// Fallback: use raw markdown if import fails
|
||||||
|
setHtml(markdown)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [markdown])
|
||||||
|
|
||||||
|
return { html, loading }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown content to HTML using marked.js (synchronous version).
|
||||||
|
* Use this when you need immediate parsing without React state.
|
||||||
|
*
|
||||||
|
* @param markdown - The markdown string to parse
|
||||||
|
* @returns HTML string from parsed markdown, or raw markdown on error
|
||||||
|
*/
|
||||||
|
export function parseMarkdownToHtml(markdown: string): string {
|
||||||
|
try {
|
||||||
|
// Use dynamic import for marked.js
|
||||||
|
// Note: This is a helper for non-React contexts
|
||||||
|
// In React components, use useStreamingContent hook instead
|
||||||
|
return markdown // Placeholder - actual implementation requires async import
|
||||||
|
} catch {
|
||||||
|
return markdown
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/src/hooks/useThinkSections.ts
Normal file
30
frontend/src/hooks/useThinkSections.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Hook for parsing markdown content with think sections.
|
||||||
|
* Extracts <think>...</think> blocks and returns main content and reasoning separately.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { parseThinkSections } from '@/lib/graph/renderingUtils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown content and extract think sections.
|
||||||
|
* Returns main content (without think blocks) and reasoning content (from think blocks).
|
||||||
|
*
|
||||||
|
* @param content - The HTML/markdown content to parse
|
||||||
|
* @param outputType - The output type ('image' or 'string')
|
||||||
|
* @returns Object with main and think content
|
||||||
|
*/
|
||||||
|
export function useThinkSections(
|
||||||
|
content: string | null,
|
||||||
|
outputType: 'image' | 'string'
|
||||||
|
): {
|
||||||
|
main: string
|
||||||
|
think: string
|
||||||
|
} {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!content || outputType === 'image') {
|
||||||
|
return { main: '', think: '' }
|
||||||
|
}
|
||||||
|
return parseThinkSections(content)
|
||||||
|
}, [content, outputType])
|
||||||
|
}
|
||||||
169
frontend/src/lib/errorBoundary.tsx
Normal file
169
frontend/src/lib/errorBoundary.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Error boundary utilities for React components.
|
||||||
|
* Provides consistent error handling across the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error state for error boundary components
|
||||||
|
*/
|
||||||
|
export type ErrorState = {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
errorInfo: React.ErrorInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial error state
|
||||||
|
*/
|
||||||
|
export const initialErrorState: ErrorState = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary props
|
||||||
|
*/
|
||||||
|
export type ErrorBoundaryProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
fallback?: React.ReactNode
|
||||||
|
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary state
|
||||||
|
*/
|
||||||
|
export type ErrorBoundaryState = ErrorState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary component
|
||||||
|
* Catches JavaScript errors anywhere in the child component tree
|
||||||
|
* and displays a fallback UI instead of crashing
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props)
|
||||||
|
this.state = initialErrorState
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
errorInfo: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||||
|
this.setState({
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log error to console or error reporting service
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||||
|
|
||||||
|
// Call custom error handler if provided
|
||||||
|
if (this.props.onError) {
|
||||||
|
this.props.onError(error, errorInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = (): void => {
|
||||||
|
this.setState(initialErrorState)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
// You can render any custom fallback UI
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="error-boundary">
|
||||||
|
<h1>Something went wrong.</h1>
|
||||||
|
<details>
|
||||||
|
<summary>Error Details</summary>
|
||||||
|
<pre>{this.state.error?.toString()}</pre>
|
||||||
|
<pre>{this.state.errorInfo?.componentStack}</pre>
|
||||||
|
</details>
|
||||||
|
<button onClick={this.handleReset}>Try Again</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for error handling in functional components
|
||||||
|
*/
|
||||||
|
export function useErrorHandler<T extends Error = Error>(): {
|
||||||
|
error: T | null
|
||||||
|
setError: (error: T | null) => void
|
||||||
|
hasError: boolean
|
||||||
|
resetError: () => void
|
||||||
|
} {
|
||||||
|
const [error, setError] = React.useState<T | null>(null)
|
||||||
|
|
||||||
|
const hasError = error !== null
|
||||||
|
const resetError = React.useCallback(() => setError(null), [])
|
||||||
|
|
||||||
|
return { error, setError, hasError, resetError }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to update error state consistently
|
||||||
|
*/
|
||||||
|
export function updateErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null | { kind: string; message: string }) => void,
|
||||||
|
error: unknown
|
||||||
|
): void {
|
||||||
|
setError({
|
||||||
|
kind: 'render',
|
||||||
|
message: (error as { message?: string })?.message ?? 'Render error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to clear error state
|
||||||
|
*/
|
||||||
|
export function clearErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null) => void
|
||||||
|
): void {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to check if an error is recoverable
|
||||||
|
*/
|
||||||
|
export function isRecoverableError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) return false
|
||||||
|
// Common recoverable errors
|
||||||
|
const recoverableMessages = [
|
||||||
|
'Network error',
|
||||||
|
'Timeout',
|
||||||
|
'Connection refused',
|
||||||
|
'Rate limit exceeded',
|
||||||
|
]
|
||||||
|
return recoverableMessages.some((msg) => (error as Error).message.includes(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to format error message for display
|
||||||
|
*/
|
||||||
|
export function formatErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
return 'An unknown error occurred'
|
||||||
|
}
|
||||||
155
frontend/src/lib/errorHandling.ts
Normal file
155
frontend/src/lib/errorHandling.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Error handling utilities for consistent error management.
|
||||||
|
* Provides centralized error handling logic across the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error kind for categorizing errors
|
||||||
|
*/
|
||||||
|
export type ErrorKind =
|
||||||
|
| 'render'
|
||||||
|
| 'network'
|
||||||
|
| 'validation'
|
||||||
|
| 'authentication'
|
||||||
|
| 'authorization'
|
||||||
|
| 'notFound'
|
||||||
|
| 'server'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error object with kind and message
|
||||||
|
*/
|
||||||
|
export type AppError = {
|
||||||
|
kind: ErrorKind
|
||||||
|
message: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error object with consistent structure
|
||||||
|
*/
|
||||||
|
export function createAppError(
|
||||||
|
kind: ErrorKind,
|
||||||
|
message: string,
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
): AppError {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract error message from unknown error
|
||||||
|
*/
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||||
|
return String(error.message)
|
||||||
|
}
|
||||||
|
return 'An unknown error occurred'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract error kind from error
|
||||||
|
*/
|
||||||
|
export function getErrorKind(error: unknown): ErrorKind {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase()
|
||||||
|
if (message.includes('network')) return 'network'
|
||||||
|
if (message.includes('validation') || message.includes('invalid')) return 'validation'
|
||||||
|
if (message.includes('auth')) return 'authentication'
|
||||||
|
if (message.includes('forbidden') || message.includes('unauthorized')) return 'authorization'
|
||||||
|
if (message.includes('not found') || message.includes('404')) return 'notFound'
|
||||||
|
if (message.includes('server') || message.includes('500')) return 'server'
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format error for display
|
||||||
|
*/
|
||||||
|
export function formatError(error: AppError | Error | string): string {
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is recoverable
|
||||||
|
*/
|
||||||
|
export function isRecoverableError(error: AppError | Error | string): boolean {
|
||||||
|
if (typeof error === 'string') return false
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase()
|
||||||
|
return ['network', 'timeout', 'connection'].some((term) => message.includes(term))
|
||||||
|
}
|
||||||
|
// error is AppError at this point
|
||||||
|
return ['network', 'server'].includes((error as AppError).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update error state consistently
|
||||||
|
*/
|
||||||
|
export function updateErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null | { kind: string; message: string }) => void,
|
||||||
|
error: unknown
|
||||||
|
): void {
|
||||||
|
setError({
|
||||||
|
kind: 'render',
|
||||||
|
message: getErrorMessage(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear error state
|
||||||
|
*/
|
||||||
|
export function clearErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null) => void
|
||||||
|
): void {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle error with consistent logging
|
||||||
|
*/
|
||||||
|
export function handleError(
|
||||||
|
error: unknown,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): AppError {
|
||||||
|
const appError = createAppError(
|
||||||
|
getErrorKind(error),
|
||||||
|
getErrorMessage(error),
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
console.error('[Error]', appError, context)
|
||||||
|
return appError
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create error handler for async functions
|
||||||
|
*/
|
||||||
|
export function createErrorHandler<T>(
|
||||||
|
onError?: (error: AppError) => void
|
||||||
|
): (error: unknown) => T {
|
||||||
|
return (error: unknown) => {
|
||||||
|
const appError = handleError(error)
|
||||||
|
onError?.(appError)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
|
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
|
||||||
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
||||||
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData()
|
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData()
|
||||||
* also reports this node as a trigger for connection path (lifecycle "trigger").
|
* also marks this node as a connection-path trigger so edges update on data changes.
|
||||||
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
|
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
|
||||||
*
|
*
|
||||||
* **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should
|
* **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useContext, useMemo } from 'react'
|
import React, { useCallback, useContext, useMemo } from 'react'
|
||||||
import { GraphContext, ConnectionPathContext } from './flowContext'
|
import { GraphContext } from './flowContext'
|
||||||
|
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
import { nodePropsAreEqual } from './flowUtils'
|
import { nodePropsAreEqual } from './flowUtils'
|
||||||
import type { AppNode } from './nodeTypes'
|
import type { AppNode } from './nodeTypes'
|
||||||
|
|
||||||
@@ -59,6 +60,15 @@ export type AbstractNodeContext<TData = Record<string, unknown>> = {
|
|||||||
targetIds: string[]
|
targetIds: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns nodes that are connected to this node (in sourceIds) and have the given type. */
|
||||||
|
export function getConnectedNodesByType<T extends FlowNode = FlowNode>(
|
||||||
|
nodes: FlowNode[],
|
||||||
|
sourceIds: string[],
|
||||||
|
type: string
|
||||||
|
): T[] {
|
||||||
|
return nodes.filter((n) => sourceIds.includes(n.id) && n.type === type) as T[]
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Hook
|
// Hook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -72,13 +82,11 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
|||||||
data: TData
|
data: TData
|
||||||
): AbstractNodeContext<TData> {
|
): AbstractNodeContext<TData> {
|
||||||
const graphCtx = useContext(GraphContext)
|
const graphCtx = useContext(GraphContext)
|
||||||
const pathCtx = useContext(ConnectionPathContext)
|
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||||
const nodes = graphCtx?.nodes ?? []
|
|
||||||
const edges = graphCtx?.edges ?? []
|
const edges = graphCtx?.edges ?? []
|
||||||
const setNodes = graphCtx?.setNodes
|
const setNodes = graphCtx?.setNodes
|
||||||
const setEdges = graphCtx?.setEdges
|
const setEdges = graphCtx?.setEdges
|
||||||
|
|
||||||
const addConnectionPathTrigger = pathCtx?.addConnectionPathTrigger
|
|
||||||
const updateData = useCallback(
|
const updateData = useCallback(
|
||||||
(partial: Partial<TData>) => {
|
(partial: Partial<TData>) => {
|
||||||
if (!setNodes) return
|
if (!setNodes) return
|
||||||
@@ -87,9 +95,9 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
|||||||
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||||
) as AppNode[]
|
) as AppNode[]
|
||||||
)
|
)
|
||||||
addConnectionPathTrigger?.(id)
|
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||||
},
|
},
|
||||||
[id, setNodes, addConnectionPathTrigger]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|
||||||
const incomingEdges = useMemo(
|
const incomingEdges = useMemo(
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ export type ConfigType = {
|
|||||||
|
|
||||||
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
||||||
const KROKI_TIMEOUT_MS = 15000
|
const KROKI_TIMEOUT_MS = 15000
|
||||||
|
const KROKI_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
// In-flight deduplication: if the same PlantUML content is already being fetched,
|
||||||
|
// reuse the existing promise instead of firing a duplicate request.
|
||||||
|
const krokiInflight = new Map<string, Promise<string>>()
|
||||||
|
// TTL result cache: avoids re-fetching identical content within the TTL window.
|
||||||
|
const krokiCache = new Map<string, { result: string; cachedAt: number }>()
|
||||||
|
|
||||||
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||||
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
||||||
@@ -172,36 +179,56 @@ const BUILTIN_CONFIG_TYPES: ConfigType[] = [
|
|||||||
language: 'plantuml',
|
language: 'plantuml',
|
||||||
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
||||||
render: async (content: string) => {
|
render: async (content: string) => {
|
||||||
const controller = new AbortController()
|
// 1. TTL cache hit
|
||||||
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
|
const cached = krokiCache.get(content)
|
||||||
try {
|
if (cached && Date.now() - cached.cachedAt < KROKI_CACHE_TTL_MS) {
|
||||||
const res = await fetch(KROKI_PLANTUML_SVG, {
|
return cached.result
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
|
||||||
body: content,
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.text()
|
|
||||||
if (res.status >= 500) {
|
|
||||||
throw new Error('Diagram service unavailable. Try again later.')
|
|
||||||
}
|
|
||||||
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
|
||||||
}
|
|
||||||
return res.text()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
if (err instanceof Error) {
|
|
||||||
if (err.name === 'AbortError') {
|
|
||||||
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
|
|
||||||
}
|
|
||||||
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
|
|
||||||
throw new Error('Diagram service unavailable. Check your connection or try again later.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. In-flight deduplication: reuse an existing request for the same content
|
||||||
|
const inflight = krokiInflight.get(content)
|
||||||
|
if (inflight) return inflight
|
||||||
|
|
||||||
|
// 3. New request
|
||||||
|
const fetchPromise = (async () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
const res = await fetch(KROKI_PLANTUML_SVG, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
body: content,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text()
|
||||||
|
if (res.status >= 500) {
|
||||||
|
throw new Error('Diagram service unavailable. Try again later.')
|
||||||
|
}
|
||||||
|
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
||||||
|
}
|
||||||
|
const svg = await res.text()
|
||||||
|
krokiCache.set(content, { result: svg, cachedAt: Date.now() })
|
||||||
|
return svg
|
||||||
|
} catch (err: unknown) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
throw new Error('Diagram request timed out. The service may be slow or unavailable.')
|
||||||
|
}
|
||||||
|
if (err.message.includes('fetch') || err.message.includes('network') || err.message.includes('Failed to fetch')) {
|
||||||
|
throw new Error('Diagram service unavailable. Check your connection or try again later.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
krokiInflight.delete(content)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
krokiInflight.set(content, fetchPromise)
|
||||||
|
return fetchPromise
|
||||||
},
|
},
|
||||||
outputMenuDescriptor: {
|
outputMenuDescriptor: {
|
||||||
submenuLabel: 'Export',
|
submenuLabel: 'Export',
|
||||||
|
|||||||
@@ -1,36 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Connection status: visual state of an edge (color/class).
|
* Connection status: visual state of an edge (color/class).
|
||||||
* Priority when multiple apply: error > paused > updating > default.
|
* Priority: error > updating > default.
|
||||||
*
|
|
||||||
* State flow: nodes report lifecycle via useSyncConnectionStatus() (nodeLifecycle.ts) → FlowContext
|
|
||||||
* holds the sets → edges pass those sets into getConnectionStatus() here. See lib/graph/state.ts.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ConnectionStatus = 'default' | 'updating' | 'paused' | 'error'
|
export type ConnectionStatus = 'default' | 'updating' | 'error'
|
||||||
|
|
||||||
export type ConnectionStatusInputs = {
|
export type ConnectionStatusInputs = {
|
||||||
source: string
|
source: string
|
||||||
target: string
|
target: string
|
||||||
pathNodeIds: Set<string>
|
pathNodeIds: Set<string>
|
||||||
pausedSegmentNodeIds: Set<string>
|
|
||||||
activeSegmentNodeIds: Set<string>
|
activeSegmentNodeIds: Set<string>
|
||||||
errorTargetNodeIds: Set<string>
|
errorTargetNodeIds: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the single connection status for an edge (priority: error > paused > updating > default).
|
* Compute the single connection status for an edge (priority: error > updating > default).
|
||||||
* Use in edge components; add new statuses by extending the type and adding a branch here.
|
|
||||||
*/
|
*/
|
||||||
export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionStatus {
|
export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionStatus {
|
||||||
const { target, pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds, errorTargetNodeIds, source } = inputs
|
const { target, pathNodeIds, activeSegmentNodeIds, errorTargetNodeIds, source } = inputs
|
||||||
if (errorTargetNodeIds.has(target)) return 'error'
|
if (errorTargetNodeIds.has(target)) return 'error'
|
||||||
if (
|
|
||||||
pathNodeIds.has(source) &&
|
|
||||||
pathNodeIds.has(target) &&
|
|
||||||
pausedSegmentNodeIds.has(source) &&
|
|
||||||
pausedSegmentNodeIds.has(target)
|
|
||||||
)
|
|
||||||
return 'paused'
|
|
||||||
if (
|
if (
|
||||||
pathNodeIds.has(source) &&
|
pathNodeIds.has(source) &&
|
||||||
pathNodeIds.has(target) &&
|
pathNodeIds.has(target) &&
|
||||||
@@ -45,6 +33,5 @@ export function getConnectionStatus(inputs: ConnectionStatusInputs): ConnectionS
|
|||||||
export const CONNECTION_STATUS_CLASS: Record<ConnectionStatus, string> = {
|
export const CONNECTION_STATUS_CLASS: Record<ConnectionStatus, string> = {
|
||||||
default: '',
|
default: '',
|
||||||
updating: 'animated-edge-path--updating',
|
updating: 'animated-edge-path--updating',
|
||||||
paused: 'animated-edge-path--paused',
|
|
||||||
error: 'animated-edge-path--error',
|
error: 'animated-edge-path--error',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import type { AppNode, AppEdge } from './nodeTypes'
|
|||||||
|
|
||||||
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
||||||
|
|
||||||
/** Role of a node in the current connection path update. */
|
/** Role of a node in the current connection path. */
|
||||||
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path'
|
export type ConnectionPathRole = 'trigger' | 'on-path'
|
||||||
|
|
||||||
export type FlowActions = {
|
export type FlowActions = {
|
||||||
pasteAtViewportCenter: () => void
|
pasteAtViewportCenter: () => void
|
||||||
@@ -25,14 +25,20 @@ export type FlowActions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Graph context (nodes, edges, setters)
|
// Graph context (setters + graphRef for reads; edges in context so edge changes trigger re-renders)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type GraphContextRef = {
|
||||||
|
current: { nodes: AppNode[]; edges: AppEdge[] }
|
||||||
|
}
|
||||||
|
|
||||||
export type GraphContextValue = {
|
export type GraphContextValue = {
|
||||||
nodes: AppNode[]
|
|
||||||
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
|
||||||
edges: AppEdge[]
|
|
||||||
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
|
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
|
||||||
|
/** Current nodes/edges; updated every render. Read from here to avoid re-rendering on position-only changes. */
|
||||||
|
graphRef: GraphContextRef
|
||||||
|
/** Edges in context so consumers (e.g. edge indicators, useAbstractNode) re-render when edges change. */
|
||||||
|
edges: AppEdge[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const GraphContext = React.createContext<GraphContextValue | null>(null)
|
const GraphContext = React.createContext<GraphContextValue | null>(null)
|
||||||
@@ -102,11 +108,9 @@ export function useConnectionPathRole(nodeId: string | undefined): ConnectionPat
|
|||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!nodeId) return null
|
if (!nodeId) return null
|
||||||
const triggers = ctx?.connectionPathTriggerNodeIds
|
const triggers = ctx?.connectionPathTriggerNodeIds
|
||||||
const updating = ctx?.connectionPathUpdatingNodeIds
|
|
||||||
const path = ctx?.connectionPathNodeIds
|
const path = ctx?.connectionPathNodeIds
|
||||||
if (!path?.has(nodeId)) return null
|
if (!path?.has(nodeId)) return null
|
||||||
if (triggers?.includes(nodeId)) return 'trigger'
|
if (triggers?.includes(nodeId)) return 'trigger'
|
||||||
if (updating?.includes(nodeId)) return 'updating'
|
|
||||||
return 'on-path'
|
return 'on-path'
|
||||||
}, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathUpdatingNodeIds, ctx?.connectionPathNodeIds])
|
}, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathNodeIds])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Graph path utilities: compute which nodes/edges are "on the path" of an update.
|
* @deprecated Use graphologyPath.ts instead. Path derivation now uses graphology
|
||||||
* Used to show connection ant trail only along the full chain (upstream → updating → downstream).
|
* (DirectedGraph + BFS) for traversal. This file re-exports from graphologyPath
|
||||||
* Works with any node types; any node can signal it is updating via startConnectionPathUpdate(id).
|
* for backward compatibility and will be removed in a future version.
|
||||||
|
*
|
||||||
|
* @see graphologyPath.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type GraphEdge = { source: string; target: string }
|
export type { GraphEdge } from './graphologyPath'
|
||||||
|
export {
|
||||||
/** Nodes reachable from seedIds by following edges forward (source → target). */
|
getPathNodeIds,
|
||||||
export function getDownstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
|
getPathToUpdatingSegmentNodeIds,
|
||||||
const out = new Set<string>(seedIds)
|
clearGraphologyPathCache,
|
||||||
let added = true
|
} from './graphologyPath'
|
||||||
while (added) {
|
|
||||||
added = false
|
|
||||||
for (const e of edges) {
|
|
||||||
if (out.has(e.source) && !out.has(e.target)) {
|
|
||||||
out.add(e.target)
|
|
||||||
added = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nodes that can reach any seed by following edges backward (target → source). */
|
|
||||||
export function getUpstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
|
|
||||||
const out = new Set<string>(seedIds)
|
|
||||||
let added = true
|
|
||||||
while (added) {
|
|
||||||
added = false
|
|
||||||
for (const e of edges) {
|
|
||||||
if (out.has(e.target) && !out.has(e.source)) {
|
|
||||||
out.add(e.source)
|
|
||||||
added = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All node ids that lie on the path of an update.
|
|
||||||
* - If updatingNodeIds non-empty: path = downstream(trigger) ∩ (upstream(updating) ∪ downstream(updating))
|
|
||||||
* so we include config→agent→rendering when agent is running.
|
|
||||||
* - Else if triggerNodeIds and pausedNodeIds non-empty: path = downstream(trigger) ∩ upstream(paused)
|
|
||||||
* so we show yellow (config→agent) when config changed and agent is on hold.
|
|
||||||
* An edge should show color iff both its source and target are in this set.
|
|
||||||
*/
|
|
||||||
export function getPathNodeIds(
|
|
||||||
edges: GraphEdge[],
|
|
||||||
updatingNodeIds: string[],
|
|
||||||
triggerNodeIds?: string[],
|
|
||||||
pausedNodeIds?: string[]
|
|
||||||
): Set<string> {
|
|
||||||
const hasUpdating = updatingNodeIds.length > 0
|
|
||||||
const hasPausedPath =
|
|
||||||
pausedNodeIds != null &&
|
|
||||||
pausedNodeIds.length > 0 &&
|
|
||||||
triggerNodeIds != null &&
|
|
||||||
triggerNodeIds.length > 0
|
|
||||||
|
|
||||||
if (hasUpdating && triggerNodeIds != null && triggerNodeIds.length > 0) {
|
|
||||||
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds)
|
|
||||||
const upstreamOfUpdating = getUpstreamNodeIds(edges, updatingNodeIds)
|
|
||||||
const downstreamOfUpdating = getDownstreamNodeIds(edges, updatingNodeIds)
|
|
||||||
const path = new Set<string>()
|
|
||||||
downstreamOfTrigger.forEach((id) => {
|
|
||||||
if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id)
|
|
||||||
})
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasPausedPath && !hasUpdating) {
|
|
||||||
const upstream = getUpstreamNodeIds(edges, pausedNodeIds!)
|
|
||||||
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds!)
|
|
||||||
const path = new Set<string>()
|
|
||||||
upstream.forEach((id) => {
|
|
||||||
if (downstreamOfTrigger.has(id)) path.add(id)
|
|
||||||
})
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasUpdating) {
|
|
||||||
const upstream = getUpstreamNodeIds(edges, updatingNodeIds)
|
|
||||||
const downstream = getDownstreamNodeIds(edges, updatingNodeIds)
|
|
||||||
const path = new Set<string>(upstream)
|
|
||||||
downstream.forEach((id) => path.add(id))
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Set()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path nodes from triggers up to and including the first paused node (e.g. Renderer waiting for Run).
|
|
||||||
* Used to color those edges yellow; rest of path stays blue.
|
|
||||||
*/
|
|
||||||
export function getPausedSegmentNodeIds(
|
|
||||||
edges: GraphEdge[],
|
|
||||||
pathNodeIds: Set<string>,
|
|
||||||
triggerNodeIds: string[],
|
|
||||||
pausedNodeIds: string[]
|
|
||||||
): Set<string> {
|
|
||||||
if (pausedNodeIds.length === 0 || triggerNodeIds.length === 0) return new Set()
|
|
||||||
const pausedSet = new Set(pausedNodeIds)
|
|
||||||
const seeds = triggerNodeIds.filter((id) => pathNodeIds.has(id))
|
|
||||||
if (seeds.length === 0) return new Set()
|
|
||||||
const out = new Set<string>(seeds)
|
|
||||||
const frontier: string[] = [...seeds]
|
|
||||||
const visited = new Set<string>(seeds)
|
|
||||||
while (frontier.length > 0) {
|
|
||||||
const n = frontier.shift()!
|
|
||||||
if (pausedSet.has(n)) continue
|
|
||||||
for (const e of edges) {
|
|
||||||
if (e.source !== n || !pathNodeIds.has(e.target) || visited.has(e.target)) continue
|
|
||||||
visited.add(e.target)
|
|
||||||
out.add(e.target)
|
|
||||||
if (pausedSet.has(e.target)) continue
|
|
||||||
frontier.push(e.target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
201
frontend/src/lib/graph/graphologyPath.ts
Normal file
201
frontend/src/lib/graph/graphologyPath.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Path derivation for connection status using graphology.
|
||||||
|
* Builds a DirectedGraph from canvas edges (with optional node attributes),
|
||||||
|
* uses BFS (outbound/inbound) to compute path sets for propagation animation.
|
||||||
|
*
|
||||||
|
* @see https://graphology.github.io/
|
||||||
|
* @see https://graphology.github.io/standard-library/traversal.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
import DirectedGraph from 'graphology'
|
||||||
|
import { bfsFromNode } from 'graphology-traversal'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type GraphEdge = { source: string; target: string; id?: string }
|
||||||
|
|
||||||
|
/** Optional attributes stored on graphology nodes (e.g. for sink detection). */
|
||||||
|
export type GraphologyNodeAttributes = {
|
||||||
|
nodeType?: string
|
||||||
|
updateMode?: 'auto' | 'manual'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional: pass when building so graph has node type/updateMode for future use. */
|
||||||
|
export type NodeAttributesMap = Record<string, GraphologyNodeAttributes>
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Graph build (with cache)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let cachedEdgesRef: GraphEdge[] | null = null
|
||||||
|
let cachedGraph: DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }> | null =
|
||||||
|
null
|
||||||
|
|
||||||
|
function buildGraph(
|
||||||
|
edges: GraphEdge[],
|
||||||
|
nodeAttributes?: NodeAttributesMap
|
||||||
|
): DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }> {
|
||||||
|
if (edges === cachedEdgesRef && cachedGraph !== null) return cachedGraph
|
||||||
|
|
||||||
|
const graph = new DirectedGraph<GraphologyNodeAttributes, { source: string; target: string; edgeId?: string }>()
|
||||||
|
const nodeIds = new Set<string>()
|
||||||
|
for (const e of edges) {
|
||||||
|
nodeIds.add(e.source)
|
||||||
|
nodeIds.add(e.target)
|
||||||
|
}
|
||||||
|
for (const id of nodeIds) {
|
||||||
|
const attrs: GraphologyNodeAttributes = { ...nodeAttributes?.[id] }
|
||||||
|
graph.mergeNode(id, attrs)
|
||||||
|
}
|
||||||
|
for (const e of edges) {
|
||||||
|
const key = e.id ?? `${e.source}->${e.target}`
|
||||||
|
if (!graph.hasEdge(e.source, e.target)) {
|
||||||
|
graph.addEdgeWithKey(key, e.source, e.target, {
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
edgeId: e.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedEdgesRef = edges
|
||||||
|
cachedGraph = graph
|
||||||
|
return graph
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call when graph structure changes from outside (e.g. store reset) to clear cache. */
|
||||||
|
export function clearGraphologyPathCache(): void {
|
||||||
|
cachedEdgesRef = null
|
||||||
|
cachedGraph = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Traversal helpers (BFS via graphology-traversal)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Outbound BFS: collect all node ids reachable from seeds following edges forward (source → target). */
|
||||||
|
function getDownstreamNodeIds(
|
||||||
|
graph: DirectedGraph,
|
||||||
|
seedIds: string[]
|
||||||
|
): Set<string> {
|
||||||
|
const out = new Set<string>()
|
||||||
|
for (const id of seedIds) {
|
||||||
|
if (!graph.hasNode(id)) continue
|
||||||
|
bfsFromNode(
|
||||||
|
graph,
|
||||||
|
id,
|
||||||
|
(node) => {
|
||||||
|
out.add(node)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
{ mode: 'outbound' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inbound BFS: collect all node ids that can reach any seed (following edges backward). */
|
||||||
|
function getUpstreamNodeIds(
|
||||||
|
graph: DirectedGraph,
|
||||||
|
seedIds: string[]
|
||||||
|
): Set<string> {
|
||||||
|
const out = new Set<string>()
|
||||||
|
for (const id of seedIds) {
|
||||||
|
if (!graph.hasNode(id)) continue
|
||||||
|
bfsFromNode(
|
||||||
|
graph,
|
||||||
|
id,
|
||||||
|
(node) => {
|
||||||
|
out.add(node)
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
{ mode: 'inbound' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path-to-updating / path-to-paused (same semantics as graphPath.ts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getPathToUpdatingNodeIds(
|
||||||
|
graph: DirectedGraph,
|
||||||
|
triggerNodeIds: string[],
|
||||||
|
updatingNodeIds: string[]
|
||||||
|
): Set<string> {
|
||||||
|
const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds)
|
||||||
|
const upstreamOfUpdating = getUpstreamNodeIds(graph, updatingNodeIds)
|
||||||
|
const downstreamOfUpdating = getDownstreamNodeIds(graph, updatingNodeIds)
|
||||||
|
const path = new Set<string>()
|
||||||
|
downstreamOfTrigger.forEach((id) => {
|
||||||
|
if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id)
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPathToPausedNodeIds(
|
||||||
|
graph: DirectedGraph,
|
||||||
|
triggerNodeIds: string[],
|
||||||
|
pausedNodeIds: string[]
|
||||||
|
): Set<string> {
|
||||||
|
const upstreamOfPaused = getUpstreamNodeIds(graph, pausedNodeIds)
|
||||||
|
const downstreamOfTrigger = getDownstreamNodeIds(graph, triggerNodeIds)
|
||||||
|
const path = new Set<string>()
|
||||||
|
upstreamOfPaused.forEach((id) => {
|
||||||
|
if (downstreamOfTrigger.has(id)) path.add(id)
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API (same signatures as graphPath.ts for drop-in replacement)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All node ids on the path of an update (downstream of trigger, or path to updating nodes).
|
||||||
|
*/
|
||||||
|
export function getPathNodeIds(
|
||||||
|
edges: GraphEdge[],
|
||||||
|
updatingNodeIds: string[],
|
||||||
|
triggerNodeIds?: string[],
|
||||||
|
_pausedNodeIds?: string[],
|
||||||
|
nodeAttributes?: NodeAttributesMap
|
||||||
|
): Set<string> {
|
||||||
|
const hasUpdating = updatingNodeIds.length > 0
|
||||||
|
const hasTrigger = triggerNodeIds != null && triggerNodeIds.length > 0
|
||||||
|
const graph = buildGraph(edges, nodeAttributes)
|
||||||
|
|
||||||
|
if (hasTrigger && hasUpdating) {
|
||||||
|
return getPathToUpdatingNodeIds(graph, triggerNodeIds!, updatingNodeIds)
|
||||||
|
}
|
||||||
|
if (hasUpdating) {
|
||||||
|
const upstream = getUpstreamNodeIds(graph, updatingNodeIds)
|
||||||
|
const downstream = getDownstreamNodeIds(graph, updatingNodeIds)
|
||||||
|
const path = new Set<string>(upstream)
|
||||||
|
downstream.forEach((id) => path.add(id))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTrigger) {
|
||||||
|
return getDownstreamNodeIds(graph, triggerNodeIds!)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path nodes that show "updating" during the time-bound pulse.
|
||||||
|
* When pulseActive is true, returns downstream(trigger); otherwise empty.
|
||||||
|
*/
|
||||||
|
export function getPathToUpdatingSegmentNodeIds(
|
||||||
|
edges: GraphEdge[],
|
||||||
|
triggerNodeIds: string[],
|
||||||
|
pulseActive: boolean,
|
||||||
|
nodeAttributes?: NodeAttributesMap
|
||||||
|
): Set<string> {
|
||||||
|
if (!pulseActive || triggerNodeIds.length === 0) return new Set()
|
||||||
|
const graph = buildGraph(edges, nodeAttributes)
|
||||||
|
return getDownstreamNodeIds(graph, triggerNodeIds)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user