Compare commits

..

10 Commits

Author SHA1 Message Date
223336c606 fix: improvements 2026-03-28 22:20:19 +01:00
cbd8f1568b fix: documentation 2026-03-20 10:48:06 +01:00
166c7b0b7f feat: improve sidebar 2026-03-20 10:20:51 +01:00
2635b45973 feat: sidebar tree 2026-03-20 08:49:00 +01:00
c3cbe35883 fIx: smaller nits 2026-03-20 00:04:46 +01:00
bc67d01bc2 feat: small improvements 2026-03-16 22:49:29 +01:00
96cb3701ba feat: redesign navs 2026-03-16 22:28:30 +01:00
5bce586db6 feat: refactor katalogos 2026-03-16 21:29:09 +01:00
febca67c9b fix: hover 2026-03-16 20:41:59 +01:00
9076a45510 fix: broken build 2026-03-16 20:40:26 +01:00
55 changed files with 4571 additions and 1436 deletions

216
ARCHITECTURE.md Normal file
View File

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

61
CONTRIBUTING.md Normal file
View File

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

108
PERFORMANCE_IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,108 @@
# Performance Improvements Plan
## 1. Current State
The core rendering and state management architecture is already well-structured for performance:
- **Command/reducer store** — all mutations go through a pure reducer; individual selectors can prevent unnecessary re-renders.
- **Delta-based undo/redo** — only diffs are stored, not full graph snapshots (max 100 entries).
- **Plugin node registry** — node types are loaded once at app init, not dynamically on each render.
- **Streaming AI responses** — `POST /api/agent/stream` uses SSE so the UI updates incrementally.
The sections below identify remaining bottlenecks and concrete next steps.
---
## 2. Known Bottlenecks
| # | Issue | Location | Impact | Root Cause |
| --- | --- | --- | --- | --- |
| 1 | Unguarded re-renders on canvas | `CanvasPage.tsx` | Frame drops on large graphs | Components not subscribed to granular store slices |
| 2 | Nunjucks template resolution on every render | `rendering.ts` resolve step | Slow Config node updates | No memoization of template output keyed to input hash |
| 3 | Kroki SVG requests not deduplicated | `rendering.ts` render step | Redundant network calls | No in-flight request deduplication or client-side cache |
| 4 | Sidebar tree renders all items | `KosmosPage` recollection tree | Scrolling lag with many workspaces | No list virtualization |
| 5 | Backend cache not wired to agent routes | `agentRoutes.ts` | Repeated identical LLM calls | `InMemoryCache` and `rateLimiter` exist but are unused |
| 6 | Full graph serialized to localStorage on every change | `useGraphStateWithHistory` | Storage I/O on every keypress | No debounce on the persistence write |
| 7 | Initial bundle size | Vite build | Slow first load | Heavy deps (BlockNote, React Flow, Nunjucks) loaded eagerly |
---
## 3. Recommended Improvements
### 3.1 Granular Store Subscriptions
Zustand supports slice-level subscriptions. Node components should select only their own data slice:
```ts
// Instead of subscribing to the entire graph:
const node = useCanvasStore(s => s.graph.nodes.find(n => n.id === id))
```
This prevents all nodes from re-rendering when a single node changes.
### 3.2 Memoize Template Resolution
Cache the Nunjucks resolution output keyed to a hash of the template source plus variable inputs. Invalidate only when those inputs change:
```ts
const resolved = useMemo(
() => resolveTemplate(template, variables),
[templateHash, variableHash]
)
```
### 3.3 Deduplicate Kroki Requests
Add a simple in-flight map in the render step: if a request for the same PlantUML source is already pending, reuse its promise. Cache successful responses keyed to the source string with a short TTL (e.g. 5 minutes).
### 3.4 Wire Backend Cache and Rate Limiter
`InMemoryCache` and `rateLimiter` middleware are implemented in `backend/src/`. Connect them to `agentRoutes.ts`:
1. Add cache lookup before calling the AI service.
2. Store the response on cache miss.
3. Apply rate limiting per IP to prevent abuse.
### 3.5 Debounce localStorage Writes
Wrap the graph persistence call in a debounce (e.g., 300 ms) to avoid a write on every keystroke or node drag. The delta-based history already computes minimal diffs; the bottleneck is the frequency of writes.
### 3.6 Virtualize the Sidebar Tree
Integrate `react-arborist` (already installed) with virtualization enabled for the recollection sidebar when item count exceeds a threshold (~50).
### 3.7 Code Split Heavy Routes
Add lazy imports for the three heavy route components so the initial bundle only loads what the user navigates to:
```ts
const FluxRoute = lazy(() => import('./app/recollections/flux/FluxRoute'))
const LogosPage = lazy(() => import('./app/recollections/logos/LogosPage'))
const KatalogosPage = lazy(() => import('./app/recollections/katalogos/KatalogosPage'))
```
### 3.8 Enable Brotli Compression in Nginx
Add brotli/gzip compression to `frontend/nginx.conf` for JS, CSS, and SVG assets. This can cut transfer size by 6070% for the JS bundle.
---
## 4. Success Metrics
| Metric | Current (estimated) | Target |
| --- | --- | --- |
| Frame time on 50-node canvas | ~16 ms | < 10 ms |
| Initial JS bundle (gzipped) | ~800 KB | < 600 KB |
| Repeated identical LLM calls | uncached | 0 network round-trips |
| localStorage write frequency | every change | debounced 300 ms |
---
## 5. Contribution Path
1. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the module you're optimizing.
2. Pick one item from section 2.
3. Add a Vitest benchmark (`performance.now()` before/after) alongside your change.
4. Submit a PR with the benchmark results in the description and update this file's "Current" column.
*This plan is a living document; update the metrics table when improvements land.*

134
README.md
View File

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

View File

@@ -13,6 +13,13 @@
"cors": "^2.8.5",
"express": "^4.21.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "^5.0.0"
},
"engines": {
"node": ">=20"
}
@@ -103,6 +110,448 @@
"zod": "^3.23.8"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -112,12 +561,120 @@
"node": ">=8.0.0"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/diff-match-patch": {
"version": "1.0.36",
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
"license": "MIT"
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -392,6 +949,48 @@
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -489,6 +1088,21 @@
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -535,6 +1149,19 @@
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -853,6 +1480,16 @@
"node": ">=0.10.0"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -1051,6 +1688,26 @@
"node": ">=0.6"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1064,6 +1721,27 @@
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",

View File

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

View File

