fix: improvements

This commit is contained in:
2026-03-28 22:20:19 +01:00
parent cbd8f1568b
commit 223336c606
10 changed files with 506 additions and 249 deletions

View File

@@ -2,104 +2,215 @@
## 1. Executive Summary ## 1. Executive Summary
This document provides a high-level overview of the ZUI application's architecture, focusing on component organization, data flow, technology stack, and contribution guidelines for junior developers and AI agents. 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 ## 2. Technology Stack
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS, Shadcn UI, Zustand (canvasStore), custom hooks, and canvas rendering engine. | Layer | Technology |
- **Backend**: Node.js 18, TypeScript, Express-like routing, Docker, and environment variable configuration. | --- | --- |
- **Database/Storage**: In-memory state management with optional persistence via cacheRepository. | Frontend framework | React 18, TypeScript (strict), Vite 7 |
- **Testing**: Vitest, React Testing Library, and Jest-like utilities. | 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. High-Level Structure ---
The workspace is divided into two primary directories: ## 3. Project Structure
- **frontend/**: Contains the React application, component library, pages, and styling. ```text
- **backend/**: Contains server-side code, services, repositories, models, and middleware. 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
### 3.1 Frontend Modules 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
```
| Module | Purpose | Key Files | ---
|--------|---------|-----------|
| **canvas** | Core graph/canvas rendering, nodes, edges, and interactive graph features. | `src/app/canvas/*`, `src/components/graph/*`, `src/components/nodes/*` |
| **recollections** | Collection management, browsing, editing, and related UI. | `src/app/recollections/*`, `src/components/ui/*` |
| **kosmos** | Knowledge organization system, settings, and user preferences. | `src/app/kosmos/*` |
| **layout** | Layout and navigation components shared across pages. | `src/app/recollections/layout/*` |
| **components** | Reusable UI components (buttons, dialogs, avatars, etc.). | `src/components/ui/*` |
| **hooks** | Custom React hooks for state, effects, and utilities. | `src/hooks/*` |
| **lib** | Shared utilities, types, and low-level helpers. | `src/lib/*` |
### 3.2 Backend Modules ## 4. Key Architectural Patterns
| Module | Purpose | Key Files | ### 4.1 Command / Reducer Store (canvasStore)
|--------|---------|-----------|
| **services** | Business logic, external API interactions, and complex computations. | `backend/src/services/*` |
| **repositories** | Data access abstraction, caching, and persistence. | `backend/src/repositories/*` |
| **models** | TypeScript interfaces and type definitions for domain objects. | `backend/src/models/*` |
| **routes** | HTTP route definitions and controllers. | `backend/src/routes/*` |
| **middleware** | Request processing pipeline (e.g., rate limiting, authentication). | `backend/src/middleware/*` |
| **index.ts** | Application entry point that composes services, routes, and middleware. | `backend/src/index.ts` |
## 4. Data Flow 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.
1. **User Interaction** (React components) → dispatches actions → updates local state (Zustand) → triggers re-render. The store has three slices:
2. **State Changes** may trigger async calls to **services** (frontend) → which call **backend APIs** → responses are cached via **cacheRepository**.
3. **Backend** processes requests via **routes**, applies **middleware**, interacts with **repositories** and **models**, and returns JSON responses.
4. **Caching** layer reduces repeated expensive computations or DB queries, improving performance.
## 5. Component Interaction Patterns - **`graph`** — nodes and edges (the React Flow state)
- **`path`** — trigger pulse for edge animation
- **`ui`** — renaming state, fullscreen node, pending connection
- **Container Components** (e.g., `CanvasPage`, `RecollectionPage`) manage layout and state provision. ### 4.2 Delta-based Undo / Redo
- **Presentational Components** (e.g., `TreeBrowser`, `AgentNode`) focus on UI rendering only.
- **Custom Hooks** encapsulate logic (e.g., `useResizeHeight`, `useStreamingContent`) and are reused across components.
- **Context Providers** (`KosmosContext`, `RecollectionSidebarContext`) supply global state to deeply nested component trees.
## 6. Extensibility & Plugin Architecture `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`.
- **Node Types** are registered via `nodeRegistry.ts` and extended through descriptor files. ### 4.3 Node Plugin Registry
- **New node types** can be added by creating a descriptor, implementing rendering logic, and registering it in `registerBuiltinNodes.tsx`.
- **Extensibility points** are documented in `NODE_TYPE_EXTENSIBILITY_PROPOSAL.md`.
## 7. Performance Considerations 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:
- **Large Render Trees**: `CanvasPage` and `RecollectionPage` handle thousands of nodes; memoization (`useMemo`, `useCallback`) and lazy loading are essential. 1. Implement a React component for the node.
- **State Updates**: Prefer granular state updates in `canvasStore` to avoid full tree re-renders. 2. Create a descriptor using `NodeTypeBuilder`.
- **Code Splitting**: Dynamic imports are used for heavy modules (e.g., rendering views, graph utilities). 3. Call `registerNodeType(descriptor)` — no edits to core code.
## 8. Contribution Workflow ### 4.4 Fluent Builder for Node Descriptors
1. Fork the repository and clone the workspace. `NodeTypeBuilder` provides a type-safe method-chaining API (`idPrefix`, `withInputOutput`, `classification`, `allowedSourceTypes`, `help`, `menu`, `withFullscreen`, `sourceRenderingLogic`, …). Required fields are validated at `.build()`.
2. Install dependencies: `pnpm install` (or `npm ci`).
3. Run the dev server: `pnpm dev` (frontend) and `pnpm start` (backend). ### 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`. 4. Create a feature branch: `git checkout -b feat/your-feature`.
5. Follow the **Coding Standards** (see `CONTRIBUTING.md` for details). 5. Follow the Coding Standards (see [CONTRIBUTING.md](CONTRIBUTING.md)).
6. Submit a Pull Request with: 6. Submit a Pull Request with a descriptive title, linked issue(s), updated tests, and documentation changes if relevant.
- Descriptive title.
- Linked issue(s).
- Updated tests (if applicable).
- Documentation updates (if relevant).
7. Code Review Checklist:
- [ ] Readability: clear naming, minimal nesting.
- [ ] Maintainability: extracted utilities, JSDoc, TypeScript typings.
- [ ] Performance: no unnecessary re-renders, proper memoization.
- [ ] Accessibility: semantic HTML, ARIA attributes.
- [ ] Security: no hardcoded secrets, proper input validation.
## 9. Coding Standards ### PR Review Checklist
- **TypeScript**: Strict mode (`strict`, `noImplicitAny`, `noUnusedVars`). - [ ] 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. - **Naming**: PascalCase for components, camelCase for functions/variables, UPPER_SNAKE_CASE for constants.
- **File Structure**: Featurefirst (`features/<feature>/`) or domaingrouped (`src/app/recollections/...`). - **File structure**: domain-grouped under `src/app/<domain>/` and `src/components/<category>/`.
- **Documentation**: JSDoc for all public APIs, TSDoc for types, and inline comments for complex logic. - **Formatting**: Prettier via `frontend/prettier.config.cjs`; line length ≤ 120.
- **Formatting**: Prettier configured via `frontend/prettier.config.cjs`. - **Comments**: JSDoc for public APIs; inline only where logic is non-obvious.
## 10. Quick Reference for AI Agents ---
- **Key Entry Points**: `frontend/src/main.tsx`, `backend/src/index.ts`. ## 9. Quick Reference for AI Agents
- **State Management**: `src/lib/graph/state.ts`, `src/app/canvas/canvasStore.*`.
- **Routing**: `backend/src/routes/agentRoutes.ts`.
- **Caching**: `backend/src/repositories/cacheRepository.ts`.
- **Performance Hotspots**: `CanvasPage.tsx`, `RecollectionPage.tsx`, large utility functions in `src/lib/*`.
*This overview is intended to serve as a living document; future sections will expand on each module, performance audit findings, and junior developer quickstart guides.* | 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.*

View File

@@ -2,49 +2,60 @@
## 1. Coding Standards ## 1. Coding Standards
- Use TypeScript with `strict` mode enabled. - Use TypeScript with `strict` mode enabled; no `any` types.
- Follow the import order: core, library, project, then relative. - Import order: core libs → third-party → project aliases (`@/`) → relative.
- Naming: PascalCase for components, camelCase for functions/variables, UPPER_SNAKE_CASE for constants. - Naming: PascalCase for components, camelCase for functions/variables, UPPER_SNAKE_CASE for constants.
- Add JSDoc comments for all public APIs. - Add JSDoc comments for all public APIs and exported types.
- Keep line length <= 120 characters, use Prettier for formatting. - Line length 120 characters; use Prettier for formatting (`frontend/prettier.config.cjs`).
- Lint with `pnpm lint` and fix warnings. - Run `npm run lint` and fix all warnings before opening a PR.
## 2. Branch Workflow ## 2. Branch Workflow
- Create a feature branch: `git checkout -b feat/<short-description>` - Create a feature branch: `git checkout -b feat/<short-description>`
- Keep branches up to date with `main` via `git rebase` or `git merge --ff-only`. - Keep branches up to date with `main` via `git rebase`.
- Submit a Pull Request with a clear title and description. - Submit a Pull Request with a clear title and description.
- Ensure PR passes CI (tests, lint, type-check). - Ensure the PR passes CI (tests, lint, type-check).
- Address review comments promptly. - Address review comments promptly.
## 3. Running Tests ## 3. Running Tests
- Install dependencies: `pnpm install`. ```bash
- Run tests in watch mode: `pnpm test --watch`. # Install dependencies
- Run tests with coverage: `pnpm test --coverage`. cd frontend && npm install
- Run lint: `pnpm lint`.
- Run type-check: `pnpm typecheck`. # 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 ## 4. Making Your First Contribution
1. Pick a beginner-friendly issue labeled `good first issue`. 1. Pick a beginner-friendly issue labeled `good first issue`.
2. Follow the steps in the Quickstart guide. 2. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the relevant module.
3. Make a small change (e.g., fix typo, add JSDoc). 3. Make a small, focused change; avoid refactoring unrelated code.
4. Run the relevant tests. 4. Run the relevant tests and confirm they pass.
5. Commit and push, then open a PR. 5. Commit and push, then open a PR with a clear description of what and why.
## 5. Where to Extend (AI Agent Guidance) ## 5. Where to Extend
- **Canvas rendering**: Extend `src/app/canvas/CanvasPage.tsx` by adding new node types or customizing the context menu. | Goal | Where to look |
- **Recollection management**: Modify `src/app/recollections/RecollectionsPage.tsx` to add new views or actions. | --- | --- |
- **State management**: Update `src/app/canvas/canvasStore.ts` for new graph features. | Add a new node type | `src/lib/graph/nodeTypeBuilder.ts` + `registerBuiltinNodes.tsx` |
- **Performance**: Optimize expensive renders in `src/app/recollections/RecollectionsPage.tsx` using memoization. | 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 ## 6. Useful Links
- [ARCHITECTURE.md](ARCHITECTURE.md) High-level architecture overview. - [ARCHITECTURE.md](ARCHITECTURE.md) architecture overview, key patterns, module map
- [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) Performance improvement plan. - [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) — bottleneck analysis and improvement plan
- [QUICKSTART_FOR_JUNIORS.md](QUICKSTART_FOR_JUNIORS.md) Junior developer quickstart. - [docs/CODE_REVIEW_CHECKLIST.md](docs/CODE_REVIEW_CHECKLIST.md) — PR review checklist
- [CODE_REVIEW_CHECKLIST.md](CODE_REVIEW_CHECKLIST.md) Checklist for reviewers. - [docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md](docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md) — node plugin system design
*Thank you for contributing!* *Thank you for contributing!*

View File

@@ -1,70 +1,108 @@
# Performance Improvements Plan # Performance Improvements Plan
## 1. Executive Summary ## 1. Current State
This document outlines identified performance bottlenecks in the ZUI application and proposes concrete actions to improve render efficiency, state management, and code splitting. The plan is targeted at enabling junior developers to contribute measurable performance gains while maintaining code quality. The core rendering and state management architecture is already well-structured for performance:
## 2. Current Performance Issues - **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.
| Issue | Location | Impact | Root Cause | The sections below identify remaining bottlenecks and concrete next steps.
|-------|----------|--------|------------|
| **Large Component Renders** | `frontend/src/app/canvas/CanvasPage.tsx`, `frontend/src/app/recollections/RecollectionPage.tsx` | High CPU usage, frame drops | These components render thousands of nodes without memoization; state updates trigger full re-renders. | ---
| **Unmemoized Expensive Calculations** | Various utility functions in `src/lib/*` (e.g., graph layout calculations) | Delayed response to user interactions | Calculations recomputed on every render cycle. |
| **Frequent State Updates** | `canvasStore` mutations in response to mouse/touch events | Unnecessary re-renders of unrelated nodes | State updates not granular; multiple mutations in quick succession. | ## 2. Known Bottlenecks
| **Lack of Code Splitting** | Heavy modules imported globally (e.g., rendering views, graph utilities) | Initial bundle size > 2MB, slow load | All modules loaded upfront even if not used. |
| **Inefficient List Rendering** | Lists of nodes in sidebar components | Scrolling lag | No virtualization; all items rendered simultaneously. | | # | Issue | Location | Impact | Root Cause |
| **Repeated API Calls** | Agent service calls without proper caching | Latency spikes | Calls bypass `cacheRepository` in some paths. | | --- | --- | --- | --- | --- |
| 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. Recommended Improvements
### 3.1 Component Refactoring ### 3.1 Granular Store Subscriptions
- **Split `CanvasPage` and `RecollectionPage`** into smaller, featurespecific subcomponents. Zustand supports slice-level subscriptions. Node components should select only their own data slice:
- **Extract pure logic** (e.g., node layout calculations) into standalone utility functions with `useMemo`.
- **Apply `React.memo`** to pure presentational components that receive static props.
### 3.2 State Management Optimization ```ts
// Instead of subscribing to the entire graph:
const node = useCanvasStore(s => s.graph.nodes.find(n => n.id === id))
```
- **Granular State Updates**: Use `canvasStore` selectors to update only the affected node slices. This prevents all nodes from re-rendering when a single node changes.
- **Batch Updates**: Wrap multiple mutations in `runWithTiming` or `unstable_batchedUpdates` to reduce render cycles.
### 3.3 Memoization & Lazy Loading ### 3.2 Memoize Template Resolution
- **Memoize Callbacks**: Replace inline event handlers with `useCallback` references stored in context or hooks. Cache the Nunjucks resolution output keyed to a hash of the template source plus variable inputs. Invalidate only when those inputs change:
- **Dynamic Imports**: Use `import()` for heavy modules (e.g., `RenderingNode`, `AnimatedEdge`) to split the bundle.
- **Virtualized Lists**: Integrate `react-window` or `react-virtualized` for large node lists in sidebars.
### 3.4 Code Splitting & Bundle Optimization ```ts
const resolved = useMemo(
() => resolveTemplate(template, variables),
[templateHash, variableHash]
)
```
- **Remove Unused Dependencies**: Audit `package.json` for deprecated libraries. ### 3.3 Deduplicate Kroki Requests
- **Enable `vite-plugin-dynamic-import`** for ondemand loading of routespecific components.
- **Compress Assets**: Configure `gzip`/`brotli` in `nginx.conf` for large SVG and texture assets.
### 3.5 Caching Strategy Enhancements 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).
- **Centralize Caching**: Ensure all external API calls route through `cacheRepository`. ### 3.4 Wire Backend Cache and Rate Limiter
- **Add TTL** to cached responses to avoid stale data while still reducing repeat calls.
### 3.6 Testing & Verification `InMemoryCache` and `rateLimiter` middleware are implemented in `backend/src/`. Connect them to `agentRoutes.ts`:
- **Performance Tests**: Add Vitest benchmarks for render times using `performance.now()`. 1. Add cache lookup before calling the AI service.
- **Profile with React DevTools**: Capture flame graphs before and after each optimization. 2. Store the response on cache miss.
- **CI Gate**: Enforce that PRs must include a performance regression test if changes affect rendering. 3. Apply rate limiting per IP to prevent abuse.
## 4. Contribution Path for Junior Developers ### 3.5 Debounce localStorage Writes
1. **Familiarize** with the `canvasStore` architecture and the `CanvasPage` component structure. 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.
2. **Pick** a lowrisk optimization (e.g., memoizing a utility function).
3. **Implement** the change, add JSDoc comments, and write a simple benchmark.
4. **Submit** a PR with:
- Description of the performance gain.
- Updated tests/benchmarks.
- Documentation in `PERFORMANCE_IMPROVEMENTS.md`.
## 5. Success Metrics ### 3.6 Virtualize the Sidebar Tree
- **Target**: Reduce average frame time from ~16ms to <10ms for `CanvasPage`. Integrate `react-arborist` (already installed) with virtualization enabled for the recollection sidebar when item count exceeds a threshold (~50).
- **Bundle Size**: Decrease initial load by ≥15%.
- **API Latency**: Cut repeated call overhead by ≥30%.
*This plan is living; subsequent sections will track progress and update targets.* ### 3.7 Code Split Heavy Routes
Add lazy imports for the three heavy route components so the initial bundle only loads what the user navigates to:
```ts
const FluxRoute = lazy(() => import('./app/recollections/flux/FluxRoute'))
const LogosPage = lazy(() => import('./app/recollections/logos/LogosPage'))
const KatalogosPage = lazy(() => import('./app/recollections/katalogos/KatalogosPage'))
```
### 3.8 Enable Brotli Compression in Nginx
Add brotli/gzip compression to `frontend/nginx.conf` for JS, CSS, and SVG assets. This can cut transfer size by 6070% for the JS bundle.
---
## 4. Success Metrics
| Metric | Current (estimated) | Target |
| --- | --- | --- |
| Frame time on 50-node canvas | ~16 ms | < 10 ms |
| Initial JS bundle (gzipped) | ~800 KB | < 600 KB |
| Repeated identical LLM calls | uncached | 0 network round-trips |
| localStorage write frequency | every change | debounced 300 ms |
---
## 5. Contribution Path
1. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the module you're optimizing.
2. Pick one item from section 2.
3. Add a Vitest benchmark (`performance.now()` before/after) alongside your change.
4. Submit a PR with the benchmark results in the description and update this file's "Current" column.
*This plan is a living document; update the metrics table when improvements land.*

142
README.md
View File

@@ -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** (for AI agent and health): **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/* and /health to backend) # → http://localhost:3000 (Vite proxies /api/* and /health to backend)
``` ```
--- ---
@@ -61,77 +80,74 @@ docker compose up --build
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
## Contribute | Command | Description |
|--------------------------------------|----------------------------------------|
For contribution guidelines, see: | `cd frontend && npm run dev` | Vite dev server (port 3000). |
| `cd frontend && npm run build` | Build frontend for production. |
- [ARCHITECTURE.md](ARCHITECTURE.md) | `cd frontend && npm run preview` | Preview production build. |
- [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) | `cd frontend && npm run test` | Run tests in watch mode (Vitest). |
- [QUICKSTART_FOR_JUNIORS.md](QUICKSTART_FOR_JUNIORS.md) | `cd frontend && npm run test:run` | Run tests once (CI). |
- [CONTRIBUTING.md](CONTRIBUTING.md) | `cd backend && npm run dev` | Backend with `tsx --watch`. |
- [CODE_REVIEW_CHECKLIST.md](CODE_REVIEW_CHECKLIST.md) | `cd backend && npm start` | Backend production run. |
| `docker compose up --build` | Run full stack in Docker. |
*Thank you for contributing!*
| Command | Description |
|--------|-------------|
| `cd frontend && npm run dev` | Vite dev server. |
| `cd frontend && npm run build` | Build frontend for production. |
| `cd frontend && npm run preview` | Preview production build. |
| `cd backend && npm run dev` | Backend with `--watch`. |
| `cd backend && npm start` | Backend production run. |
| `docker compose up --build` | Run frontend + backend in Docker. |
--- ---
## Backend API (no DB) ## Backend API
| Method | Path | Description | | Method | Path | Body / Response |
|--------|------|-------------| |--------|-----------------------|---------------------------------------------------------------------------------|
| GET | `/health` | Health check (e.g. for Docker). | | GET | `/health` | `{ ok: true, timestamp: number }` — health check for Docker/orchestration. |
| POST | `/api/agent` | Run AI agent; body `{ "prompt", "context?", "contextNodes?" }``{ "markdown" }`. | | POST | `/api/agent` | Body `{ prompt, context?, contextNodes? }``{ markdown }` (one-shot). |
| POST | `/api/agent/stream` | Body `{ prompt, context?, contextNodes? }` → SSE text stream (streaming). |
--- ---
## Agent node (local LLM or OpenAI) ## 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!*

View File

@@ -3,7 +3,7 @@
* with initial graph from recollection storage (or example). Save is explicit via save(). * with initial graph from recollection storage (or example). Save is explicit via save().
*/ */
import { useCallback, useMemo, useRef, useState } 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, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage' import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
@@ -59,5 +59,16 @@ export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGra
}, SAVING_DISPLAY_MS) }, SAVING_DISPLAY_MS)
}, [recollectionId]) }, [recollectionId])
// Auto-save after 5 seconds of inactivity when there are unsaved changes.
// Prevents data loss if the user closes the tab without pressing Ctrl+S.
const AUTO_SAVE_DELAY_MS = 5000
const saveRef = useRef(save)
saveRef.current = save
useEffect(() => {
if (!isDirty || !recollectionId) return
const timer = setTimeout(() => saveRef.current(), AUTO_SAVE_DELAY_MS)
return () => clearTimeout(timer)
}, [isDirty, recollectionId, currentSerialized])
return { ...result, save, saveStatus } return { ...result, save, saveStatus }
} }