@@ -1,101 +0,0 @@
/**
* Minimal Express API: /api/agent, /health.
* 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())
/** 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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

20
backend/tsconfig.json Normal file
View File

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

View File

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

View File

@@ -0,0 +1,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!*

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,9 @@
"test:run": "vitest run"
},
"dependencies": {
"@blocknote/core": "^0.47.1",
"@blocknote/react": "^0.47.1",
"@blocknote/shadcn": "^0.47.1",
"@blocknote/core": "0.36.1",
"@blocknote/react": "0.36.1",
"@blocknote/shadcn": "0.36.1",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
@@ -43,6 +43,7 @@
"prism-react-renderer": "^2.4.1",
"prismjs": "^1.30.0",
"react": "18.2.0",
"react-arborist": "^3.4.3",
"react-dom": "18.2.0",
"react-router-dom": "^6.28.0",
"react-simple-code-editor": "^0.14.1",

View File

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

View File

@@ -26,5 +26,19 @@ export {
selectRenamingNodeId,
selectFullscreenNodeId,
selectConnectionFrom,
// Fine-grained selectors
selectNodeById,
selectNodesById,
selectNodesByType,
selectNodeIdsByType,
selectEdgesBySource,
selectEdgesByTarget,
selectEdgesForNode,
selectNodeCount,
selectEdgeCount,
selectHasNode,
selectHasEdge,
// SVG detection
isSvgContent,
} from './canvasStore.selectors'
export type { ConnectionPathRole } from './canvasStore.selectors'

View File

@@ -10,10 +10,19 @@ import {
} from '@/lib/graph/graphologyPath'
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
import type { CanvasStore } from './canvasStore.types'
import type { AppNode } from '@/lib/graph/nodeTypes'
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
// ---------------------------------------------------------------------------
@@ -38,6 +47,65 @@ 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
// ---------------------------------------------------------------------------

View File

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

View File

@@ -17,9 +17,11 @@ function KosmosLayoutInner() {
if (recollectionId) recordRecollectionAccess(recollectionId)
}, [recollectionId, recordRecollectionAccess])
const showSidebar = !recollectionId
return (
<SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen}>
<KosmosSidebar />
{showSidebar && <KosmosSidebar />}
<div className="relative flex h-dvh min-h-0 w-full flex-1 flex-col overflow-hidden bg-background">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Outlet />

View File

@@ -38,7 +38,6 @@ export function KosmosSidebar() {
const navigate = useNavigate()
const location = useLocation()
const { recollectionId: selectedRecollectionId } = useParams<{ recollectionId: string }>()
const handleSelectRecollection = useCallback((id: string) => navigate(`/recollections/${id}`), [navigate])
const handleCreateRecollection = useCallback(
(recollection: Parameters<typeof createRecollection>[0]) => {
@@ -99,7 +98,7 @@ export function KosmosSidebar() {
) : (
<SidebarMenuButton asChild size="lg" tooltip="Zoë" className="font-semibold font-serif">
<Link to="/recollections">
<span className="flex size-8 min-w-8 items-center justify-center rounded-lg 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
className="size-6 shrink-0 rounded-[2px] opacity-90"
style={{
@@ -149,13 +148,11 @@ export function KosmosSidebar() {
const isActive = selectedRecollectionId === recollection.id
return (
<SidebarMenuItem key={recollection.id}>
<SidebarMenuButton
tooltip={recollection.name}
isActive={isActive}
onClick={() => handleSelectRecollection(recollection.id)}
>
<Icon className="size-4" />
<span>{recollection.name}</span>
<SidebarMenuButton asChild tooltip={recollection.name} isActive={isActive}>
<Link to={`/recollections/${recollection.id}`}>
<Icon className="size-4" />
<span>{recollection.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)

View File

@@ -3,15 +3,17 @@
*/
import React, { useEffect } from 'react'
import { Link, Outlet, useParams } from 'react-router-dom'
import { Outlet, useParams, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { RecollectionMenubarProvider } from './layout/RecollectionMenubarContext'
import { RecollectionActionsProvider } from './layout/RecollectionActionsContext'
import { RecollectionTitleContent } from './layout/RecollectionTitleContent'
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(() => {
@@ -20,29 +22,44 @@ export function RecollectionLayout() {
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 (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
<p className="text-sm text-muted-foreground">Recollection not found.</p>
<Link to="/recollections" className="text-sm text-primary hover:underline">
Back to recollections
</Link>
</div>
)
return null
}
return (
<RecollectionMenubarProvider>
<RecollectionActionsProvider>
<RecollectionTitleContent />
<RecollectionActionsProvider>
<RecollectionSidebarProvider>
<div className="flex min-h-0 flex-1 flex-col">
<RecollectionMenubar />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<Outlet />
<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>
</RecollectionActionsProvider>
</RecollectionMenubarProvider>
</RecollectionSidebarProvider>
</RecollectionActionsProvider>
)
}

View File

@@ -260,7 +260,10 @@ function RecollectionActionsMenu({
export type ViewMode = 'table' | 'cards'
const RECOLLECTIONS_SCROLL_KEY = 'recollections-list-scroll'
export function RecollectionsPage() {
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
const { orderedRecollections, deleteRecollection, renameRecollection, createRecollection, restoreRecollection } = usePlatform()
const navigate = useNavigate()
const [viewMode, setViewMode] = useState<ViewMode>('cards')
@@ -329,6 +332,28 @@ export function RecollectionsPage() {
}
}, [duplicateTarget])
// Restore scroll position when returning to the list
React.useEffect(() => {
const el = scrollContainerRef.current
if (!el) return
try {
const saved = sessionStorage.getItem(RECOLLECTIONS_SCROLL_KEY)
if (saved !== null) {
const top = parseInt(saved, 10)
if (Number.isFinite(top)) requestAnimationFrame(() => { el.scrollTop = top })
}
} catch {
/* ignore */
}
return () => {
try {
sessionStorage.setItem(RECOLLECTIONS_SCROLL_KEY, String(el.scrollTop))
} catch {
/* ignore */
}
}
}, [])
const handleDeleteOpen = useCallback((recollection: Recollection) => {
setDeleteTarget(recollection)
}, [])
@@ -487,7 +512,7 @@ export function RecollectionsPage() {
return (
<div className="relative flex flex-1 flex-col min-h-0">
<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>
<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">

View File

@@ -3,7 +3,7 @@
* 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, useRef, useState } from 'react'
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 {
@@ -120,7 +120,7 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
pathnameRef.current = pathname
const isFluxActive = pathname.endsWith('/flux')
const isLogosActive = pathname.endsWith('/logos') || /\/recollections\/[^/]+\/?$/.test(pathname)
const isLogosActive = pathname.includes('/logos') || /\/recollections\/[^/]+\/?$/.test(pathname)
const activeSlot = isFluxActive ? flux : isLogosActive ? logos : null
const setFluxSlot = useCallback((slot: FluxSlot | null) => {
@@ -130,6 +130,22 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
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)
@@ -179,7 +195,7 @@ export function RecollectionActionsProvider({ children }: { children: React.Reac
)
const currentPath = pathnameRef.current
const fluxActive = currentPath.endsWith('/flux')
const logosActive = currentPath.endsWith('/logos') || /\/recollections\/[^/]+\/?$/.test(currentPath)
const logosActive = currentPath.includes('/logos') || /\/recollections\/[^/]+\/?$/.test(currentPath)
if (written.graph && fluxActive && fluxRef.current?.onRefreshFromStore) {
fluxRef.current.onRefreshFromStore(written.graph)
}

View File

@@ -46,17 +46,21 @@ export function RecollectionEditViewMenus() {
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [activeSlot])
const menus = useMemo(() => {
if (!activeSlot) return null
const hasFluxOnly =
fluxSlot &&
(fluxSlot.onDuplicate != null || fluxSlot.onCopy != null || fluxSlot.onPaste != null)
return (
<Menubar className="shrink-0 rounded-none border-0 bg-transparent p-0 shadow-none">
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="font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarTrigger className="h-9 font-normal text-muted-foreground">Edit</MenubarTrigger>
<MenubarContent>
<MenubarItem onClick={activeSlot.undo} disabled={!activeSlot.canUndo} className="gap-2">
<MenubarItem
onClick={() => activeSlot?.undo()}
disabled={!activeSlot?.canUndo}
className="gap-2"
>
<Undo2 className="h-4 w-4" />
Undo
<span className="ml-auto pl-4">
@@ -65,7 +69,11 @@ export function RecollectionEditViewMenus() {
</KbdGroup>
</span>
</MenubarItem>
<MenubarItem onClick={activeSlot.redo} disabled={!activeSlot.canRedo} className="gap-2">
<MenubarItem
onClick={() => activeSlot?.redo()}
disabled={!activeSlot?.canRedo}
className="gap-2"
>
<Redo2 className="h-4 w-4" />
Redo
<span className="ml-auto pl-4">
@@ -74,13 +82,13 @@ export function RecollectionEditViewMenus() {
</KbdGroup>
</span>
</MenubarItem>
{hasFluxOnly && (
{hasFluxOnly && fluxSlot && (
<>
<MenubarSeparator />
{fluxSlot!.onDuplicate != null && (
{fluxSlot.onDuplicate != null && (
<MenubarItem
onClick={fluxSlot!.onDuplicate}
disabled={!fluxSlot!.canDuplicate}
onClick={fluxSlot.onDuplicate}
disabled={!fluxSlot.canDuplicate}
className="gap-2"
>
<CopyPlus className="h-4 w-4" />
@@ -92,10 +100,10 @@ export function RecollectionEditViewMenus() {
</span>
</MenubarItem>
)}
{fluxSlot!.onCopy != null && (
{fluxSlot.onCopy != null && (
<MenubarItem
onClick={fluxSlot!.onCopy}
disabled={!fluxSlot!.canCopy}
onClick={fluxSlot.onCopy}
disabled={!fluxSlot.canCopy}
className="gap-2"
>
<Copy className="h-4 w-4" />
@@ -107,8 +115,8 @@ export function RecollectionEditViewMenus() {
</span>
</MenubarItem>
)}
{fluxSlot!.onPaste != null && (
<MenubarItem onClick={fluxSlot!.onPaste} className="gap-2">
{fluxSlot.onPaste != null && (
<MenubarItem onClick={fluxSlot.onPaste} className="gap-2">
<ClipboardPaste className="h-4 w-4" />
Paste
<span className="ml-auto pl-4">
@@ -123,7 +131,7 @@ export function RecollectionEditViewMenus() {
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
<MenubarTrigger className="font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarTrigger className="h-9 font-normal text-muted-foreground">View</MenubarTrigger>
<MenubarContent>
{fluxSlot?.onFitView && (
<MenubarItem onClick={fluxSlot.onFitView} className="gap-2">
@@ -138,8 +146,9 @@ export function RecollectionEditViewMenus() {
</MenubarContent>
</MenubarMenu>
</Menubar>
)
}, [activeSlot, fluxSlot])
),
[activeSlot, fluxSlot, hasFluxOnly]
)
return menus
}

View File

@@ -76,7 +76,7 @@ export function RecollectionFileMenu() {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger className="rounded-sm px-2 py-1 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">
<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>

View File

@@ -1,47 +1,114 @@
/**
* Recollection menubar: Back | title + save status | registered menus (middle).
* Recollection menubar: Back | breadcrumbs (left) | view switcher + File + Edit + save status (center).
*/
import React from 'react'
import { Link, useParams } from 'react-router-dom'
import { Link, useLocation, useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { ArrowLeft } from 'lucide-react'
import { useRecollectionMenubar } from './RecollectionMenubarContext'
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 { titleContent } = useRecollectionMenubar()
const { activeSlot } = useRecollectionActions()
const saveStatus = activeSlot?.saveStatus ?? null
const recollection = recollectionId ? recollections.find((p) => p.id === recollectionId) : null
const defaultTitle = recollection ? (
<span className="truncate text-sm font-medium text-muted-foreground">{recollection.name}</span>
) : 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">
<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"
>
<ArrowLeft className="size-4" />
</Link>
<RecollectionViewSwitcher />
{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-1.5">
{titleContent ?? defaultTitle}
<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 className="ml-auto flex shrink-0 items-center gap-2 pl-2">
<RecollectionFileMenu />
<RecollectionEditViewMenus />
</div>
</div>
)
}

View File

@@ -1,37 +0,0 @@
/**
* Context for the recollection menubar:
* - Title (next to back): optional title + status, e.g. recollection name and save state.
* - Middle: shared Edit/View menus (RecollectionEditViewMenus), no longer registrable.
*/
import React, { createContext, useCallback, useContext, useState } from 'react'
export type RecollectionMenubarContextValue = {
/** Content next to the back button (e.g. title + save status, with optional dropdown). Null = use default recollection name. */
titleContent: React.ReactNode
setTitleContent: (content: React.ReactNode) => void
}
const RecollectionMenubarContext = createContext<RecollectionMenubarContextValue | null>(null)
export function RecollectionMenubarProvider({ children }: { children: React.ReactNode }) {
const [titleContent, setTitleContentState] = useState<React.ReactNode>(null)
const setTitleContent = useCallback((content: React.ReactNode) => {
setTitleContentState(() => content)
}, [])
const value: RecollectionMenubarContextValue = React.useMemo(
() => ({ titleContent, setTitleContent }),
[titleContent, setTitleContent]
)
return (
<RecollectionMenubarContext.Provider value={value}>
{children}
</RecollectionMenubarContext.Provider>
)
}
export function useRecollectionMenubar(): RecollectionMenubarContextValue {
const ctx = useContext(RecollectionMenubarContext)
if (!ctx) throw new Error('useRecollectionMenubar must be used within RecollectionMenubarProvider')
return ctx
}

View File

@@ -0,0 +1,111 @@
/**
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
*/
import React, { useCallback } from 'react'
import { useParams, useLocation, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Plus, FileText } from 'lucide-react'
import { FluxIcon } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { useRecollectionSidebar } from './RecollectionSidebarContext'
import { TreeBrowser } from './TreeBrowser'
import {
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
sidebarMenuButtonVariants,
} from '@/components/ui/sidebar'
export function RecollectionSidebar() {
const { recollectionId } = useParams<{ recollectionId: string }>()
const { pathname } = useLocation()
const navigate = useNavigate()
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
const base = recollectionId ? `/recollections/${recollectionId}` : ''
const baseLogos = `${base}/logos`
const isKatalogosView = pathname.endsWith('/logos/katalogos')
const isFluxView = pathname.endsWith('/flux')
const handleAddPage = useCallback(() => {
// Add a new top-level page
const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1)
const newPage = {
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
title: 'Untitled',
parentId: null,
position: maxPos + 1,
}
handleTreeChange([...tree, newPage])
handleSelectPage(newPage.id)
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
}, [tree, handleTreeChange, handleSelectPage, baseLogos, navigate])
return (
<div
className="flex h-full w-[var(--sidebar-width)] shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground"
style={{ '--sidebar-width': '16rem' } as React.CSSProperties}
>
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
<SidebarGroup>
<div className="flex items-center justify-between gap-2 py-1.5">
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
onClick={handleAddPage}
>
<Plus className="size-3.5" />
New page
</Button>
</div>
<SidebarGroupContent>
<TreeBrowser />
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Katalogos</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<button
type="button"
data-active={isKatalogosView}
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
onClick={() => navigate(`${base}/logos/katalogos`)}
>
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
<span className="truncate">Artifacts</span>
</button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Flux</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<button
type="button"
data-active={isFluxView}
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
onClick={() => navigate(`${base}/flux`)}
>
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
<span className="truncate">Canvas</span>
</button>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</div>
)
}

View File

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

View File

@@ -1,123 +0,0 @@
/**
* Shared title + save status block (same for Logos and Flux). Gets save status and Save from
* the active slot (RecollectionActionsContext); Import/Export from layout-level handlers.
*/
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { useRecollectionMenubar } from './RecollectionMenubarContext'
import { useRecollectionActions } from './RecollectionActionsContext'
import { CheckCircle2, CircleDot, Loader2 } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const SAVE_KEYS = { key: 's', shiftKey: false }
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
}
/** Registers the shared title + save dropdown as titleContent. Renders nothing. */
export function RecollectionTitleContent() {
const { setTitleContent } = useRecollectionMenubar()
const { activeSlot } = useRecollectionActions()
const { recollectionId } = useParams<{ recollectionId: string }>()
const { recollections } = usePlatform()
const recollectionName = useMemo(
() => (recollectionId ? recollections.find((p) => p.id === recollectionId)?.name ?? null : null),
[recollectionId, recollections]
)
const [showSavedBriefly, setShowSavedBriefly] = useState(false)
const savedBrieflyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevSaveStatusRef = useRef<string>('saved')
const saveStatus = activeSlot?.saveStatus ?? 'saved'
const onSave = activeSlot?.onSave
const canSave = activeSlot?.canSave ?? false
useEffect(() => {
if (saveStatus === 'unsaved') {
setShowSavedBriefly(false)
if (savedBrieflyTimerRef.current) {
clearTimeout(savedBrieflyTimerRef.current)
savedBrieflyTimerRef.current = null
}
} else if (prevSaveStatusRef.current === 'saving' && saveStatus === 'saved') {
setShowSavedBriefly(true)
if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current)
savedBrieflyTimerRef.current = setTimeout(() => {
savedBrieflyTimerRef.current = null
setShowSavedBriefly(false)
}, 2500)
}
prevSaveStatusRef.current = saveStatus
}, [saveStatus])
useEffect(() => {
return () => {
if (savedBrieflyTimerRef.current) clearTimeout(savedBrieflyTimerRef.current)
}
}, [])
useEffect(() => {
const onKeyDown = (ev: KeyboardEvent) => {
if (matchKey(ev, SAVE_KEYS) && onSave && canSave) {
ev.preventDefault()
ev.stopPropagation()
onSave()
}
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
}, [onSave, canSave])
const trigger = useMemo(
() => (
<span className="flex items-center gap-1.5 truncate">
<span className="truncate text-sm font-medium font-serif max-w-[180px]">
{recollectionName ?? 'Untitled'}
</span>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
className="shrink-0 flex items-center text-muted-foreground cursor-default"
aria-live="polite"
aria-label={
saveStatus === 'saving' ? 'Saving' : saveStatus === 'unsaved' ? 'Unsaved changes' : 'All changes saved'
}
>
{saveStatus === 'saving' && <Loader2 className="size-3.5 animate-spin" aria-hidden />}
{saveStatus === 'unsaved' && <CircleDot className="size-3.5" aria-hidden />}
{saveStatus !== 'unsaved' && (saveStatus === 'saved' || showSavedBriefly) && (
<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>
),
[recollectionName, saveStatus, showSavedBriefly]
)
const titleNode = useMemo(
() => (
<span className="flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm">
{trigger}
</span>
),
[trigger]
)
useLayoutEffect(() => {
setTitleContent(titleNode)
return () => setTitleContent(null)
}, [titleNode, setTitleContent])
return null
}

View File

@@ -1,12 +1,16 @@
/**
* Circular view switcher: icon and title use the same vertical elevator-style animation.
* Click toggles; tooltip includes shortcut.
* View switcher: circle with current view icon; click cycles Logos → Katalogos → Flux.
* Keyboard shortcut: ⌘⇧↑ / ⌘⇧↓ to cycle view.
*/
import React, { useCallback, useEffect } from 'react'
import React, { useCallback, useEffect, useMemo } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { FluxIcon, LogosIcon, RecollectionsIcon } from '@/lib/icons'
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)
@@ -19,25 +23,19 @@ export function RecollectionViewSwitcher() {
const navigate = useNavigate()
const base = recollectionId ? `/recollections/${recollectionId}` : ''
type Mode = 'logos' | 'katalogos' | 'flux'
const mode: Mode = pathname.endsWith('/flux')
? 'flux'
: pathname.endsWith('/katalogos')
? 'katalogos'
: 'logos'
const mode = useMemo(() => getMode(pathname), [pathname])
const modeIndex = RECOLLECTION_VIEW_MODES.indexOf(mode)
const goRelative = useCallback(
(delta: 1 | -1) => {
if (!base) return
const order: Mode[] = ['logos', 'katalogos', 'flux']
const currentIndex = order.indexOf(mode)
const nextMode = order[(currentIndex + (delta === 1 ? 1 : order.length - 1)) % order.length]
navigate(`${base}/${nextMode}`)
const nextIndex = (modeIndex + delta + RECOLLECTION_VIEW_MODES.length) % RECOLLECTION_VIEW_MODES.length
navigate(pathForMode(base, RECOLLECTION_VIEW_MODES[nextIndex]))
},
[base, mode, navigate]
[base, modeIndex, navigate]
)
const toggle = useCallback(() => {
const cycle = useCallback(() => {
goRelative(1)
}, [goRelative])
@@ -47,11 +45,8 @@ export function RecollectionViewSwitcher() {
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)
}
if (ev.key === 'ArrowDown') goRelative(1)
else if (ev.key === 'ArrowUp') goRelative(-1)
}
window.addEventListener('keydown', onKeyDown, true)
return () => window.removeEventListener('keydown', onKeyDown, true)
@@ -59,53 +54,34 @@ export function RecollectionViewSwitcher() {
if (!base) return null
const shortcut = shortcutLabel()
const tooltipText =
mode === 'logos'
? `Logos · Next: Katalogos (${shortcut})`
: mode === 'katalogos'
? `Katalogos · Next: Flux (${shortcut})`
: `Flux · Next: Logos (${shortcut})`
const tooltipText = `${viewLabel(mode)} (${shortcutLabel()})`
const translateY = -modeIndex * ICON_SLOT_SIZE_REM
return (
<div className="flex shrink-0 items-center gap-2">
<div className="flex h-9 shrink-0 items-center">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggle}
aria-label={
mode === 'logos'
? 'Switch to Katalogos'
: mode === 'katalogos'
? 'Switch to Flux'
: 'Switch to Logos'
}
className="h-7 w-7 shrink-0 overflow-hidden rounded-full border border-border/60 bg-background shadow-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
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="h-full w-full overflow-hidden">
<div
className="flex w-full flex-col transition-transform duration-200 ease-out"
style={{
height: '300%',
transform:
mode === 'logos'
? 'translateY(0)'
: mode === 'katalogos'
? 'translateY(-33.3333%)'
: 'translateY(-66.6667%)',
}}
>
<div className="flex h-9 w-full items-center justify-center">
<LogosIcon className="size-3.5 shrink-0 text-muted-foreground" />
</div>
<div className="flex h-9 w-full items-center justify-center">
<RecollectionsIcon className="size-3.5 shrink-0 text-muted-foreground" />
</div>
<div className="flex h-9 w-full items-center justify-center">
<FluxIcon className="size-3.5 shrink-0 text-muted-foreground" />
</div>
<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>
@@ -113,29 +89,6 @@ export function RecollectionViewSwitcher() {
<TooltipContent side="bottom">{tooltipText}</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="h-9 min-w-[3.5rem] overflow-hidden">
<div
className="flex w-full flex-col transition-transform duration-200 ease-out"
style={{
transform:
mode === 'logos'
? 'translateY(0)'
: mode === 'katalogos'
? 'translateY(-33.3333%)'
: 'translateY(-66.6667%)',
}}
>
<span className="flex h-9 flex-shrink-0 items-center text-sm font-medium text-foreground">
Logos
</span>
<span className="flex h-9 flex-shrink-0 items-center text-sm font-medium text-foreground">
Katalogos
</span>
<span className="flex h-9 flex-shrink-0 items-center text-sm font-medium text-foreground">
Flux
</span>
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
/**
* Shared modal for renaming a recollection. Used by RecollectionsPage and RecollectionTitleContent.
* Shared modal for renaming a recollection. Used by RecollectionsPage and RecollectionFileMenu.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'

View File

@@ -0,0 +1,609 @@
/**
* TreeBrowser: A tree browser component using react-arborist for managing
* Logos pages in a hierarchical folder structure.
*
* Features:
* - Drag and drop reordering
* - Folder expansion/collapse
* - Context menu for actions
* - Shadcn theme integration
* - Inline editing for page titles
* - Smooth animations
* - Search functionality
* - Expand/collapse all
*/
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useResizeHeight } from '@/hooks/useResizeHeight'
import {
Tree,
NodeApi,
NodeRendererProps,
RowRendererProps,
TreeApi,
type CursorProps,
} from 'react-arborist'
import { cn } from '@/lib/utils'
import {
FileText,
ChevronRight,
ChevronDown,
Plus,
MoreHorizontal,
Trash2,
Pencil,
Search,
ChevronsDownUp,
ChevronsUpDown,
X,
GripVertical,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { useRecollectionSidebar } from './RecollectionSidebarContext'
import { useParams, useNavigate, useLocation } from 'react-router-dom'
import type { LogosPageMeta } from '../state/recollectionStore'
import { removeLogosPageContent } from '../state/recollectionStore'
// Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object).
type TreeNode = LogosPageMeta & {
children?: TreeNode[]
isFolder: boolean
}
// Convert flat tree to hierarchical structure
function buildTree(pages: LogosPageMeta[]): TreeNode[] {
const pageMap = new Map<string, TreeNode>()
const rootNodes: TreeNode[] = []
// First pass: create all nodes
pages.forEach((page) => {
pageMap.set(page.id, {
...page,
children: [],
isFolder: false,
})
})
// Second pass: build parent-child relationships
pages.forEach((page) => {
const node = pageMap.get(page.id)!
if (page.parentId === null) {
rootNodes.push(node)
} else {
const parent = pageMap.get(page.parentId)
if (parent) {
parent.children?.push(node)
parent.isFolder = true
}
}
})
// Sort children by position
function sortNodes(nodes: TreeNode[]) {
nodes.sort((a, b) => a.position - b.position)
nodes.forEach((node) => sortNodes(node.children || []))
}
sortNodes(rootNodes)
return rootNodes
}
// Memoize the buildTree result to avoid unnecessary re-creation
function useTreeNodes(tree: LogosPageMeta[]) {
return useMemo(() => buildTree(tree), [tree])
}
// Inline editable title component
function EditableTitle({
title,
onSave,
onCancel,
}: {
title: string
onSave: (newTitle: string) => void
onCancel: () => void
}) {
const inputRef = useRef<HTMLInputElement>(null)
React.useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onSave(inputRef.current?.value || '')
} else if (e.key === 'Escape') {
onCancel()
}
}
return (
<input
ref={inputRef}
type="text"
defaultValue={title}
onBlur={(e) => onSave(e.currentTarget.value)}
onKeyDown={handleKeyDown}
className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background"
onClick={(e) => e.stopPropagation()}
/>
)
}
const ROW_INDENT_PX = 20
const ROW_GUTTER_PX = 8
/**
* Line cursor = “insert as sibling at this indent” (not “drop into folder”).
* Triangle + dashed rail read as a slot between rows; matches sidebar theme.
*/
const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) {
return (
<div
role="presentation"
aria-hidden
className="pointer-events-none absolute z-20 flex items-center gap-1.5"
style={{
top: top - 5,
left: left + ROW_GUTTER_PX,
right: Math.max(indent, ROW_GUTTER_PX),
}}
>
<span className="flex h-3 w-2.5 shrink-0 items-center justify-center text-primary drop-shadow-sm">
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor" aria-hidden>
<path d="M0 5 L9 1.5 L9 8.5 Z" />
</svg>
</span>
<div className="flex h-3 min-w-[2rem] flex-1 items-center">
<div
className={cn(
'h-0 w-full border-t-2 border-dashed border-primary',
'shadow-[0_1px_0_0_hsl(var(--primary)/0.35)]',
'motion-safe:animate-[tree-drop-line-pulse_1.8s_ease-in-out_infinite]'
)}
/>
</div>
</div>
)
})
/**
* Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover
* (always visible on coarse pointers). Title/icon strip is click-to-navigate only.
*/
function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
const { recollectionId } = useParams<{ recollectionId: string }>()
const navigate = useNavigate()
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
const base = recollectionId ? `/recollections/${recollectionId}` : ''
const baseLogos = `${base}/logos`
const handleToggle = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
node.toggle()
},
[node]
)
const handleSelect = useCallback(
(e: React.MouseEvent) => {
if (!node.state.isEditing && !node.state.isDragging) {
handleSelectPage(node.data.id)
navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`)
}
},
[handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging]
)
const handleRename = useCallback(() => {
node.edit()
}, [node])
const handleDelete = useCallback(() => {
const toDelete = new Set<string>()
function collectDescendants(id: string) {
toDelete.add(id)
tree.forEach((p) => {
if (p.parentId === id) collectDescendants(p.id)
})
}
collectDescendants(node.data.id)
if (recollectionId) {
toDelete.forEach((pageId) => {
removeLogosPageContent(recollectionId, pageId)
})
}
handleTreeChange(tree.filter((p) => !toDelete.has(p.id)))
}, [tree, handleTreeChange, node.data.id, recollectionId])
const handleAddChild = useCallback(() => {
const maxPos = Math.max(
0,
...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position),
-1
)
const newPage: LogosPageMeta = {
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
title: 'Untitled',
parentId: node.data.id,
position: maxPos + 1,
}
handleTreeChange([...tree, newPage])
if (!node.isOpen) node.open()
}, [tree, handleTreeChange, node])
const hasChildren = (node.children?.length || 0) > 0
const pageTitle = node.data.title || 'Untitled'
return (
<div className="flex min-w-0 flex-1 items-center gap-1">
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */}
<div
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden',
'transition-[width,opacity] duration-200 ease-out',
'w-0 opacity-0 group-hover:w-4 group-hover:opacity-100',
'[@media(pointer:coarse)]:w-5 [@media(pointer:coarse)]:opacity-100',
node.state.isDragging && 'w-4 opacity-100 [@media(pointer:coarse)]:w-5',
node.state.isEditing && 'pointer-events-none w-0 opacity-0'
)}
>
<div
ref={dragHandle}
style={{ ...style, paddingLeft: 0 }}
title={node.state.isEditing ? undefined : 'Drag to reorder'}
className={cn(
'logos-tree-drag-handle group/drag',
'relative flex h-6 w-4 shrink-0 touch-none items-center justify-center rounded-md',
'[@media(pointer:coarse)]:h-7 [@media(pointer:coarse)]:w-5',
'border border-sidebar-border/50 bg-sidebar-accent/40',
'text-sidebar-foreground/70',
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out',
'hover:border-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
'hover:shadow-sm',
'active:scale-[0.97] active:cursor-grabbing',
'cursor-grab outline-none',
'dark:border-sidebar-border/55 dark:bg-sidebar-accent/35 dark:text-sidebar-foreground/75',
'dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground',
node.state.isDragging &&
'cursor-grabbing border-primary/55 bg-primary/18 text-primary shadow-sm ring-1 ring-primary/30 dark:border-primary/50 dark:bg-primary/22 dark:text-primary'
)}
onClick={(e) => e.stopPropagation()}
>
<GripVertical
className={cn(
'size-3 shrink-0 text-current transition-opacity duration-150 opacity-85 group-hover/drag:opacity-100',
node.state.isDragging && 'opacity-100'
)}
strokeWidth={2}
/>
</div>
</div>
{hasChildren ? (
<button
type="button"
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={handleToggle}
>
{node.isOpen ? (
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
) : (
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
)}
</button>
) : (
<div className="w-4 shrink-0" />
)}
<div
className={cn(
'flex min-h-[28px] min-w-0 flex-1 items-center gap-1 rounded-md px-0.5 -mx-0.5',
node.state.isEditing ? 'cursor-text' : 'cursor-pointer hover:bg-sidebar-accent/40'
)}
onClick={handleSelect}
>
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
<div className="min-w-0 flex-1">
{node.state.isEditing ? (
<EditableTitle
title={node.data.title}
onSave={(newTitle) => {
if (newTitle.trim()) {
handleTreeChange(
tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p))
)
}
node.submit(newTitle)
}}
onCancel={() => node.reset()}
/>
) : (
<span className="block truncate select-none">{pageTitle}</span>
)}
</div>
</div>
<div
className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={(e) => {
e.stopPropagation()
handleAddChild()
}}
title="Add child page"
>
<Plus className="size-3.5 text-sidebar-foreground/70" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={handleRename}>
<Pencil className="mr-2 size-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
const { pathname } = useLocation()
const { activePageId } = useRecollectionSidebar()
const { recollectionId } = useParams<{ recollectionId: string }>()
const base = recollectionId ? `/recollections/${recollectionId}` : ''
const baseLogos = `${base}/logos`
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
const level = node.level
const indentPx = ROW_GUTTER_PX + level * ROW_INDENT_PX
return (
<div
ref={innerRef}
{...attrs}
style={{
...attrs.style,
paddingLeft: `${indentPx}px`,
}}
className={cn(
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md transition-[box-shadow,background-color,ring-color] duration-150',
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
node.state.isDragging && 'cursor-grabbing opacity-45',
// Folder drop target: index === null in library — reads as “open container”, not a line between rows
node.state.willReceiveDrop &&
'cursor-copy bg-sidebar-accent/30 ring-2 ring-inset ring-dashed ring-sidebar-ring/80 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]'
)}
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
>
{level > 0 && (
<div
className="pointer-events-none absolute bottom-0 top-0 border-l-2 border-sidebar-border/45"
style={{ left: `${ROW_GUTTER_PX + (level - 1) * ROW_INDENT_PX + ROW_INDENT_PX / 2}px` }}
/>
)}
{children}
</div>
)
}
export function TreeBrowser() {
const { tree, handleTreeChange } = useRecollectionSidebar()
const [searchQuery, setSearchQuery] = useState('')
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600)
// Build tree from flat structure
const treeNodes = useTreeNodes(tree)
// Filter tree based on search query
const filteredTree = useMemo(() => {
if (!searchQuery.trim()) return treeNodes
const searchTerm = searchQuery.toLowerCase()
function filterNodes(nodes: TreeNode[]): TreeNode[] {
const result: TreeNode[] = []
for (const node of nodes) {
const matches = node.title.toLowerCase().includes(searchTerm)
const children = filterNodes(node.children || [])
if (matches || children.length > 0) {
result.push({
...node,
children: children.length > 0 ? children : undefined,
})
}
}
return result
}
return filterNodes(treeNodes)
}, [treeNodes, searchQuery])
// Get all folder IDs for initial open state
const initialOpenState = useMemo(() => {
const folderIds: Record<string, boolean> = {}
function collectIds(nodes: TreeNode[]) {
nodes.forEach((node) => {
if (node.isFolder) {
folderIds[node.id] = true
collectIds(node.children || [])
}
})
}
collectIds(treeNodes)
return folderIds
}, [treeNodes])
// Handle drag and drop reordering (react-arborist onMove)
const handleMove = useCallback(
({
dragIds,
parentId: newParentId,
index,
}: {
dragIds: string[]
parentId: string | null
index: number
}) => {
const dragId = dragIds[0]
if (!dragId) return
const dragged = tree.find((p) => p.id === dragId)
if (!dragged) return
const oldParentId = dragged.parentId
const moved: LogosPageMeta = { ...dragged, parentId: newParentId }
const newSiblings = tree
.filter((p) => p.parentId === newParentId && p.id !== dragId)
.sort((a, b) => a.position - b.position)
newSiblings.splice(index, 0, moved)
const updates = new Map<string, LogosPageMeta>()
newSiblings.forEach((p, i) => {
updates.set(p.id, { ...p, parentId: newParentId, position: i })
})
if (oldParentId !== newParentId) {
tree
.filter((p) => p.parentId === oldParentId && p.id !== dragId)
.sort((a, b) => a.position - b.position)
.forEach((p, i) => {
updates.set(p.id, { ...p, position: i })
})
}
handleTreeChange(tree.map((p) => updates.get(p.id) ?? p))
},
[tree, handleTreeChange]
)
// Expand all folders
const handleExpandAll = useCallback(() => {
treeRef.current?.openAll()
}, [])
// Collapse all folders
const handleCollapseAll = useCallback(() => {
treeRef.current?.closeAll()
}, [])
// Clear search
const handleClearSearch = useCallback(() => {
setSearchQuery('')
}, [])
// Fixed prop shape: react-arborist TreeProvider uses [...Object.values(treeProps), …] as useMemo deps;
// varying key counts on the props object trigger "dependency array changed size" warnings.
const arboristTreeProps = useMemo(
() => ({
data: filteredTree,
idAccessor: 'id' as const,
childrenAccessor: 'children' as const,
width: '100%' as const,
height: Math.max(treeContainerHeight, 100),
rowHeight: 32,
indent: ROW_INDENT_PX,
renderRow: TreeRow,
initialOpenState,
onMove: handleMove,
disableDrag: Boolean(searchQuery),
disableDrop: Boolean(searchQuery),
className: 'react-arborist-tree',
renderCursor: LogosDropCursor,
children: LogosTreeNode,
}),
[filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
)
return (
<div className="flex flex-col gap-2 h-full">
{/* Search Toolbar */}
<div className="flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
<Input
type="text"
placeholder="Search pages..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
/>
{searchQuery && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
onClick={handleClearSearch}
>
<X className="size-3" />
</Button>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
title="Expand/Collapse options"
>
<ChevronsUpDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExpandAll}>
<ChevronsDownUp className="mr-2 size-3.5" />
Expand all
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCollapseAll}>
<ChevronsUpDown className="mr-2 size-3.5" />
Collapse all
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Tree */}
<div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
<Tree ref={treeRef} {...arboristTreeProps} />
</div>
</div>
)
}

View File

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

View File

@@ -1,29 +1,24 @@
/**
* Logos page: BlockNote editor for the recollection. Content persisted in recollection store.
* Left sidebar for page/subpage hierarchy (one level). Layout and styling aligned with Flux.
* 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 } from 'react-router-dom'
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 {
getLogosContent,
getLogosPageTree,
setLogosPageTree,
getLogosContentForPage,
setLogosContentForPage,
removeLogosPageContent,
type StoredLogosContent,
type LogosPageMeta,
type LogosPageId,
} from '../state/recollectionStore'
import { logosSchema } from './logosSchema'
import { LogosSidebar } from './LogosSidebar'
import { KatalogosPage } from '../katalogos/KatalogosPage'
import { toast } from 'sonner'
/** Wraps BlockNoteView so refs go to a div, not the function component (avoids ref warning). */
@@ -59,60 +54,30 @@ function patchBlockNoteRefWarning() {
}
}
const MAIN_PAGE_ID: LogosPageId = 'main'
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 [tree, setTree] = useState<LogosPageMeta[]>([])
const [activePageId, setActivePageId] = useState<LogosPageId | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('saved')
// Load page tree and run migration when empty (treat existing single doc as "Main" page).
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 (!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)
setActivePageId((prev) => {
const firstId = t[0]?.id ?? null
if (prev != null && t.some((p) => p.id === prev)) return prev
return firstId
})
}, [recollectionId])
// Persist tree when sidebar changes it.
useEffect(() => {
if (!recollectionId || tree.length === 0) return
setLogosPageTree(recollectionId, tree)
}, [recollectionId, tree])
const handleTreeChange = useCallback((newTree: LogosPageMeta[]) => {
setTree(newTree)
}, [])
const handleDeletePage = useCallback(
(pageId: LogosPageId) => {
if (recollectionId) removeLogosPageContent(recollectionId, pageId)
},
[recollectionId]
)
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)
// BlockNote requires a non-empty array of blocks; use undefined for default empty doc.
if (!content || !Array.isArray(content) || content.length === 0) return undefined
return content
}, [recollectionId, activePageId, reloadKey])
@@ -160,7 +125,10 @@ export function LogosPage() {
}, [editor, recollectionId, activePageId, persistContent])
useEffect(() => {
if (!editor || !recollectionId || !activePageId) return
if (isKatalogosView || !editor || !recollectionId || !activePageId) {
if (isKatalogosView) setLogosSlot(null)
return
}
const slot = {
saveStatus,
onSave,
@@ -173,7 +141,7 @@ export function LogosPage() {
}
setLogosSlot(slot)
return () => setLogosSlot(null)
}, [setLogosSlot, saveStatus, onSave, editor, recollectionId, activePageId])
}, [isKatalogosView, setLogosSlot, saveStatus, onSave, editor, recollectionId, activePageId])
const activePage = tree.find((p) => p.id === activePageId)
@@ -205,7 +173,6 @@ export function LogosPage() {
[editor]
)
// Render paths (no hooks below this point).
if (!recollectionId) {
return (
<div className="flex h-full min-h-0 flex-1 flex-col items-center justify-center p-8 text-muted-foreground">
@@ -214,48 +181,33 @@ export function LogosPage() {
)
}
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 bg-background">
<LogosSidebar
recollectionId={recollectionId}
tree={tree}
onTreeChange={handleTreeChange}
activePageId={activePageId}
onSelectPage={setActivePageId}
onDeletePage={handleDeletePage}
/>
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{tree.length === 0 ? 'Loading…' : 'Select a page'}
</div>
<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-row bg-background">
<LogosSidebar
recollectionId={recollectionId}
tree={tree}
onTreeChange={handleTreeChange}
activePageId={activePageId}
onSelectPage={setActivePageId}
onDeletePage={handleDeletePage}
/>
<div className="flex min-w-0 flex-1 flex-col overflow-auto">
<div className="mx-auto w-full max-w-3xl p-4">
<h1 className="mb-4 truncate font-serif text-2xl font-semibold tracking-tight text-foreground">
{activePage?.title ?? recollection?.name ?? 'Untitled'}
</h1>
<BlockNoteViewWrapper
editor={editor as any}
theme={theme}
className="min-h-full w-full"
slashMenu={false}
>
<SuggestionMenuController triggerCharacter="/" getItems={getSlashMenuItems} />
</BlockNoteViewWrapper>
</div>
<div className="flex h-full min-h-0 flex-1 flex-col overflow-auto bg-background">
<div className="mx-auto w-full max-w-3xl p-4">
<BlockNoteViewWrapper
editor={editor as any}
theme={theme}
className="min-h-full w-full"
slashMenu={false}
>
<SuggestionMenuController triggerCharacter="/" getItems={getSlashMenuItems} />
</BlockNoteViewWrapper>
</div>
</div>
)

View File

@@ -1,480 +0,0 @@
/**
* Logos sidebar: one-level page/subpage hierarchy. Create, rename, delete, reorder.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
ChevronDown,
ChevronRight,
FileText,
MoreHorizontal,
Pencil,
Plus,
Trash2,
ArrowUp,
ArrowDown,
} from 'lucide-react'
import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore'
import { cn } from '@/lib/utils'
const DEFAULT_PAGE_TITLE = 'Untitled'
function generatePageId(): LogosPageId {
return `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function sortByPosition(a: LogosPageMeta, b: LogosPageMeta) {
return a.position - b.position
}
export type LogosSidebarProps = {
recollectionId: string
tree: LogosPageMeta[]
onTreeChange: (tree: LogosPageMeta[]) => void
activePageId: LogosPageId | null
onSelectPage: (id: LogosPageId) => void
onDeletePage?: (pageId: LogosPageId) => void
}
export function LogosSidebar({
recollectionId,
tree,
onTreeChange,
activePageId,
onSelectPage,
onDeletePage,
}: LogosSidebarProps) {
const [expandedPages, setExpandedPages] = useState<Set<LogosPageId>>(new Set())
const expandedInitializedRef = useRef(false)
const [editingId, setEditingId] = useState<LogosPageId | null>(null)
const [editTitle, setEditTitle] = useState('')
const [deleteTarget, setDeleteTarget] = useState<LogosPageMeta | null>(null)
const pages = tree.filter((p) => p.parentId === null).sort(sortByPosition)
// Default: expand parent pages that have subpages so children are visible on first load.
useEffect(() => {
if (tree.length === 0 || expandedInitializedRef.current) return
expandedInitializedRef.current = true
const parentIdsWithChildren = tree
.filter((p) => p.parentId === null && tree.some((s) => s.parentId === p.id))
.map((p) => p.id)
if (parentIdsWithChildren.length > 0) {
setExpandedPages((prev) => {
const next = new Set(prev)
parentIdsWithChildren.forEach((id) => next.add(id))
return next
})
}
}, [tree])
const toggleExpanded = useCallback((id: LogosPageId) => {
setExpandedPages((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const updatePage = useCallback(
(id: LogosPageId, patch: Partial<Pick<LogosPageMeta, 'title' | 'position'>>) => {
const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p))
onTreeChange(next)
},
[tree, onTreeChange]
)
const addPage = useCallback(() => {
const maxPos = Math.max(0, ...pages.map((p) => p.position), -1)
const newPage: LogosPageMeta = {
id: generatePageId(),
title: DEFAULT_PAGE_TITLE,
parentId: null,
position: maxPos + 1,
}
onTreeChange([...tree, newPage])
setEditingId(newPage.id)
setEditTitle(newPage.title)
onSelectPage(newPage.id)
}, [tree, pages, onTreeChange, onSelectPage])
const addSubpage = useCallback(
(parentId: LogosPageId) => {
const siblings = tree.filter((p) => p.parentId === parentId).sort(sortByPosition)
const maxPos = siblings.length === 0 ? 0 : Math.max(...siblings.map((p) => p.position)) + 1
const newPage: LogosPageMeta = {
id: generatePageId(),
title: DEFAULT_PAGE_TITLE,
parentId,
position: maxPos,
}
onTreeChange([...tree, newPage])
setExpandedPages((prev) => new Set(prev).add(parentId))
setEditingId(newPage.id)
setEditTitle(newPage.title)
onSelectPage(newPage.id)
},
[tree, onTreeChange, onSelectPage]
)
const startRename = useCallback((page: LogosPageMeta) => {
setEditingId(page.id)
setEditTitle(page.title)
}, [])
const commitRename = useCallback(() => {
if (editingId && editTitle.trim()) {
updatePage(editingId, { title: editTitle.trim() })
}
setEditingId(null)
setEditTitle('')
}, [editingId, editTitle, updatePage])
const movePage = useCallback(
(id: LogosPageId, delta: number) => {
const page = tree.find((p) => p.id === id)
if (!page) return
const siblings = tree
.filter((p) => p.parentId === page.parentId)
.sort(sortByPosition)
const idx = siblings.findIndex((p) => p.id === id)
if (idx < 0) return
const newIdx = Math.max(0, Math.min(siblings.length - 1, idx + delta))
if (newIdx === idx) return
const reordered = siblings.slice()
const [removed] = reordered.splice(idx, 1)
reordered.splice(newIdx, 0, removed)
const withNewPositions = tree.map((p) => {
const i = reordered.findIndex((r) => r.id === p.id)
return i >= 0 ? { ...p, position: i } : p
})
onTreeChange(withNewPositions)
},
[tree, onTreeChange]
)
const removePage = useCallback(
(page: LogosPageMeta) => {
const toRemove = [page.id, ...tree.filter((p) => p.parentId === page.id).map((p) => p.id)]
onTreeChange(tree.filter((p) => !toRemove.includes(p.id)))
toRemove.forEach((id) => onDeletePage?.(id))
if (activePageId && toRemove.includes(activePageId)) {
const remaining = tree.filter((p) => !toRemove.includes(p.id)).sort(sortByPosition)
const first = remaining[0]
if (first) onSelectPage(first.id)
}
setDeleteTarget(null)
},
[tree, activePageId, onTreeChange, onDeletePage, onSelectPage]
)
const subpages = (parentId: LogosPageId) =>
tree.filter((p) => p.parentId === parentId).sort(sortByPosition)
const isPage = (p: LogosPageMeta) => p.parentId === null
return (
<div className="flex h-full w-64 shrink-0 flex-col border-r border-border bg-muted/30">
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-2 py-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Pages
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={addPage}
>
<Plus className="size-3.5" />
New page
</Button>
</div>
<div className="flex-1 overflow-y-auto py-1">
{pages.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
No pages yet.
<br />
<Button
type="button"
variant="link"
className="h-auto p-0 text-xs"
onClick={addPage}
>
Create your first page
</Button>
</div>
) : (
<ul className="flex flex-col gap-0.5 px-1">
{pages.map((page) => {
const subs = subpages(page.id)
const expanded = expandedPages.has(page.id)
const isActive = activePageId === page.id
return (
<li key={page.id} className="flex flex-col gap-0.5">
<PageRow
page={page}
isSubpage={false}
isActive={isActive}
isEditing={editingId === page.id}
editTitle={editTitle}
onEditTitleChange={setEditTitle}
onCommitRename={commitRename}
onStartRename={() => startRename(page)}
onSelect={() => onSelectPage(page.id)}
onExpandToggle={() => toggleExpanded(page.id)}
expanded={expanded}
hasSubpages={subs.length > 0}
onAddSubpage={() => addSubpage(page.id)}
onMoveUp={() => movePage(page.id, -1)}
onMoveDown={() => movePage(page.id, 1)}
onDelete={() => setDeleteTarget(page)}
canMoveUp={pages.indexOf(page) > 0}
canMoveDown={pages.indexOf(page) < pages.length - 1}
/>
{expanded && (
<ul className="ml-3 flex flex-col gap-0.5 border-l border-border/60 pl-2">
{subs.map((sub, i) => {
const subActive = activePageId === sub.id
const subEditing = editingId === sub.id
return (
<li key={sub.id}>
<PageRow
page={sub}
isSubpage
isActive={subActive}
isEditing={subEditing}
editTitle={editTitle}
onEditTitleChange={setEditTitle}
onCommitRename={commitRename}
onStartRename={() => startRename(sub)}
onSelect={() => onSelectPage(sub.id)}
onAddSubpage={undefined}
onMoveUp={() => movePage(sub.id, -1)}
onMoveDown={() => movePage(sub.id, 1)}
onDelete={() => setDeleteTarget(sub)}
canMoveUp={i > 0}
canMoveDown={i < subs.length - 1}
/>
</li>
)
})}
</ul>
)}
</li>
)
})}
</ul>
)}
</div>
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{deleteTarget && isPage(deleteTarget)
? 'Delete page and subpages?'
: 'Delete subpage?'}
</DialogTitle>
</DialogHeader>
{deleteTarget && (
<p className="text-sm text-muted-foreground">
{isPage(deleteTarget) && subpages(deleteTarget.id).length > 0 ? (
<>
&quot;{deleteTarget.title}&quot; and its {subpages(deleteTarget.id).length}{' '}
subpage(s) will be permanently removed.
</>
) : (
<> &quot;{deleteTarget.title}&quot; will be permanently removed.</>
)}
</p>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={() => deleteTarget && removePage(deleteTarget)}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
type PageRowProps = {
page: LogosPageMeta
isSubpage: boolean
isActive: boolean
isEditing: boolean
editTitle: string
onEditTitleChange: (v: string) => void
onCommitRename: () => void
onStartRename: () => void
onSelect: () => void
onExpandToggle?: () => void
expanded?: boolean
hasSubpages?: boolean
onAddSubpage?: () => void
onMoveUp: () => void
onMoveDown: () => void
onDelete: () => void
canMoveUp: boolean
canMoveDown: boolean
}
function PageRow({
page,
isSubpage,
isActive,
isEditing,
editTitle,
onEditTitleChange,
onCommitRename,
onStartRename,
onSelect,
onExpandToggle,
expanded,
hasSubpages,
onAddSubpage,
onMoveUp,
onMoveDown,
onDelete,
canMoveUp,
canMoveDown,
}: PageRowProps) {
const isParentRow = onExpandToggle != null
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') onCommitRename()
if (e.key === 'Escape') {
onEditTitleChange(page.title)
onCommitRename()
}
}
return (
<div
className={cn(
'group flex items-center gap-0.5 rounded-md pr-1',
isActive && 'bg-accent text-accent-foreground'
)}
>
{isParentRow ? (
// VSCode-like "twisty gutter" so labels align nicely.
<span className="flex h-8 w-4 shrink-0 items-center justify-center">
{hasSubpages ? (
<button
type="button"
className="flex size-4 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
onExpandToggle?.()
}}
aria-expanded={expanded}
aria-label={expanded ? 'Collapse subpages' : 'Expand subpages'}
>
{expanded ? (
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronRight className="size-3.5 shrink-0" />
)}
</button>
) : (
<span className="inline-block size-4" aria-hidden />
)}
</span>
) : isSubpage ? (
<span className="flex h-8 w-4 shrink-0 items-center justify-center">
<span className="inline-block size-4" aria-hidden />
</span>
) : null}
{isSubpage && <span className="w-2 shrink-0" />}
{isEditing ? (
<input
type="text"
className="h-7 min-w-0 flex-1 rounded border border-input bg-background px-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={editTitle}
onChange={(e) => onEditTitleChange(e.target.value)}
onBlur={onCommitRename}
onKeyDown={handleKeyDown}
autoFocus
aria-label="Page title"
/>
) : (
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1.5 py-1.5 text-left text-sm hover:bg-accent/80"
onClick={onSelect}
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{page.title || DEFAULT_PAGE_TITLE}</span>
</button>
)}
{!isEditing && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-hover:opacity-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-3.5" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem onClick={() => { onStartRename(); onSelect(); }}>
<Pencil className="mr-2 size-3.5" />
Rename
</DropdownMenuItem>
{onAddSubpage != null && (
<DropdownMenuItem onClick={onAddSubpage}>
<Plus className="mr-2 size-3.5" />
New subpage
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onMoveUp} disabled={!canMoveUp}>
<ArrowUp className="mr-2 size-3.5" />
Move up
</DropdownMenuItem>
<DropdownMenuItem onClick={onMoveDown} disabled={!canMoveDown}>
<ArrowDown className="mr-2 size-3.5" />
Move down
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 size-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)
}

View File

@@ -10,7 +10,7 @@ const fluxOutputBlock = createFluxOutputBlock()
export const logosSchema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
fluxOutput: fluxOutputBlock(),
fluxOutput: fluxOutputBlock,
},
})

View File

@@ -73,6 +73,66 @@ 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))
@@ -95,6 +155,10 @@ 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))
@@ -167,6 +231,10 @@ export function removeLogosPageContent(recollectionId: string, pageId: LogosPage
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 {
@@ -198,6 +266,10 @@ export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOut
)
}
// ---------------------------------------------------------------------------
// Remove Recollection Data
// ---------------------------------------------------------------------------
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */
export function removeRecollectionData(recollectionId: string): void {
localStorage.removeItem(getGraphKey(recollectionId))

View File

@@ -9,6 +9,13 @@ import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/g
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike } from '@/lib/graph/templateRefs'
// Module-level cache for resolved Nunjucks output.
// Keyed by a fingerprint of all inputs (template contents + variable/data values + function bodies).
// Avoids re-running the Nunjucks environment when the same inputs are seen again (e.g. undo/redo,
// multiple rendering nodes sharing the same config, rapid edits cycling back to a prior value).
const MAX_RESOLVE_CACHE = 200
const resolveCache = new Map<string, ResolvedContentResult>()
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
const { nodes, edges, sourceNodeId, renderNodeId } = context
const srcId = sourceNodeId
@@ -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 formatFilterResult = (r: unknown): string => {
@@ -248,7 +273,13 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
return
}
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
resolve({ resolved, outputTypeId })
const result: ResolvedContentResult = { resolved, outputTypeId }
// FIFO eviction when cache is full
if (resolveCache.size >= MAX_RESOLVE_CACHE) {
resolveCache.delete(resolveCache.keys().next().value as string)
}
resolveCache.set(cacheKey, result)
resolve(result)
})
})
}

View File

@@ -2,9 +2,61 @@
* runs the resolve → render pipeline, manages streaming/cache,
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
* pipeline interface.
*
* ## Auto vs Manual Mode Decision Tree
*
* The hook supports two update modes: 'auto' and 'manual'. The mode is determined by:
*
* 1. **Node data override**: Check `data?.updateMode` first
* 2. **Source logic default**: Fall back to `sourceLogic?.defaultUpdateMode`
* 3. **Hardcoded default**: Default to 'auto' if neither is set
*
* ### Auto Mode Behavior
* - Runs automatically when upstream data changes (sourceSignature changes)
* - Debounces runs by 250ms to avoid excessive computation
* - Skips runs when node becomes visible with cache (visibility grace period: 400ms)
* - Updates path trigger on each run
*
* ### Manual Mode Behavior
* - Only runs when `runTrigger` increments (user clicks "Run")
* - Requires explicit user action to update output
* - Caches last run signature to prevent re-runs on unrelated changes
*
* ## Effect Dependencies
*
* The main effect depends on:
* - `id`: Node ID (re-run when node changes)
* - `srcId`: Source node ID (re-run when connection changes)
* - `srcNode?.type`: Source node type (re-run when source type changes)
* - `effectiveUpdateMode`: Auto vs manual mode
* - `runTrigger`: Run trigger counter (for manual mode)
* - `sourceSignature`: Combined signature of all upstream nodes
* - `viewportWidth/Height`: Viewport dimensions (affects rendering)
* - `retryCount`: Retry counter (for error recovery)
* - `incomingIds.length`: Number of incoming connections
*
* ## Rendering Pipeline
*
* 1. **Resolve**: Source node (config/agent) produces resolved content
* 2. **Render**: Output type renderer (plantuml/markdown/wireframe) produces HTML/SVG
* 3. **Cache**: Results are cached in node data for persistence
* 4. **Display**: UI consumes the rendered content
*
* ## Streaming Support
*
* When the source node supports streaming (e.g., agent nodes):
* - `onStreamingStart`: Called when streaming begins
* - `onStreamingChunk`: Called for each chunk of markdown content
* - Streaming content is parsed with marked.js for preview
*
* ## Error Handling
*
* Errors during resolve or render are caught and stored in the `error` state.
* The error includes a `kind` (e.g., 'render', 'no-content') and `message`.
*/
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAbstractNode } from '@/lib/graph/abstractNode'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
@@ -124,8 +176,11 @@ export function useRenderingNodeState(
// 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.
const storeNodes = useCanvasStore((s) => s.graph.nodes)
const storeEdges = useCanvasStore((s) => s.graph.edges)
// 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

View File

@@ -758,6 +758,7 @@ export {
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
sidebarMenuButtonVariants,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,

View 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
}
}

View 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])
}

View 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'
}

View 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
}
}

View File

@@ -53,6 +53,13 @@ export type ConfigType = {
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
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[] = [
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
@@ -172,36 +179,56 @@ const BUILTIN_CONFIG_TYPES: ConfigType[] = [
language: 'plantuml',
insertBlocks: PLANTUML_INSERT_BLOCKS,
render: async (content: string) => {
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}`)
}
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
// 1. TTL cache hit
const cached = krokiCache.get(content)
if (cached && Date.now() - cached.cachedAt < KROKI_CACHE_TTL_MS) {
return cached.result
}
// 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: {
submenuLabel: 'Export',

View File

@@ -0,0 +1,182 @@
/**
* Node factory utilities for creating and managing nodes.
* Centralizes node creation logic to reduce duplication.
*/
import type { Node, Edge } from '@xyflow/react'
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
import {
getDefaultDataForType,
getNextNodeId,
} from '@/lib/graph/flowUtils'
import { getRegisteredNodeTypeIds, getDefaultStyle } from '@/lib/graph/nodeRegistry'
/**
* Create a new node with auto-generated ID and default data.
*
* @param type - Node type ID (e.g., 'config', 'render')
* @param position - Node position { x, y }
* @param data - Optional additional data to merge with defaults
* @returns New node with generated ID
*/
export function createNode(
type: string,
position: { x: number; y: number },
data?: Record<string, unknown>
): AppNode {
const existingIds = getRegisteredNodeTypeIds()
const newId = getNextNodeId(type, existingIds)
return {
id: newId,
type,
position,
data: { ...getDefaultDataForType(type, newId), ...(data ?? {}) },
style: getDefaultStyle(type),
}
}
/**
* Create a new node with a specific ID.
* Use this when you need to control the node ID (e.g., when duplicating).
*
* @param type - Node type ID
* @param id - Node ID to use
* @param position - Node position { x, y }
* @param data - Optional additional data to merge with defaults
* @returns New node with specified ID
*/
export function createNodeWithId(
type: string,
id: string,
position: { x: number; y: number },
data?: Record<string, unknown>
): AppNode {
return {
id,
type,
position,
data: { ...getDefaultDataForType(type, id), ...(data ?? {}) },
style: getDefaultStyle(type),
}
}
/**
* Create a new edge connecting two nodes.
*
* @param source - Source node ID
* @param target - Target node ID
* @param id - Optional edge ID (auto-generated if not provided)
* @param data - Optional edge data
* @returns New edge
*/
export function createEdge(
source: string,
target: string,
id?: string,
data?: Record<string, unknown>
): AppEdge {
return {
id: id ?? `e-${source}-${target}`,
source,
target,
data: data ?? {},
}
}
/**
* Create multiple nodes from a template.
*
* @param template - Template node to copy
* @param count - Number of nodes to create
* @param offset - Position offset between nodes { x, y }
* @returns Array of new nodes
*/
export function createNodesFromTemplate(
template: Partial<AppNode>,
count: number,
offset: { x: number; y: number }
): AppNode[] {
const nodes: AppNode[] = []
for (let i = 0; i < count; i++) {
const position = {
x: (template.position?.x ?? 0) + i * offset.x,
y: (template.position?.y ?? 0) + i * offset.y,
}
nodes.push({
id: template.id ?? getNextNodeId(template.type ?? 'node', nodes.map((n) => n.id)),
type: template.type ?? 'node',
position,
data: { ...template.data },
style: template.style ?? getDefaultStyle(template.type ?? 'node'),
})
}
return nodes
}
/**
* Get existing node IDs for a specific type.
*
* @param nodes - Array of nodes to search
* @param type - Node type to filter by
* @returns Array of node IDs for the specified type
*/
export function getNodeIdsByType(nodes: AppNode[], type: string): string[] {
return nodes.filter((n) => n.type === type).map((n) => n.id)
}
/**
* Get the next available node ID for a type based on existing nodes.
*
* @param type - Node type
* @param existingNodes - Array of existing nodes
* @returns Next available node ID
*/
export function getNextNodeIdForType(type: string, existingNodes: AppNode[]): string {
const existingIds = existingNodes.map((n) => n.id)
return getNextNodeId(type, existingIds)
}
/**
* Duplicate a node with a new ID and offset position.
*
* @param node - Node to duplicate
* @param offset - Position offset for the duplicate
* @returns Duplicated node
*/
export function duplicateNode(node: AppNode, offset: { x: number; y: number } = { x: 30, y: 30 }): AppNode {
const existingIds = [node.id] // Start with current node ID
const newId = getNextNodeId(node.type ?? 'node', existingIds)
const pos = node.position ?? { x: 0, y: 0 }
return {
id: newId,
type: node.type ?? 'node',
position: { x: pos.x + offset.x, y: pos.y + offset.y },
data: typeof node.data === 'object' && node.data !== null ? { ...(node.data as object) } : node.data,
style: node.style ?? getDefaultStyle(node.type ?? 'node'),
}
}
/**
* Create a connection between two nodes.
*
* @param sourceNodeId - Source node ID
* @param sourceHandle - Source handle ID (optional)
* @param targetNodeId - Target node ID
* @param targetHandle - Target handle ID (optional)
* @returns Edge object connecting the nodes
*/
export function createConnection(
sourceNodeId: string,
sourceHandle?: string,
targetNodeId: string = '',
targetHandle?: string
): Edge {
const edge: Edge = {
id: `e-${sourceNodeId}-${targetNodeId}`,
source: sourceNodeId,
target: targetNodeId,
sourceHandle,
targetHandle,
}
return edge
}

View File

@@ -0,0 +1,48 @@
/**
* SVG detection utilities.
* Centralized logic for detecting and processing 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()))
}
/** Check if a file path or URL points to an SVG file. */
export function isSvgPath(path: string | null | undefined): boolean {
return Boolean(path?.toLowerCase().endsWith('.svg'))
}
/** Process SVG HTML for viewport display (aspect ratio, fill container). */
export function processSvgDisplay(html: string): string {
let out = html
// Change preserveAspectRatio from 'none' to 'xMidYMid meet' for proper scaling
out = out.replace(/\bpreserveAspectRatio\s*=\s*["']none["']/gi, 'preserveAspectRatio="xMidYMid meet"')
// Set width to 100% to fill container
out = out.replace(/\bwidth\s*=\s*["'][^"']*["']/i, 'width="100%"')
// Set height to 100% to fill container
out = out.replace(/\bheight\s*=\s*["'][^"']*["']/i, 'height="100%"')
// Override width/height in style attributes
out = out.replace(/\bstyle\s*=\s*["']([^"']*)["']/i, (_, style) => {
const overridden = style.replace(/\b(width|height):[^;]+/gi, '$1:100%')
return `style="${overridden}"`
})
return out
}
/** Extract SVG content from an HTML string. */
export function extractSvgContent(html: string): string | null {
const svgMatch = html.match(/<svg[\s\S]*?<\/svg>/i)
return svgMatch ? svgMatch[0] : null
}
/** Check if content is an SVG data URL. */
export function isSvgDataUrl(content: string | null | undefined): boolean {
return Boolean(content?.trim().startsWith('data:image/svg+xml'))
}
/** Convert SVG string to data URL. */
export function svgToDataUrl(svg: string): string {
const encoded = btoa(unescape(encodeURIComponent(svg)))
return `data:image/svg+xml;base64,${encoded}`
}

View File

@@ -1,21 +1,25 @@
import React from 'react'
import React, { lazy, Suspense } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { Toaster } from 'sonner'
import { ThemeProvider } from './lib/themeContext'
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
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 { LogosPage } from './app/recollections/logos/LogosPage'
import { FluxRoute } from './app/recollections/flux/FluxRoute'
import { KatalogosPage } from './app/recollections/katalogos/KatalogosPage'
import { NotFoundPage } from './app/NotFoundPage'
import './lib/prismSetup'
import 'prismjs/themes/prism.css'
import './styles.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()
registerBuiltinNodes()
@@ -23,6 +27,7 @@ createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<BrowserRouter>
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground text-sm">Loading</div>}>
<Routes>
<Route path="/" element={<KosmosPage />}>
<Route index element={<Navigate to="/recollections" replace />} />
@@ -30,11 +35,14 @@ createRoot(document.getElementById('root')!).render(
<Route path="recollections/:recollectionId" element={<RecollectionLayout />}>
<Route index element={<Navigate to="logos" replace />} />
<Route path="logos" element={<LogosPage />} />
<Route path="katalogos" element={<KatalogosPage />} />
<Route path="logos/katalogos" element={<LogosPage />} />
<Route path="flux" element={<FluxRoute />} />
<Route path="*" element={<Navigate to="logos" replace />} />
</Route>
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</BrowserRouter>
<Toaster richColors position="bottom-right" />
</ThemeProvider>

View File

@@ -1,5 +1,4 @@
@import "shadcn/dist/tailwind.css";
@import "@blocknote/core/fonts/inter.css";
@import "@blocknote/shadcn/style.css";
@tailwind base;
@@ -374,18 +373,23 @@ pre {
.token.nunjucks-var {
color: #7c3aed;
}
.token.nunjucks-tag {
color: #c2410c;
}
.token.nunjucks-comment {
color: #0d9488;
}
.dark .token.nunjucks-var {
color: #a78bfa;
}
.dark .token.nunjucks-tag {
color: #fb923c;
}
.dark .token.nunjucks-comment {
color: #2dd4bf;
}
@@ -397,38 +401,75 @@ pre {
background: none;
}
/* BlockNote (Logos editor): font sizes to match app typography (Tailwind text-smtext-lg scale) */
.logos-blocknote .bn-editor {
font-size: 0.875rem; /* text-sm */
line-height: 1.5;
/* Logos tree drop line (LogosDropCursor in TreeBrowser.tsx) */
@keyframes tree-drop-line-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.logos-blocknote .bn-inline-content {
font-size: inherit;
line-height: inherit;
}
.logos-blocknote [data-content-type="heading"][data-level="1"] .bn-inline-content {
font-size: 1.25rem; /* text-xl */
font-weight: 600;
line-height: 1.3;
}
.logos-blocknote [data-content-type="heading"][data-level="2"] .bn-inline-content {
font-size: 1.125rem; /* text-lg */
font-weight: 600;
line-height: 1.35;
}
.logos-blocknote [data-content-type="heading"][data-level="3"] .bn-inline-content {
font-size: 1rem; /* text-base */
font-weight: 600;
line-height: 1.4;
}
.logos-blocknote [data-content-type="paragraph"] .bn-inline-content {
/* React Arborist tree styling for sidebar integration */
.react-arborist-tree {
background: transparent !important;
font-size: 0.875rem;
}
.logos-blocknote [data-content-type="bulletListItem"] .bn-inline-content,
.logos-blocknote [data-content-type="numberedListItem"] .bn-inline-content {
font-size: 0.875rem;
.react-arborist-tree * {
outline: none !important;
}
.logos-blocknote [data-content-type="codeBlock"] .bn-inline-content,
.logos-blocknote [data-content-type="code"] .bn-inline-content {
font-size: 0.8125rem; /* slightly smaller for code */
/* Remove default react-arborist styling */
.react-arborist-tree [role="treeitem"] {
cursor: pointer;
}
/* Drag preview styling */
.react-arborist-tree [data-react-arborist-drag-preview] {
background: hsl(var(--sidebar-accent)) !important;
border: 1px solid hsl(var(--sidebar-border)) !important;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
opacity: 0.9;
}
/* Drop cursor line */
.react-arborist-tree [data-react-arborist-drop-cursor] {
background: hsl(var(--sidebar-primary)) !important;
height: 2px !important;
border-radius: 1px;
z-index: 10;
}
/* Make rows draggable */
.react-arborist-tree [draggable="true"] {
cursor: grab;
}
.react-arborist-tree [draggable="true"]:active {
cursor: grabbing;
}
/* Drag handle: restore focus ring (tree uses outline:none on all descendants) */
.react-arborist-tree .logos-tree-drag-handle:focus-visible {
outline: 2px solid hsl(var(--sidebar-ring)) !important;
outline-offset: 2px;
box-shadow:
0 0 0 2px hsl(var(--sidebar-background)),
0 2px 8px hsl(var(--sidebar-ring) / 0.25);
}
/* Hide default react-arborist backgrounds */
.react-arborist-tree [data-react-arborist-tree] {
background: transparent !important;
}
.react-arborist-tree [data-react-arborist-list-item] {
background: transparent !important;
}