View File

@@ -14,6 +14,7 @@
*/ */
import React, { useCallback, useMemo, useRef, useState } from 'react' import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import { import {
Tree, Tree,
NodeApi, NodeApi,
@@ -422,6 +423,7 @@ export function TreeBrowser() {
const { tree, handleTreeChange } = useRecollectionSidebar() const { tree, handleTreeChange } = useRecollectionSidebar()
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const treeRef = useRef<TreeApi<TreeNode> | null>(null) const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600)
// Build tree from flat structure // Build tree from flat structure
const treeNodes = useTreeNodes(tree) const treeNodes = useTreeNodes(tree)
@@ -533,7 +535,7 @@ export function TreeBrowser() {
idAccessor: 'id' as const, idAccessor: 'id' as const,
childrenAccessor: 'children' as const, childrenAccessor: 'children' as const,
width: '100%' as const, width: '100%' as const,
height: 600, height: Math.max(treeContainerHeight, 100),
rowHeight: 32, rowHeight: 32,
indent: ROW_INDENT_PX, indent: ROW_INDENT_PX,
renderRow: TreeRow, renderRow: TreeRow,
@@ -545,7 +547,7 @@ export function TreeBrowser() {
renderCursor: LogosDropCursor, renderCursor: LogosDropCursor,
children: LogosTreeNode, children: LogosTreeNode,
}), }),
[filteredTree, initialOpenState, handleMove, searchQuery] [filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
) )
return ( return (
@@ -599,7 +601,7 @@ export function TreeBrowser() {
</div> </div>
{/* Tree */} {/* Tree */}
<div className="flex-1 min-h-0 overflow-hidden"> <div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
<Tree ref={treeRef} {...arboristTreeProps} /> <Tree ref={treeRef} {...arboristTreeProps} />
</div> </div>
</div> </div>

View File

@@ -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
@@ -107,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 => {
@@ -248,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)
}) })
}) })
} }

View File

@@ -56,6 +56,7 @@
*/ */
import { useCallback, useDeferredValue, 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 { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle' import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore' import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
@@ -175,8 +176,11 @@ export function useRenderingNodeState(
// Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant 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. // Context only exposes a ref, so we wouldn't re-render when another node updates otherwise.
const storeNodes = useCanvasStore((s) => s.graph.nodes) // Single combined subscription (vs two separate) halves listener overhead; useShallow prevents
const storeEdges = useCanvasStore((s) => s.graph.edges) // 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 nodes = storeNodes.length > 0 ? storeNodes : contextNodes
const edges = storeEdges.length > 0 ? storeEdges : contextEdges const edges = storeEdges.length > 0 ? storeEdges : contextEdges

View File

@@ -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',

View File

@@ -1,21 +1,25 @@
import React from 'react' import React, { lazy, Suspense } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { ThemeProvider } from './lib/themeContext' import { ThemeProvider } from './lib/themeContext'
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes' import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
import { registerBuiltinConfigTypes } from './lib/graph/configTypes' import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
import { KosmosPage } from './app/kosmos/KosmosPage'
import { RecollectionsPage } from './app/recollections/RecollectionsPage'
import { RecollectionLayout } from './app/recollections/RecollectionLayout' import { RecollectionLayout } from './app/recollections/RecollectionLayout'
import { LogosPage } from './app/recollections/logos/LogosPage'
import { FluxRoute } from './app/recollections/flux/FluxRoute'
import { NotFoundPage } from './app/NotFoundPage' import { NotFoundPage } from './app/NotFoundPage'
import './lib/prismSetup' import './lib/prismSetup'
import 'prismjs/themes/prism.css' import 'prismjs/themes/prism.css'
import './styles.css' import './styles.css'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
// Lazy-load heavy route components so their bundles are fetched on first navigation,
// not at initial app load. RecollectionLayout and NotFoundPage stay eager (lightweight).
const KosmosPage = lazy(() => import('./app/kosmos/KosmosPage').then(m => ({ default: m.KosmosPage })))
const RecollectionsPage = lazy(() => import('./app/recollections/RecollectionsPage').then(m => ({ default: m.RecollectionsPage })))
const LogosPage = lazy(() => import('./app/recollections/logos/LogosPage').then(m => ({ default: m.LogosPage })))
const FluxRoute = lazy(() => import('./app/recollections/flux/FluxRoute').then(m => ({ default: m.FluxRoute })))
registerBuiltinConfigTypes() registerBuiltinConfigTypes()
registerBuiltinNodes() registerBuiltinNodes()
@@ -23,6 +27,7 @@ createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <ThemeProvider>
<BrowserRouter> <BrowserRouter>
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground text-sm">Loading</div>}>
<Routes> <Routes>
<Route path="/" element={<KosmosPage />}> <Route path="/" element={<KosmosPage />}>
<Route index element={<Navigate to="/recollections" replace />} /> <Route index element={<Navigate to="/recollections" replace />} />
@@ -37,6 +42,7 @@ createRoot(document.getElementById('root')!).render(
</Route> </Route>
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</Suspense>
</BrowserRouter> </BrowserRouter>
<Toaster richColors position="bottom-right" /> <Toaster richColors position="bottom-right" />
</ThemeProvider> </ThemeProvider>