Compare commits
5 Commits
bc67d01bc2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 223336c606 | |||
| cbd8f1568b | |||
| 166c7b0b7f | |||
| 2635b45973 | |||
| c3cbe35883 |
216
ARCHITECTURE.md
Normal file
216
ARCHITECTURE.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# Architecture Overview
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
Zui is a node-based visual editor for composing configs, templates, variables, and AI-generated content. The frontend is a React 18 SPA backed by a thin Express API for AI agent calls. All graph data is persisted in `localStorage`; the backend is stateless.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technology Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
| --- | --- |
|
||||||
|
| Frontend framework | React 18, TypeScript (strict), Vite 7 |
|
||||||
|
| Graph canvas | `@xyflow/react` v12 (React Flow) |
|
||||||
|
| State management | Zustand 5 (command/reducer pattern) |
|
||||||
|
| Styling | Tailwind CSS + Shadcn UI (`@radix-ui/*`) |
|
||||||
|
| Rich text | BlockNote 0.36 with custom blocks |
|
||||||
|
| Templating | Nunjucks |
|
||||||
|
| Graph traversal | `graphology` + `graphology-traversal` |
|
||||||
|
| Routing | React Router v6 |
|
||||||
|
| Backend | Node.js 20+, TypeScript, Express 4 |
|
||||||
|
| AI SDK | Vercel AI SDK v4 + `@ai-sdk/openai` |
|
||||||
|
| Testing | Vitest, React Testing Library |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Project Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
frontend/src/
|
||||||
|
├── main.tsx # Entry: registers node types & config types, mounts app
|
||||||
|
├── app/
|
||||||
|
│ ├── canvas/ # CanvasPage, canvasStore (Zustand), canvasStore.test.ts
|
||||||
|
│ ├── kosmos/ # KosmosPage, sidebar, AI settings, KosmosContext
|
||||||
|
│ └── recollections/
|
||||||
|
│ ├── RecollectionsPage # Workspace list
|
||||||
|
│ ├── RecollectionLayout # Shared layout (sidebar + outlet)
|
||||||
|
│ ├── flux/ # Flux view (graph canvas)
|
||||||
|
│ ├── logos/ # Logos view (BlockNote rich text)
|
||||||
|
│ └── katalogos/ # Katalogos view (artifact gallery)
|
||||||
|
├── components/
|
||||||
|
│ ├── graph/ # AnimatedEdge, BaseNode, handles, keyboard shortcuts
|
||||||
|
│ ├── nodes/ # One folder per node type
|
||||||
|
│ └── ui/ # Shadcn primitives
|
||||||
|
├── hooks/ # useGraphStateWithHistory, useResizeHeight, …
|
||||||
|
└── lib/
|
||||||
|
└── graph/
|
||||||
|
├── nodeRegistry.ts # Mutable plugin registry
|
||||||
|
├── registerBuiltinNodes.tsx
|
||||||
|
├── nodeTypeBuilder.ts # Fluent builder for node descriptors
|
||||||
|
├── configTypes.ts # Output type registry (plantuml, markdown, wireframe)
|
||||||
|
├── rendering.ts # 3-step rendering pipeline contract
|
||||||
|
├── state.ts # Graph state types
|
||||||
|
└── canvasStore/ # Reducer + selectors
|
||||||
|
|
||||||
|
backend/src/
|
||||||
|
├── index.ts # Express app, CORS, routes, error handler
|
||||||
|
├── routes/agentRoutes.ts # POST /api/agent, POST /api/agent/stream
|
||||||
|
├── services/agentService.ts # LLM call logic (Vercel AI SDK)
|
||||||
|
├── repositories/cacheRepository.ts
|
||||||
|
├── models/index.ts
|
||||||
|
└── middleware/rateLimiter.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Key Architectural Patterns
|
||||||
|
|
||||||
|
### 4.1 Command / Reducer Store (canvasStore)
|
||||||
|
|
||||||
|
The Zustand canvas store uses a pure reducer (`canvasStoreReducer`) with a discriminated union of `CanvasCommand` types. All mutations go through `dispatch(command)`. In dev mode every command is logged to the console.
|
||||||
|
|
||||||
|
The store has three slices:
|
||||||
|
|
||||||
|
- **`graph`** — nodes and edges (the React Flow state)
|
||||||
|
- **`path`** — trigger pulse for edge animation
|
||||||
|
- **`ui`** — renaming state, fullscreen node, pending connection
|
||||||
|
|
||||||
|
### 4.2 Delta-based Undo / Redo
|
||||||
|
|
||||||
|
`useGraphStateWithHistory` computes *inverse deltas* (only the nodes/edges that changed) rather than snapshotting the full graph. Maximum 100 history entries. Drag operations commit a pre-drag snapshot on `onNodeDragStop`.
|
||||||
|
|
||||||
|
### 4.3 Node Plugin Registry
|
||||||
|
|
||||||
|
Node types are registered via a mutable registry in `nodeRegistry.ts`. The built-ins are registered at app init from `registerBuiltinNodes.tsx`. Adding a new node type requires:
|
||||||
|
|
||||||
|
1. Implement a React component for the node.
|
||||||
|
2. Create a descriptor using `NodeTypeBuilder`.
|
||||||
|
3. Call `registerNodeType(descriptor)` — no edits to core code.
|
||||||
|
|
||||||
|
### 4.4 Fluent Builder for Node Descriptors
|
||||||
|
|
||||||
|
`NodeTypeBuilder` provides a type-safe method-chaining API (`idPrefix`, `withInputOutput`, `classification`, `allowedSourceTypes`, `help`, `menu`, `withFullscreen`, `sourceRenderingLogic`, …). Required fields are validated at `.build()`.
|
||||||
|
|
||||||
|
### 4.5 Three-Step Rendering Pipeline
|
||||||
|
|
||||||
|
Defined as an explicit contract in `rendering.ts`:
|
||||||
|
|
||||||
|
1. **Resolve** — source node (Config/Agent) produces `{ resolved, outputTypeId, reasoning? }`
|
||||||
|
2. **Render** — output type handler converts the resolved string (PlantUML → Kroki SVG, Markdown → HTML, Wireframe → SVG)
|
||||||
|
3. **Display** — the Rendering node shows an image viewport or scrollable HTML
|
||||||
|
|
||||||
|
### 4.6 Node Classification System
|
||||||
|
|
||||||
|
Four metaphorical classes drive the create menu grouping:
|
||||||
|
|
||||||
|
| Class | Node type | Metaphor |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `psyche` | Variable | source / input |
|
||||||
|
| `physis` | Data (CSV) | physical data |
|
||||||
|
| `pneuma` | Config / Render | transformation |
|
||||||
|
| `archon` | Agent | AI / LLM |
|
||||||
|
|
||||||
|
### 4.7 Recollections (Workspaces)
|
||||||
|
|
||||||
|
Each workspace (recollection) is stored in `localStorage`:
|
||||||
|
|
||||||
|
| Key | Content |
|
||||||
|
| --- | --- |
|
||||||
|
| `zui_platform_recollections` | Metadata index (id, name, icon, timestamps) |
|
||||||
|
| `zui_graph_<id>` | Flux graph (nodes + edges) |
|
||||||
|
| `zui_logos_<id>` | BlockNote rich text document |
|
||||||
|
| `zui_render_cache_<id>` | Rendered artifact cache |
|
||||||
|
|
||||||
|
Legacy key prefix `emanations` is migrated to `recollections` on first load.
|
||||||
|
|
||||||
|
### 4.8 User-configurable AI Connection
|
||||||
|
|
||||||
|
`KosmosContext` persists `aiConnection` (provider, baseURL, model, apiKey) in `localStorage` under `zui_ai_connection`. The Agent node forwards this to the backend with each request, so users can switch between OpenAI and local LLMs (LM Studio, Ollama) from the UI without env variable changes.
|
||||||
|
|
||||||
|
### 4.9 Diagram Rendering via Kroki
|
||||||
|
|
||||||
|
PlantUML diagrams are rendered by proxying to `https://kroki.io` via `/api/kroki/plantuml/svg`. This proxy is configured in both the Vite dev server and Nginx production config. Timeout: 15 seconds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
User interaction
|
||||||
|
→ dispatch(CanvasCommand) [canvasStore reducer]
|
||||||
|
→ Zustand state update [graph / path / ui slices]
|
||||||
|
→ React re-render [React Flow nodes/edges]
|
||||||
|
|
||||||
|
Rendering node triggered
|
||||||
|
→ Resolve source node [Config/Agent → resolved string]
|
||||||
|
→ Render output type [plantuml/markdown/wireframe]
|
||||||
|
→ Kroki proxy (PlantUML) [GET /api/kroki/plantuml/svg]
|
||||||
|
→ Agent backend (AI) [POST /api/agent or /api/agent/stream]
|
||||||
|
→ Display artifact [image / HTML / SVG viewport]
|
||||||
|
|
||||||
|
Agent node (streaming)
|
||||||
|
→ POST /api/agent/stream [Express → agentService]
|
||||||
|
→ Vercel AI SDK streams SSE [backend → frontend]
|
||||||
|
→ useStreamingContent hook consumes [updates node data]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Backend Architecture
|
||||||
|
|
||||||
|
The backend is intentionally thin and stateless. It has three responsibilities:
|
||||||
|
|
||||||
|
1. **Proxy AI calls** — forwards prompts to the configured LLM (OpenAI or OpenAI-compatible).
|
||||||
|
2. **Stream responses** — `POST /api/agent/stream` uses `pipeTextStreamToResponse` from the Vercel AI SDK.
|
||||||
|
3. **Health check** — `GET /health` for Docker/orchestration readiness probes.
|
||||||
|
|
||||||
|
The `InMemoryCache` and `rateLimiter` middleware are implemented but not yet wired into the agent routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Contribution Workflow
|
||||||
|
|
||||||
|
1. Fork the repository and clone locally.
|
||||||
|
2. Install dependencies: `cd frontend && npm install` and `cd backend && npm install`.
|
||||||
|
3. Run the dev servers (see README).
|
||||||
|
4. Create a feature branch: `git checkout -b feat/your-feature`.
|
||||||
|
5. Follow the Coding Standards (see [CONTRIBUTING.md](CONTRIBUTING.md)).
|
||||||
|
6. Submit a Pull Request with a descriptive title, linked issue(s), updated tests, and documentation changes if relevant.
|
||||||
|
|
||||||
|
### PR Review Checklist
|
||||||
|
|
||||||
|
- [ ] Readability: clear naming, minimal nesting
|
||||||
|
- [ ] TypeScript: no `any`, strict types throughout
|
||||||
|
- [ ] Performance: no unnecessary re-renders, proper memoization
|
||||||
|
- [ ] Accessibility: semantic HTML, ARIA attributes where needed
|
||||||
|
- [ ] Security: no hardcoded secrets, input validated at boundaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Coding Standards
|
||||||
|
|
||||||
|
- **TypeScript**: strict mode (`strict`, `noImplicitAny`, `noUnusedLocals`).
|
||||||
|
- **Naming**: PascalCase for components, camelCase for functions/variables, UPPER_SNAKE_CASE for constants.
|
||||||
|
- **File structure**: domain-grouped under `src/app/<domain>/` and `src/components/<category>/`.
|
||||||
|
- **Formatting**: Prettier via `frontend/prettier.config.cjs`; line length ≤ 120.
|
||||||
|
- **Comments**: JSDoc for public APIs; inline only where logic is non-obvious.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Quick Reference for AI Agents
|
||||||
|
|
||||||
|
| Topic | Location |
|
||||||
|
| --- | --- |
|
||||||
|
| Entry point | `frontend/src/main.tsx` |
|
||||||
|
| Canvas store | `frontend/src/app/canvas/canvasStore.ts` |
|
||||||
|
| Store reducer | `frontend/src/app/canvas/canvasStoreReducer.ts` |
|
||||||
|
| Node registry | `frontend/src/lib/graph/nodeRegistry.ts` |
|
||||||
|
| Node builder | `frontend/src/lib/graph/nodeTypeBuilder.ts` |
|
||||||
|
| Rendering pipeline | `frontend/src/lib/graph/rendering.ts` |
|
||||||
|
| Built-in node registration | `frontend/src/lib/graph/registerBuiltinNodes.tsx` |
|
||||||
|
| AI settings context | `frontend/src/app/kosmos/KosmosContext.tsx` |
|
||||||
|
| Backend entry | `backend/src/index.ts` |
|
||||||
|
| Agent service | `backend/src/services/agentService.ts` |
|
||||||
|
|
||||||
|
*This document is a living reference; update it when architectural decisions change.*
|
||||||
61
CONTRIBUTING.md
Normal file
61
CONTRIBUTING.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Contribution Guide
|
||||||
|
|
||||||
|
## 1. Coding Standards
|
||||||
|
|
||||||
|
- Use TypeScript with `strict` mode enabled; no `any` types.
|
||||||
|
- Import order: core libs → third-party → project aliases (`@/`) → relative.
|
||||||
|
- Naming: PascalCase for components, camelCase for functions/variables, UPPER_SNAKE_CASE for constants.
|
||||||
|
- Add JSDoc comments for all public APIs and exported types.
|
||||||
|
- Line length ≤ 120 characters; use Prettier for formatting (`frontend/prettier.config.cjs`).
|
||||||
|
- Run `npm run lint` and fix all warnings before opening a PR.
|
||||||
|
|
||||||
|
## 2. Branch Workflow
|
||||||
|
|
||||||
|
- Create a feature branch: `git checkout -b feat/<short-description>`
|
||||||
|
- Keep branches up to date with `main` via `git rebase`.
|
||||||
|
- Submit a Pull Request with a clear title and description.
|
||||||
|
- Ensure the PR passes CI (tests, lint, type-check).
|
||||||
|
- Address review comments promptly.
|
||||||
|
|
||||||
|
## 3. Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Run tests once (CI)
|
||||||
|
npm run test:run
|
||||||
|
|
||||||
|
# Type-check
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Making Your First Contribution
|
||||||
|
|
||||||
|
1. Pick a beginner-friendly issue labeled `good first issue`.
|
||||||
|
2. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the relevant module.
|
||||||
|
3. Make a small, focused change; avoid refactoring unrelated code.
|
||||||
|
4. Run the relevant tests and confirm they pass.
|
||||||
|
5. Commit and push, then open a PR with a clear description of what and why.
|
||||||
|
|
||||||
|
## 5. Where to Extend
|
||||||
|
|
||||||
|
| Goal | Where to look |
|
||||||
|
| --- | --- |
|
||||||
|
| Add a new node type | `src/lib/graph/nodeTypeBuilder.ts` + `registerBuiltinNodes.tsx` |
|
||||||
|
| Add a new output/render type | `src/lib/graph/configTypes.ts` + `rendering.ts` |
|
||||||
|
| Change canvas state shape | `src/app/canvas/canvasStore.ts` + reducer + tests |
|
||||||
|
| Add a new Logos block | `src/app/recollections/logos/logosSchema.ts` |
|
||||||
|
| Add a backend endpoint | `backend/src/routes/` → `services/` → `index.ts` |
|
||||||
|
|
||||||
|
## 6. Useful Links
|
||||||
|
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — architecture overview, key patterns, module map
|
||||||
|
- [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) — bottleneck analysis and improvement plan
|
||||||
|
- [docs/CODE_REVIEW_CHECKLIST.md](docs/CODE_REVIEW_CHECKLIST.md) — PR review checklist
|
||||||
|
- [docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md](docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md) — node plugin system design
|
||||||
|
|
||||||
|
*Thank you for contributing!*
|
||||||
108
PERFORMANCE_IMPROVEMENTS.md
Normal file
108
PERFORMANCE_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# Performance Improvements Plan
|
||||||
|
|
||||||
|
## 1. Current State
|
||||||
|
|
||||||
|
The core rendering and state management architecture is already well-structured for performance:
|
||||||
|
|
||||||
|
- **Command/reducer store** — all mutations go through a pure reducer; individual selectors can prevent unnecessary re-renders.
|
||||||
|
- **Delta-based undo/redo** — only diffs are stored, not full graph snapshots (max 100 entries).
|
||||||
|
- **Plugin node registry** — node types are loaded once at app init, not dynamically on each render.
|
||||||
|
- **Streaming AI responses** — `POST /api/agent/stream` uses SSE so the UI updates incrementally.
|
||||||
|
|
||||||
|
The sections below identify remaining bottlenecks and concrete next steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Known Bottlenecks
|
||||||
|
|
||||||
|
| # | Issue | Location | Impact | Root Cause |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 1 | Unguarded re-renders on canvas | `CanvasPage.tsx` | Frame drops on large graphs | Components not subscribed to granular store slices |
|
||||||
|
| 2 | Nunjucks template resolution on every render | `rendering.ts` resolve step | Slow Config node updates | No memoization of template output keyed to input hash |
|
||||||
|
| 3 | Kroki SVG requests not deduplicated | `rendering.ts` render step | Redundant network calls | No in-flight request deduplication or client-side cache |
|
||||||
|
| 4 | Sidebar tree renders all items | `KosmosPage` recollection tree | Scrolling lag with many workspaces | No list virtualization |
|
||||||
|
| 5 | Backend cache not wired to agent routes | `agentRoutes.ts` | Repeated identical LLM calls | `InMemoryCache` and `rateLimiter` exist but are unused |
|
||||||
|
| 6 | Full graph serialized to localStorage on every change | `useGraphStateWithHistory` | Storage I/O on every keypress | No debounce on the persistence write |
|
||||||
|
| 7 | Initial bundle size | Vite build | Slow first load | Heavy deps (BlockNote, React Flow, Nunjucks) loaded eagerly |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Recommended Improvements
|
||||||
|
|
||||||
|
### 3.1 Granular Store Subscriptions
|
||||||
|
|
||||||
|
Zustand supports slice-level subscriptions. Node components should select only their own data slice:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Instead of subscribing to the entire graph:
|
||||||
|
const node = useCanvasStore(s => s.graph.nodes.find(n => n.id === id))
|
||||||
|
```
|
||||||
|
|
||||||
|
This prevents all nodes from re-rendering when a single node changes.
|
||||||
|
|
||||||
|
### 3.2 Memoize Template Resolution
|
||||||
|
|
||||||
|
Cache the Nunjucks resolution output keyed to a hash of the template source plus variable inputs. Invalidate only when those inputs change:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const resolved = useMemo(
|
||||||
|
() => resolveTemplate(template, variables),
|
||||||
|
[templateHash, variableHash]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Deduplicate Kroki Requests
|
||||||
|
|
||||||
|
Add a simple in-flight map in the render step: if a request for the same PlantUML source is already pending, reuse its promise. Cache successful responses keyed to the source string with a short TTL (e.g. 5 minutes).
|
||||||
|
|
||||||
|
### 3.4 Wire Backend Cache and Rate Limiter
|
||||||
|
|
||||||
|
`InMemoryCache` and `rateLimiter` middleware are implemented in `backend/src/`. Connect them to `agentRoutes.ts`:
|
||||||
|
|
||||||
|
1. Add cache lookup before calling the AI service.
|
||||||
|
2. Store the response on cache miss.
|
||||||
|
3. Apply rate limiting per IP to prevent abuse.
|
||||||
|
|
||||||
|
### 3.5 Debounce localStorage Writes
|
||||||
|
|
||||||
|
Wrap the graph persistence call in a debounce (e.g., 300 ms) to avoid a write on every keystroke or node drag. The delta-based history already computes minimal diffs; the bottleneck is the frequency of writes.
|
||||||
|
|
||||||
|
### 3.6 Virtualize the Sidebar Tree
|
||||||
|
|
||||||
|
Integrate `react-arborist` (already installed) with virtualization enabled for the recollection sidebar when item count exceeds a threshold (~50).
|
||||||
|
|
||||||
|
### 3.7 Code Split Heavy Routes
|
||||||
|
|
||||||
|
Add lazy imports for the three heavy route components so the initial bundle only loads what the user navigates to:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const FluxRoute = lazy(() => import('./app/recollections/flux/FluxRoute'))
|
||||||
|
const LogosPage = lazy(() => import('./app/recollections/logos/LogosPage'))
|
||||||
|
const KatalogosPage = lazy(() => import('./app/recollections/katalogos/KatalogosPage'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.8 Enable Brotli Compression in Nginx
|
||||||
|
|
||||||
|
Add brotli/gzip compression to `frontend/nginx.conf` for JS, CSS, and SVG assets. This can cut transfer size by 60–70% for the JS bundle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Success Metrics
|
||||||
|
|
||||||
|
| Metric | Current (estimated) | Target |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Frame time on 50-node canvas | ~16 ms | < 10 ms |
|
||||||
|
| Initial JS bundle (gzipped) | ~800 KB | < 600 KB |
|
||||||
|
| Repeated identical LLM calls | uncached | 0 network round-trips |
|
||||||
|
| localStorage write frequency | every change | debounced 300 ms |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Contribution Path
|
||||||
|
|
||||||
|
1. Read [ARCHITECTURE.md](ARCHITECTURE.md) to understand the module you're optimizing.
|
||||||
|
2. Pick one item from section 2.
|
||||||
|
3. Add a Vitest benchmark (`performance.now()` before/after) alongside your change.
|
||||||
|
4. Submit a PR with the benchmark results in the description and update this file's "Current" column.
|
||||||
|
|
||||||
|
*This plan is a living document; update the metrics table when improvements land.*
|
||||||
116
README.md
116
README.md
@@ -1,42 +1,61 @@
|
|||||||
# Zui
|
# Zui
|
||||||
|
|
||||||
Node-based editor (React Flow) for configs, variables, and rendering (PlantUML, Markdown, etc.). Optional Node.js backend API for demos or future features (e.g. todos CRUD).
|
Node-based visual editor (React Flow) for composing configs, variables, templates, and AI-generated content. Three views per workspace: **Flux** (graph canvas), **Logos** (rich text), and **Katalogos** (artifact gallery). Optional Node.js backend for AI agent calls.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
```
|
```text
|
||||||
my-app/
|
zui/
|
||||||
├── frontend/ # React app (Vite, TypeScript)
|
├── frontend/ # React SPA (Vite, TypeScript)
|
||||||
│ ├── Dockerfile # Multi-stage for prod (build → Nginx)
|
|
||||||
│ ├── Dockerfile.dev # Dev with hot reload
|
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ ├── public/
|
│ │ ├── main.tsx # Entry point: registers node/config types, mounts React
|
||||||
|
│ │ ├── app/
|
||||||
|
│ │ │ ├── canvas/ # Core graph editor (React Flow + Zustand)
|
||||||
|
│ │ │ ├── kosmos/ # Platform shell: sidebar, recollection list, AI settings
|
||||||
|
│ │ │ └── recollections/ # Logos (rich text), Flux (canvas), Katalogos (gallery)
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── graph/ # AnimatedEdge, BaseNode, handles, keyboard shortcuts
|
||||||
|
│ │ │ ├── nodes/ # One folder per node type (agent, config, variable, …)
|
||||||
|
│ │ │ └── ui/ # Shadcn-based primitives
|
||||||
|
│ │ ├── hooks/ # Custom React hooks
|
||||||
|
│ │ └── lib/
|
||||||
|
│ │ └── graph/ # Registry, types, state, rendering pipeline, Nunjucks utils
|
||||||
|
│ ├── Dockerfile # Multi-stage: Vite build → Nginx
|
||||||
|
│ ├── Dockerfile.dev # Dev with hot reload
|
||||||
│ ├── nginx.conf
|
│ ├── nginx.conf
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── backend/ # Node.js/Express API
|
├── backend/ # Node.js/Express API
|
||||||
│ ├── Dockerfile
|
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ └── index.js
|
│ │ ├── index.ts # Express entry: registers routes, CORS, error handler
|
||||||
|
│ │ ├── routes/agentRoutes.ts
|
||||||
|
│ │ ├── services/agentService.ts
|
||||||
|
│ │ ├── repositories/cacheRepository.ts
|
||||||
|
│ │ ├── models/index.ts
|
||||||
|
│ │ └── middleware/rateLimiter.ts
|
||||||
│ └── package.json
|
│ └── package.json
|
||||||
|
├── docs/
|
||||||
|
│ ├── ARCHITECTURE.md
|
||||||
|
│ ├── PERFORMANCE.md
|
||||||
|
│ ├── NODE_TYPE_EXTENSIBILITY_PROPOSAL.md
|
||||||
|
│ └── CODE_REVIEW_CHECKLIST.md
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── .dockerignore
|
|
||||||
└── .gitignore
|
└── .gitignore
|
||||||
```
|
```
|
||||||
|
|
||||||
Ignored by git: `node_modules`, `dist`, `.env`, `.env.*` (see [.gitignore](.gitignore)). Local Docker overrides: `docker-compose.override.yml` (optional, not committed).
|
Ignored by git: `node_modules`, `dist`, `.env`, `.env.*`. Local Docker overrides: `docker-compose.override.yml` (optional, not committed).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Run locally (dev)
|
## Run locally (dev)
|
||||||
|
|
||||||
**Frontend only:**
|
**Frontend only** (no AI agent):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm install && npm run dev
|
cd frontend && npm install && npm run dev
|
||||||
# → http://localhost:3000
|
# → http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend + backend** (for AI agent and health):
|
**Frontend + backend** (for AI agent and health check):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1 – backend
|
# Terminal 1 – backend
|
||||||
@@ -56,70 +75,79 @@ cd frontend && npm install && npm run dev
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Frontend**: http://localhost:3000 (Nginx; `/api/*` proxied to backend).
|
- **Frontend**: <http://localhost:3000> (Nginx; `/api/*` proxied to backend).
|
||||||
- **Backend**: http://localhost:8080 (Express).
|
- **Backend**: <http://localhost:8080> (Express).
|
||||||
|
|
||||||
Environment variables (backend service):
|
Environment variables (backend service):
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|-------------|----------------------------|-------------|
|
|------------------|---------------------------|--------------------------------------------|
|
||||||
| `PORT` | `8080` | Backend listen port. |
|
| `PORT` | `8080` | Backend listen port. |
|
||||||
| `CORS_ORIGIN` | `http://localhost:3000` | Allowed origin for CORS. |
|
| `CORS_ORIGIN` | `http://localhost:3000` | Allowed origin for CORS. |
|
||||||
|
| `AI_BASE_URL` | *(unset)* | OpenAI-compatible base URL (local LLMs). |
|
||||||
|
| `AI_MODEL` | `gpt-4o-mini` | Model ID for the AI agent. |
|
||||||
|
| `OPENAI_API_KEY` | *(unset)* | Required when using OpenAI directly. |
|
||||||
|
|
||||||
For self-hosting (e.g. Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL; put Caddy or Nginx in front for TLS if needed.
|
For self-hosting (e.g., Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL and put Caddy or Nginx in front for TLS.
|
||||||
|
|
||||||
**Dev with Docker (frontend hot reload):** use `frontend/Dockerfile.dev` and mount `./frontend` as a volume, or run `cd frontend && npm run dev` locally.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|--------|-------------|
|
|--------------------------------------|----------------------------------------|
|
||||||
| `cd frontend && npm run dev` | Vite dev server. |
|
| `cd frontend && npm run dev` | Vite dev server (port 3000). |
|
||||||
| `cd frontend && npm run build` | Build frontend for production. |
|
| `cd frontend && npm run build` | Build frontend for production. |
|
||||||
| `cd frontend && npm run preview` | Preview production build. |
|
| `cd frontend && npm run preview` | Preview production build. |
|
||||||
| `cd backend && npm run dev` | Backend with `--watch`. |
|
| `cd frontend && npm run test` | Run tests in watch mode (Vitest). |
|
||||||
|
| `cd 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. |
|
| `cd backend && npm start` | Backend production run. |
|
||||||
| `docker compose up --build` | Run frontend + backend in Docker. |
|
| `docker compose up --build` | Run full stack in Docker. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backend API (no DB)
|
## Backend API
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Body / Response |
|
||||||
|--------|------|-------------|
|
|--------|-----------------------|---------------------------------------------------------------------------------|
|
||||||
| GET | `/health` | Health check (e.g. for Docker). |
|
| GET | `/health` | `{ ok: true, timestamp: number }` — health check for Docker/orchestration. |
|
||||||
| POST | `/api/agent` | Run AI agent; body `{ "prompt", "context?", "contextNodes?" }` → `{ "markdown" }`. |
|
| POST | `/api/agent` | Body `{ prompt, context?, contextNodes? }` → `{ markdown }` (one-shot). |
|
||||||
|
| POST | `/api/agent/stream` | Body `{ prompt, context?, contextNodes? }` → SSE text stream (streaming). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Agent node (local LLM or OpenAI)
|
## Agent node (local LLM or OpenAI)
|
||||||
|
|
||||||
The **Agent** node uses an OpenAI-compatible API. You can use:
|
AI connection settings are configured **directly in the UI** — open the sidebar and go to **AI Settings**. You can switch between providers without restarting the server.
|
||||||
|
|
||||||
**1. Local LLM (e.g. LM Studio)**
|
The backend reads its AI config from environment variables as a fallback:
|
||||||
|
|
||||||
1. Install [LM Studio](https://lmstudio.ai/) and load a model.
|
### Local LLM (e.g. LM Studio)
|
||||||
2. Start the local server: in LM Studio open the **Developer** tab and run the **Local Server** (default: `http://localhost:1234`).
|
|
||||||
3. In the project root or `backend/`, set:
|
1. Install [LM Studio](https://lmstudio.ai/), load a model, and start the local server (default: `http://localhost:1234`).
|
||||||
|
2. Set env vars (backend):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export AI_BASE_URL=http://localhost:1234/v1
|
export AI_BASE_URL=http://localhost:1234/v1
|
||||||
# Optional: set to the model name shown in LM Studio (e.g. the loaded model id). Default is "local-model".
|
export AI_MODEL=your-model-name # optional; matches the name shown in LM Studio
|
||||||
export AI_MODEL=your-model-name
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Start the backend (`cd backend && npm run dev`). The Agent node will use your local model.
|
### OpenAI
|
||||||
|
|
||||||
**2. OpenAI**
|
```bash
|
||||||
|
export OPENAI_API_KEY=sk-...
|
||||||
|
export AI_MODEL=gpt-4o-mini # optional; defaults to gpt-4o-mini
|
||||||
|
```
|
||||||
|
|
||||||
Set `OPENAI_API_KEY` to your API key. The backend will use `gpt-4o-mini` unless you set `AI_MODEL`.
|
---
|
||||||
|
|
||||||
**Env summary (backend)**
|
## Contribute
|
||||||
|
|
||||||
| Variable | When to use | Description |
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — architecture overview, key patterns, module map
|
||||||
|----------|--------------|-------------|
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — coding standards, branch workflow, test commands
|
||||||
| `AI_BASE_URL` | Local LLM (LM Studio, Ollama, etc.) | OpenAI-compatible base URL, e.g. `http://localhost:1234/v1`. |
|
- [PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md) — bottleneck analysis and improvement plan
|
||||||
| `AI_MODEL` | Optional | Model id (for local: use the name shown in LM Studio; for OpenAI: e.g. `gpt-4o-mini`). |
|
- [docs/CODE_REVIEW_CHECKLIST.md](docs/CODE_REVIEW_CHECKLIST.md) — PR review checklist
|
||||||
| `OPENAI_API_KEY` | OpenAI only | Your OpenAI API key. Not required when using `AI_BASE_URL` only. |
|
- [docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md](docs/NODE_TYPE_EXTENSIBILITY_PROPOSAL.md) — node plugin system design
|
||||||
|
|
||||||
|
*Thank you for contributing!*
|
||||||
|
|||||||
678
backend/package-lock.json
generated
678
backend/package-lock.json
generated
@@ -13,6 +13,13 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0"
|
"express": "^4.21.0"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
@@ -103,6 +110,448 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@opentelemetry/api": {
|
"node_modules/@opentelemetry/api": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
@@ -112,12 +561,120 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/body-parser": {
|
||||||
|
"version": "1.19.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/connect": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/connect": {
|
||||||
|
"version": "3.4.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/cors": {
|
||||||
|
"version": "2.8.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
|
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/diff-match-patch": {
|
"node_modules/@types/diff-match-patch": {
|
||||||
"version": "1.0.36",
|
"version": "1.0.36",
|
||||||
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
|
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
|
||||||
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
|
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/express": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/body-parser": "*",
|
||||||
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
|
"@types/serve-static": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/express-serve-static-core": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/qs": "*",
|
||||||
|
"@types/range-parser": "*",
|
||||||
|
"@types/send": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/http-errors": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/qs": {
|
||||||
|
"version": "6.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
|
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/range-parser": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/send": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/serve-static": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-errors": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -392,6 +949,48 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.4",
|
||||||
|
"@esbuild/android-arm": "0.27.4",
|
||||||
|
"@esbuild/android-arm64": "0.27.4",
|
||||||
|
"@esbuild/android-x64": "0.27.4",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.4",
|
||||||
|
"@esbuild/darwin-x64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.4",
|
||||||
|
"@esbuild/linux-arm": "0.27.4",
|
||||||
|
"@esbuild/linux-arm64": "0.27.4",
|
||||||
|
"@esbuild/linux-ia32": "0.27.4",
|
||||||
|
"@esbuild/linux-loong64": "0.27.4",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.4",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.4",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.4",
|
||||||
|
"@esbuild/linux-s390x": "0.27.4",
|
||||||
|
"@esbuild/linux-x64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.4",
|
||||||
|
"@esbuild/sunos-x64": "0.27.4",
|
||||||
|
"@esbuild/win32-arm64": "0.27.4",
|
||||||
|
"@esbuild/win32-ia32": "0.27.4",
|
||||||
|
"@esbuild/win32-x64": "0.27.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -489,6 +1088,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -535,6 +1149,19 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||||
|
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -853,6 +1480,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -1051,6 +1688,26 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -1064,6 +1721,27 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Minimal Express API for Zui (agent, health; no DB)",
|
"description": "Minimal Express API for Zui (agent, health; no DB)",
|
||||||
"main": "src/index.js",
|
"type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.ts",
|
||||||
"dev": "node --watch src/index.js"
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -16,5 +18,12 @@
|
|||||||
"@ai-sdk/openai": "^1.0.0",
|
"@ai-sdk/openai": "^1.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.0"
|
"express": "^4.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,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
102
backend/src/index.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Backend API: Layered architecture for scalability and maintainability.
|
||||||
|
*
|
||||||
|
* ## Architecture Layers
|
||||||
|
*
|
||||||
|
* ### Controllers (routes/)
|
||||||
|
* - Handle HTTP requests and responses
|
||||||
|
* - Validate input and format output
|
||||||
|
* - Call services for business logic
|
||||||
|
*
|
||||||
|
* ### Services (services/)
|
||||||
|
* - Implement business logic
|
||||||
|
* - Coordinate between controllers and repositories
|
||||||
|
* - Handle validation and transformation
|
||||||
|
*
|
||||||
|
* ### Repositories (repositories/)
|
||||||
|
* - Data access layer
|
||||||
|
* - Interact with databases or external APIs
|
||||||
|
* - Return domain models
|
||||||
|
*
|
||||||
|
* ### Models (models/)
|
||||||
|
* - Data structures and types
|
||||||
|
* - Validation schemas
|
||||||
|
*
|
||||||
|
* ### Middleware (middleware/)
|
||||||
|
* - Request validation
|
||||||
|
* - Authentication/authorization
|
||||||
|
* - Rate limiting
|
||||||
|
* - Error handling
|
||||||
|
*
|
||||||
|
* ## Adding New Endpoints
|
||||||
|
*
|
||||||
|
* 1. Define the model in `models/`
|
||||||
|
* 2. Implement repository logic in `repositories/`
|
||||||
|
* 3. Implement service logic in `services/`
|
||||||
|
* 4. Create controller in `routes/`
|
||||||
|
* 5. Register route in `index.ts`
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Configuration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT) || 8080
|
||||||
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:3000'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Express App Setup
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
app.use(cors({ origin: CORS_ORIGIN }))
|
||||||
|
app.use(express.json())
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent Routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { handleAgentRequest, handleAgentStreamRequest } from './routes/agentRoutes.js'
|
||||||
|
|
||||||
|
/** POST /api/agent - Run AI agent */
|
||||||
|
app.post('/api/agent', handleAgentRequest)
|
||||||
|
|
||||||
|
/** POST /api/agent/stream - Stream AI agent response */
|
||||||
|
app.post('/api/agent/stream', handleAgentStreamRequest)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Health Check Endpoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** GET /health - Health check for Docker / orchestration */
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.status(200).json({ ok: true, timestamp: Date.now() })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Error Handling Middleware
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Global error handler for consistent error responses */
|
||||||
|
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
console.error('Error:', err)
|
||||||
|
res.status(500).json({ error: err.message ?? 'Internal server error' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Start Server
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Export for testing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default app
|
||||||
88
backend/src/middleware/rateLimiter.ts
Normal file
88
backend/src/middleware/rateLimiter.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Middleware: Rate limiting for API endpoints.
|
||||||
|
*
|
||||||
|
* This module provides rate limiting middleware to protect the API
|
||||||
|
* from abuse and ensure fair usage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit configuration
|
||||||
|
*/
|
||||||
|
export type RateLimitConfig = {
|
||||||
|
/** Maximum number of requests allowed in the window */
|
||||||
|
max: number
|
||||||
|
/** Window size in milliseconds */
|
||||||
|
windowMs: number
|
||||||
|
/** Error message to return when rate limited */
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default rate limit configuration
|
||||||
|
*/
|
||||||
|
export const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = {
|
||||||
|
max: 100,
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
message: 'Too many requests, please try again later.',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory store for rate limiting
|
||||||
|
*/
|
||||||
|
const rateLimitStore = new Map<string, { count: number; resetTime: number }>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiting middleware
|
||||||
|
*
|
||||||
|
* @param config - Rate limit configuration
|
||||||
|
* @returns Express middleware function
|
||||||
|
*/
|
||||||
|
export function rateLimit(config: RateLimitConfig = DEFAULT_RATE_LIMIT_CONFIG) {
|
||||||
|
return (req: { ip?: string; socket?: { remoteAddress?: string } }, res: { status: (code: number) => { json: (body: { error: string }) => void }, locals: Record<string, unknown> }, next: () => void): void => {
|
||||||
|
const ip = req.ip || req.socket?.remoteAddress || 'unknown'
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
let entry = rateLimitStore.get(ip)
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetTime) {
|
||||||
|
// Reset the counter for a new window
|
||||||
|
entry = { count: 1, resetTime: now + config.windowMs }
|
||||||
|
rateLimitStore.set(ip, entry)
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
|
||||||
|
if (entry.count > config.max) {
|
||||||
|
res.status(429).json({ error: config.message ?? 'Too many requests' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired entries from the rate limit store
|
||||||
|
*/
|
||||||
|
export function cleanupRateLimitStore(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [ip, entry] of rateLimitStore.entries()) {
|
||||||
|
if (now > entry.resetTime) {
|
||||||
|
rateLimitStore.delete(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit status for an IP
|
||||||
|
*/
|
||||||
|
export function getRateLimitStatus(ip: string): { remaining: number; resetTime: number } | null {
|
||||||
|
const entry = rateLimitStore.get(ip)
|
||||||
|
if (!entry) return null
|
||||||
|
return {
|
||||||
|
remaining: Math.max(0, DEFAULT_RATE_LIMIT_CONFIG.max - entry.count),
|
||||||
|
resetTime: entry.resetTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/src/models/index.ts
Normal file
63
backend/src/models/index.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Models: Data structures and validation schemas.
|
||||||
|
*
|
||||||
|
* This module defines the domain models used throughout the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent request payload
|
||||||
|
*/
|
||||||
|
export type AgentRequest = {
|
||||||
|
prompt: string
|
||||||
|
context?: string
|
||||||
|
contextNodes?: AgentContextNode[]
|
||||||
|
connection?: AgentConnection
|
||||||
|
reasoning?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context node for agent requests
|
||||||
|
*/
|
||||||
|
export type AgentContextNode = {
|
||||||
|
id: string
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI connection configuration
|
||||||
|
*/
|
||||||
|
export type AgentConnection = {
|
||||||
|
provider: 'openai' | 'local'
|
||||||
|
baseURL?: string
|
||||||
|
apiKey?: string
|
||||||
|
model?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent response
|
||||||
|
*/
|
||||||
|
export type AgentResponse = {
|
||||||
|
markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent stream response
|
||||||
|
*/
|
||||||
|
export type AgentStreamResponse = {
|
||||||
|
markdown: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check response
|
||||||
|
*/
|
||||||
|
export type HealthResponse = {
|
||||||
|
ok: boolean
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error response
|
||||||
|
*/
|
||||||
|
export type ErrorResponse = {
|
||||||
|
error: string
|
||||||
|
}
|
||||||
103
backend/src/repositories/cacheRepository.ts
Normal file
103
backend/src/repositories/cacheRepository.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Repositories: Data access layer.
|
||||||
|
*
|
||||||
|
* This module handles data persistence and retrieval.
|
||||||
|
* It abstracts the underlying storage mechanism.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache entry for expensive computations
|
||||||
|
*/
|
||||||
|
export type CacheEntry<T> = {
|
||||||
|
/** Unique key for the cache entry */
|
||||||
|
key: string
|
||||||
|
/** Cached data */
|
||||||
|
data: T
|
||||||
|
/** Timestamp (ms) when this entry was created */
|
||||||
|
createdAt: number
|
||||||
|
/** Time-to-live in milliseconds */
|
||||||
|
ttl?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory cache implementation
|
||||||
|
*/
|
||||||
|
export class InMemoryCache {
|
||||||
|
private cache = new Map<string, CacheEntry<unknown>>()
|
||||||
|
private defaultTtl = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from the cache
|
||||||
|
*
|
||||||
|
* @param key - Cache key
|
||||||
|
* @returns Cached value or null if not found or expired
|
||||||
|
*/
|
||||||
|
get<T>(key: string): T | null {
|
||||||
|
const entry = this.cache.get(key) as CacheEntry<T> | undefined
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
// Check if entry has expired
|
||||||
|
if (entry.ttl && Date.now() - entry.createdAt > entry.ttl) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value in the cache
|
||||||
|
*
|
||||||
|
* @param key - Cache key
|
||||||
|
* @param data - Data to cache
|
||||||
|
* @param ttl - Optional time-to-live in milliseconds (uses default if not specified)
|
||||||
|
*/
|
||||||
|
set<T>(key: string, data: T, ttl?: number): void {
|
||||||
|
this.cache.set(key, {
|
||||||
|
key,
|
||||||
|
data,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
ttl: ttl ?? this.defaultTtl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a value from the cache
|
||||||
|
*
|
||||||
|
* @param key - Cache key
|
||||||
|
*/
|
||||||
|
delete(key: string): void {
|
||||||
|
this.cache.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all entries from the cache
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired entries
|
||||||
|
*/
|
||||||
|
cleanup(): void {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, entry] of this.cache.entries()) {
|
||||||
|
if (entry.ttl && now - entry.createdAt > entry.ttl) {
|
||||||
|
this.cache.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of entries in the cache
|
||||||
|
*/
|
||||||
|
size(): number {
|
||||||
|
return this.cache.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global cache instance
|
||||||
|
*/
|
||||||
|
export const cache = new InMemoryCache()
|
||||||
72
backend/src/routes/agentRoutes.ts
Normal file
72
backend/src/routes/agentRoutes.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Routes: HTTP request handlers.
|
||||||
|
*
|
||||||
|
* This module contains the route handlers for the API.
|
||||||
|
* Routes validate input and format output, then call services for business logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentRequest, AgentResponse, ErrorResponse } from '../models'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agent - Run AI agent
|
||||||
|
*
|
||||||
|
* Request body: { prompt, context?, contextNodes?, connection? }
|
||||||
|
* Response: { markdown }
|
||||||
|
*/
|
||||||
|
export async function handleAgentRequest(req: any, res: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
const body = req.body
|
||||||
|
const { buildAgentRequest } = await import('../services/agentService')
|
||||||
|
|
||||||
|
const built = buildAgentRequest(body)
|
||||||
|
if ('error' in built) {
|
||||||
|
res.status(503).json({ error: built.error } as ErrorResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { generateText } = await import('ai')
|
||||||
|
const result = await generateText({
|
||||||
|
model: built.openai as any,
|
||||||
|
prompt: built.fullPrompt,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { processAgentResponse } = await import('../services/agentService')
|
||||||
|
const response = processAgentResponse(result)
|
||||||
|
res.json(response)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Agent error:', err)
|
||||||
|
res.status(500).json({ error: (err as Error).message ?? 'Agent request failed' } as ErrorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/agent/stream - Stream AI agent response
|
||||||
|
*
|
||||||
|
* Request body: { prompt, context?, contextNodes?, connection? }
|
||||||
|
* Response: plain text (markdown) chunks
|
||||||
|
*/
|
||||||
|
export async function handleAgentStreamRequest(req: any, res: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
const body = req.body
|
||||||
|
const { buildAgentRequest } = await import('../services/agentService')
|
||||||
|
|
||||||
|
const built = buildAgentRequest(body)
|
||||||
|
if ('error' in built) {
|
||||||
|
res.status(503).json({ error: built.error } as ErrorResponse)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { streamText } = await import('ai')
|
||||||
|
const result = streamText({
|
||||||
|
model: built.openai as any,
|
||||||
|
prompt: built.fullPrompt,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
res.setHeader('Transfer-Encoding', 'chunked')
|
||||||
|
result.pipeTextStreamToResponse(res)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Agent stream error:', err)
|
||||||
|
res.status(500).json({ error: (err as Error).message ?? 'Agent request failed' } as ErrorResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/src/services/agentService.ts
Normal file
65
backend/src/services/agentService.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Services: Business logic layer.
|
||||||
|
*
|
||||||
|
* This module contains the business logic for the application.
|
||||||
|
* Services coordinate between controllers and repositories.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AgentRequest, AgentResponse, AgentConnection } from '../models'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build OpenAI client and full prompt from request body.
|
||||||
|
* Returns { openai, modelId, fullPrompt } or { error }.
|
||||||
|
*/
|
||||||
|
export function buildAgentRequest(body: AgentRequest): { openai: unknown; modelId: string; fullPrompt: string } | { error: string } {
|
||||||
|
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
|
||||||
|
const reasoningEnabled = Boolean(reasoning)
|
||||||
|
let baseURL = process.env.AI_BASE_URL?.trim() || null
|
||||||
|
let apiKey = process.env.OPENAI_API_KEY?.trim() || null
|
||||||
|
let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
|
||||||
|
|
||||||
|
if (conn && typeof conn === 'object') {
|
||||||
|
const c = conn as AgentConnection
|
||||||
|
const provider = c.provider === 'openai' ? 'openai' : 'local'
|
||||||
|
if (provider === 'local') {
|
||||||
|
baseURL = (typeof c.baseURL === 'string' && c.baseURL.trim()) ? c.baseURL.trim() : baseURL
|
||||||
|
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : (apiKey || 'lm-studio')
|
||||||
|
} else {
|
||||||
|
baseURL = null
|
||||||
|
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : apiKey
|
||||||
|
}
|
||||||
|
if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseURL && !apiKey) {
|
||||||
|
return { error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePrompt = [
|
||||||
|
typeof prompt === 'string' ? prompt : 'No prompt provided.',
|
||||||
|
context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
|
||||||
|
Array.isArray(contextNodes) && contextNodes.length > 0
|
||||||
|
? `\n\nContext from connected nodes:\n${contextNodes.map((n) => (n.content != null ? n.content : `${n.id}: (no content)`)).join('\n\n')}`
|
||||||
|
: '',
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const fullPrompt = reasoningEnabled
|
||||||
|
? basePrompt + '\n\nRespond in exactly two markdown sections. First: "## Reasoning" with your step-by-step reasoning. Then: "## Output" with only the final answer. No preamble.'
|
||||||
|
: basePrompt + '\n\nRespond with structured markdown only. No preamble.'
|
||||||
|
|
||||||
|
const { createOpenAI } = require('@ai-sdk/openai')
|
||||||
|
const openai = createOpenAI({
|
||||||
|
apiKey: apiKey || 'lm-studio',
|
||||||
|
...(baseURL && { baseURL, compatibility: 'compatible' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return { openai, modelId, fullPrompt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process agent response
|
||||||
|
*/
|
||||||
|
export function processAgentResponse(result: { text?: string }): AgentResponse {
|
||||||
|
const markdown = result?.text ?? ''
|
||||||
|
return { markdown }
|
||||||
|
}
|
||||||
20
backend/tsconfig.json
Normal file
20
backend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
48
docs/CODE_REVIEW_CHECKLIST.md
Normal file
48
docs/CODE_REVIEW_CHECKLIST.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Code Review Checklist
|
||||||
|
|
||||||
|
Use this checklist during code reviews to ensure high standards of readability, maintainability, and performance.
|
||||||
|
|
||||||
|
## 1. Readability
|
||||||
|
|
||||||
|
- [ ] **Naming**: Variables, functions, and components use descriptive, consistent names (camelCase for functions/variables, PascalCase for components).
|
||||||
|
- [ ] **JSDoc**: All public APIs have complete JSDoc comments with `@param`, `@returns`, and `@example` tags.
|
||||||
|
- [ ] **Line Length**: No line exceeds 120 characters; wrap long lines for readability.
|
||||||
|
- [ ] **Comments**: Explain *why* something is done, not just *what* is done. Avoid redundant comments.
|
||||||
|
- [ ] **Whitespace**: Consistent spacing and indentation (2 spaces for TypeScript/JSX).
|
||||||
|
|
||||||
|
## 2. Maintainability
|
||||||
|
|
||||||
|
- [ ] **Component Structure**: Large components are split into smaller, focused sub‑components.
|
||||||
|
- [ ] **Hooks**: Custom hooks encapsulate reusable logic and are named with `use` prefix.
|
||||||
|
- [ ] **Utility Functions**: Pure functions live in `src/lib/` and are exported for reuse.
|
||||||
|
- [ ] **Type Safety**: All function signatures include explicit TypeScript types; no `any` usage.
|
||||||
|
- [ ] **Import Order**: Core → Library → Project → Relative imports; alphabetical within groups.
|
||||||
|
|
||||||
|
## 3. Performance
|
||||||
|
|
||||||
|
- [ ] **Memoization**: Expensive calculations use `useMemo`; event handlers use `useCallback`.
|
||||||
|
- [ ] **Lazy Loading**: Heavy modules are loaded via `import()`; code splitting is configured in Vite.
|
||||||
|
- [ ] **Virtualization**: Large lists use `react-window` or similar for efficient rendering.
|
||||||
|
- [ ] **State Granularity**: State updates target only the minimal portion of state; avoid unnecessary re‑renders.
|
||||||
|
- [ ] **Bundle Size**: No unused dependencies; assets are compressed (gzip/brotli) in production.
|
||||||
|
|
||||||
|
## 4. Accessibility & Security
|
||||||
|
|
||||||
|
- [ ] **ARIA**: Semantic HTML and ARIA attributes are present for interactive elements.
|
||||||
|
- [ ] **Input Validation**: User inputs are validated before processing; no hard‑coded secrets.
|
||||||
|
- [ ] **Error Handling**: Errors are caught and logged appropriately; user‑friendly messages are shown.
|
||||||
|
|
||||||
|
## 5. Testing
|
||||||
|
|
||||||
|
- [ ] **Unit Tests**: Cover all new logic with tests; aim for ≥ 80 % coverage on critical paths.
|
||||||
|
- [ ] **Integration Tests**: Verify that components interact correctly with state/store.
|
||||||
|
- [ ] **Test Naming**: Test files end with `.test.tsx` and describe the behavior being tested.
|
||||||
|
|
||||||
|
## 6. Documentation Links
|
||||||
|
|
||||||
|
- [ ] Architecture Overview: [[ARCHITECTURE.md](ARCHITECTURE.md)]
|
||||||
|
- [ ] Performance Plan: [[PERFORMANCE_IMPROVEMENTS.md](PERFORMANCE_IMPROVEMENTS.md)]
|
||||||
|
- [ ] Junior Developer Quickstart: [[QUICKSTART_FOR_JUNIORS.md](QUICKSTART_FOR_JUNIORS.md)]
|
||||||
|
- [ ] Contribution Guide: [[CONTRIBUTING.md](CONTRIBUTING.md)]
|
||||||
|
|
||||||
|
*Reviewers should mark any failing items as **[ ]** and request changes before approving.*
|
||||||
99
frontend/docs/QUICKSTART_FOR_JUNIORS.md
Normal file
99
frontend/docs/QUICKSTART_FOR_JUNIORS.md
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# Junior Developer Quickstart
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
This guide explains how to set up the development environment, run the application, and make your first contribution.
|
||||||
|
|
||||||
|
## 2. Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** (v18 or later)
|
||||||
|
- **pnpm** (package manager)
|
||||||
|
- **Docker** (for backend services, optional)
|
||||||
|
- **Git** (version control)
|
||||||
|
|
||||||
|
## 3. Repository Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/your-org/zui.git
|
||||||
|
cd zui
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Running the Application
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:3000> to see the app.
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend API will be available at <http://localhost:5000>.
|
||||||
|
|
||||||
|
## 5. Making Your First Contribution
|
||||||
|
|
||||||
|
1. **Create a feature branch**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b feat/your-first-contribution
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Make a small change**:
|
||||||
|
- Improve a README typo.
|
||||||
|
- Add a missing JSDoc comment.
|
||||||
|
- Fix a minor bug.
|
||||||
|
3. **Run the appropriate tests** to ensure your change doesn't break anything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Commit your changes** with a clear message:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -am "feat: brief description of change"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Push the branch** to GitHub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin feat/your-first-contribution
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Open a Pull Request** on GitHub, linking the relevant issue.
|
||||||
|
|
||||||
|
## 6. Coding Standards
|
||||||
|
|
||||||
|
- Follow the patterns in **ARCHITECTURE.md** and **CONTRIBUTING.md**.
|
||||||
|
- Use TypeScript with strict mode.
|
||||||
|
- Add JSDoc for all public APIs.
|
||||||
|
- Keep changes small and focused.
|
||||||
|
|
||||||
|
## 7. Helpful Links
|
||||||
|
|
||||||
|
- **ARCHITECTURE.md** - High-level architecture overview.
|
||||||
|
- **PERFORMANCE_IMPROVEMENTS.md** - Performance improvement plan.
|
||||||
|
- **CODE_REVIEW_CHECKLIST.md** - Checklist for reviewers.
|
||||||
|
|
||||||
|
## 8. FAQ
|
||||||
|
|
||||||
|
**Q:** Do I need to write tests?
|
||||||
|
**A:** For bug fixes and new features, yes. Unit tests and integration tests are located under `frontend/src/app/**/*.test.tsx`.
|
||||||
|
|
||||||
|
**Q:** How do I run the linter?
|
||||||
|
**A:** `pnpm lint`.
|
||||||
|
|
||||||
|
**Q:** Where can I find more documentation?
|
||||||
|
**A:** See the `docs/` directory and the repository wiki.
|
||||||
|
|
||||||
|
---\n*Happy coding!*
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
||||||
<title>React Flow + shadcn Canvas</title>
|
<title>ZOË | Kosmos</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
144
frontend/package-lock.json
generated
144
frontend/package-lock.json
generated
@@ -41,6 +41,7 @@
|
|||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-arborist": "^3.4.3",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
@@ -519,7 +520,6 @@
|
|||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -2927,6 +2927,24 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-dnd/asap": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-dnd/invariant": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-dnd/shallowequal": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@remirror/core-constants": {
|
"node_modules/@remirror/core-constants": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||||
@@ -3875,7 +3893,7 @@
|
|||||||
"version": "25.3.3",
|
"version": "25.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
@@ -5325,6 +5343,26 @@
|
|||||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dnd-core": {
|
||||||
|
"version": "14.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz",
|
||||||
|
"integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-dnd/asap": "^4.0.0",
|
||||||
|
"@react-dnd/invariant": "^2.0.0",
|
||||||
|
"redux": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dnd-core/node_modules/redux": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.9.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
@@ -6472,6 +6510,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/hoist-non-react-statics": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^16.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.5",
|
"version": "4.12.5",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
||||||
@@ -7449,6 +7502,12 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/memoize-one": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/merge-descriptors": {
|
"node_modules/merge-descriptors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||||
@@ -9358,6 +9417,62 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-arborist": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-yFnq1nIQhT2uJY4TZVz2tgAiBb9lxSyvF4vC3S8POCK8xLzjGIxVv3/4dmYquQJ7AHxaZZArRGHiHKsEewKdTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-dnd": "^14.0.3",
|
||||||
|
"react-dnd-html5-backend": "^14.0.3",
|
||||||
|
"react-window": "^1.8.11",
|
||||||
|
"redux": "^5.0.0",
|
||||||
|
"use-sync-external-store": "^1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.14",
|
||||||
|
"react-dom": ">= 16.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dnd": {
|
||||||
|
"version": "14.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
|
||||||
|
"integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-dnd/invariant": "^2.0.0",
|
||||||
|
"@react-dnd/shallowequal": "^2.0.0",
|
||||||
|
"dnd-core": "14.0.1",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"hoist-non-react-statics": "^3.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||||
|
"@types/node": ">= 12",
|
||||||
|
"@types/react": ">= 16",
|
||||||
|
"react": ">= 16.14"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/hoist-non-react-statics": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dnd-html5-backend": {
|
||||||
|
"version": "14.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
|
||||||
|
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dnd-core": "14.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||||
@@ -9525,6 +9640,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-window": {
|
||||||
|
"version": "1.8.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
|
||||||
|
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.0.0",
|
||||||
|
"memoize-one": ">=3.1.1 <6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -9575,6 +9707,12 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/rehype-format": {
|
"node_modules/rehype-format": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz",
|
||||||
@@ -10761,7 +10899,7 @@
|
|||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicorn-magic": {
|
"node_modules/unicorn-magic": {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"prism-react-renderer": "^2.4.1",
|
"prism-react-renderer": "^2.4.1",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-arborist": "^3.4.3",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"react-simple-code-editor": "^0.14.1",
|
"react-simple-code-editor": "^0.14.1",
|
||||||
|
|||||||
@@ -26,5 +26,19 @@ export {
|
|||||||
selectRenamingNodeId,
|
selectRenamingNodeId,
|
||||||
selectFullscreenNodeId,
|
selectFullscreenNodeId,
|
||||||
selectConnectionFrom,
|
selectConnectionFrom,
|
||||||
|
// Fine-grained selectors
|
||||||
|
selectNodeById,
|
||||||
|
selectNodesById,
|
||||||
|
selectNodesByType,
|
||||||
|
selectNodeIdsByType,
|
||||||
|
selectEdgesBySource,
|
||||||
|
selectEdgesByTarget,
|
||||||
|
selectEdgesForNode,
|
||||||
|
selectNodeCount,
|
||||||
|
selectEdgeCount,
|
||||||
|
selectHasNode,
|
||||||
|
selectHasEdge,
|
||||||
|
// SVG detection
|
||||||
|
isSvgContent,
|
||||||
} from './canvasStore.selectors'
|
} from './canvasStore.selectors'
|
||||||
export type { ConnectionPathRole } from './canvasStore.selectors'
|
export type { ConnectionPathRole } from './canvasStore.selectors'
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ import {
|
|||||||
} from '@/lib/graph/graphologyPath'
|
} from '@/lib/graph/graphologyPath'
|
||||||
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
||||||
import type { CanvasStore } from './canvasStore.types'
|
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
|
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
|
// Raw slices
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -38,6 +47,65 @@ export function selectEdges(state: CanvasStore) {
|
|||||||
return state.graph.edges
|
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
|
// Derived path (Sets) – depend on graph.edges + path primitive arrays
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* with initial graph from recollection storage (or example). Save is explicit via save().
|
* with initial graph from recollection storage (or example). Save is explicit via save().
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||||
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
import { getInitialGraph } from '@/app/canvas/canvasGraphUtils'
|
||||||
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
import { saveGraphToStorage, RECOLLECTION_VERSION } from '@/app/recollections/state/recollectionGraphStorage'
|
||||||
@@ -59,5 +59,16 @@ export function useCanvasGraph(recollectionId: string | undefined): UseCanvasGra
|
|||||||
}, SAVING_DISPLAY_MS)
|
}, SAVING_DISPLAY_MS)
|
||||||
}, [recollectionId])
|
}, [recollectionId])
|
||||||
|
|
||||||
|
// Auto-save after 5 seconds of inactivity when there are unsaved changes.
|
||||||
|
// Prevents data loss if the user closes the tab without pressing Ctrl+S.
|
||||||
|
const AUTO_SAVE_DELAY_MS = 5000
|
||||||
|
const saveRef = useRef(save)
|
||||||
|
saveRef.current = save
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirty || !recollectionId) return
|
||||||
|
const timer = setTimeout(() => saveRef.current(), AUTO_SAVE_DELAY_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [isDirty, recollectionId, currentSerialized])
|
||||||
|
|
||||||
return { ...result, save, saveStatus }
|
return { ...result, save, saveStatus }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,23 +2,14 @@
|
|||||||
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
|
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useState } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import { Plus, FileText } from 'lucide-react'
|
||||||
Dialog,
|
import { FluxIcon } from '@/lib/icons'
|
||||||
DialogContent,
|
import { cn } from '@/lib/utils'
|
||||||
DialogFooter,
|
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||||
DialogHeader,
|
import { TreeBrowser } from './TreeBrowser'
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import {
|
import {
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
@@ -28,194 +19,31 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
sidebarMenuButtonVariants,
|
sidebarMenuButtonVariants,
|
||||||
} from '@/components/ui/sidebar'
|
} from '@/components/ui/sidebar'
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { FluxIcon } from '@/lib/icons'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
|
||||||
import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore'
|
|
||||||
|
|
||||||
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 function RecollectionSidebar() {
|
export function RecollectionSidebar() {
|
||||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
const { pathname } = useLocation()
|
const { pathname } = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const {
|
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||||
tree,
|
|
||||||
activePageId,
|
|
||||||
handleSelectPage,
|
|
||||||
handleTreeChange,
|
|
||||||
handleDeletePage,
|
|
||||||
} = useRecollectionSidebar()
|
|
||||||
|
|
||||||
const [renameTarget, setRenameTarget] = useState<LogosPageMeta | null>(null)
|
|
||||||
const [renameTitle, setRenameTitle] = useState('')
|
|
||||||
const [deleteTarget, setDeleteTarget] = useState<LogosPageMeta | null>(null)
|
|
||||||
|
|
||||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
const baseLogos = `${base}/logos`
|
const baseLogos = `${base}/logos`
|
||||||
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
||||||
const isFluxView = pathname.endsWith('/flux')
|
const isFluxView = pathname.endsWith('/flux')
|
||||||
|
|
||||||
const pages = tree.filter((p) => p.parentId === null).sort(sortByPosition)
|
const handleAddPage = useCallback(() => {
|
||||||
|
// Add a new top-level page
|
||||||
const updatePage = useCallback(
|
const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1)
|
||||||
(id: LogosPageId, patch: Partial<Pick<LogosPageMeta, 'title' | 'position'>>) => {
|
const newPage = {
|
||||||
const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p))
|
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
handleTreeChange(next)
|
title: 'Untitled',
|
||||||
},
|
|
||||||
[tree, handleTreeChange]
|
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
parentId: null,
|
||||||
position: maxPos + 1,
|
position: maxPos + 1,
|
||||||
}
|
}
|
||||||
handleTreeChange([...tree, newPage])
|
handleTreeChange([...tree, newPage])
|
||||||
setRenameTarget(newPage)
|
|
||||||
setRenameTitle(newPage.title)
|
|
||||||
handleSelectPage(newPage.id)
|
handleSelectPage(newPage.id)
|
||||||
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
|
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
|
||||||
}, [tree, pages, handleTreeChange, handleSelectPage, baseLogos, navigate])
|
}, [tree, handleTreeChange, handleSelectPage, baseLogos, navigate])
|
||||||
|
|
||||||
const startRename = useCallback((page: LogosPageMeta) => {
|
|
||||||
setRenameTarget(page)
|
|
||||||
setRenameTitle(page.title)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const commitRename = useCallback(() => {
|
|
||||||
if (renameTarget && renameTitle.trim()) {
|
|
||||||
updatePage(renameTarget.id, { title: renameTitle.trim() })
|
|
||||||
}
|
|
||||||
setRenameTarget(null)
|
|
||||||
setRenameTitle('')
|
|
||||||
}, [renameTarget, renameTitle, 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
|
|
||||||
})
|
|
||||||
handleTreeChange(withNewPositions)
|
|
||||||
},
|
|
||||||
[tree, handleTreeChange]
|
|
||||||
)
|
|
||||||
|
|
||||||
const removePage = useCallback(
|
|
||||||
(page: LogosPageMeta) => {
|
|
||||||
handleTreeChange(tree.filter((p) => p.id !== page.id))
|
|
||||||
handleDeletePage(page.id)
|
|
||||||
if (activePageId === page.id) {
|
|
||||||
const remaining = tree.filter((p) => p.id !== page.id).sort(sortByPosition)
|
|
||||||
const first = remaining[0]
|
|
||||||
if (first) {
|
|
||||||
handleSelectPage(first.id)
|
|
||||||
navigate(`${baseLogos}?page=${encodeURIComponent(first.id)}`)
|
|
||||||
} else {
|
|
||||||
navigate(baseLogos)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setDeleteTarget(null)
|
|
||||||
},
|
|
||||||
[tree, activePageId, handleTreeChange, handleDeletePage, handleSelectPage, baseLogos, navigate]
|
|
||||||
)
|
|
||||||
|
|
||||||
const onPageClick = useCallback(
|
|
||||||
(pageId: LogosPageId) => {
|
|
||||||
handleSelectPage(pageId)
|
|
||||||
navigate(`${baseLogos}?page=${encodeURIComponent(pageId)}`)
|
|
||||||
},
|
|
||||||
[handleSelectPage, baseLogos, navigate]
|
|
||||||
)
|
|
||||||
|
|
||||||
const renderPageActions = useCallback(
|
|
||||||
(page: LogosPageMeta, options: { canMoveUp: boolean; canMoveDown: boolean }) => {
|
|
||||||
const { canMoveUp, canMoveDown } = options
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-sidebar="menu-action"
|
|
||||||
className={cn(
|
|
||||||
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
|
||||||
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 shrink-0"
|
|
||||||
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={() => {
|
|
||||||
startRename(page)
|
|
||||||
onPageClick(page.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="mr-2 size-3.5" />
|
|
||||||
Rename
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={() => movePage(page.id, -1)} disabled={!canMoveUp}>
|
|
||||||
<ArrowUp className="mr-2 size-3.5" />
|
|
||||||
Move up
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => movePage(page.id, 1)} disabled={!canMoveDown}>
|
|
||||||
<ArrowDown className="mr-2 size-3.5" />
|
|
||||||
Move down
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={() => setDeleteTarget(page)} className="text-destructive focus:text-destructive">
|
|
||||||
<Trash2 className="mr-2 size-3.5" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[startRename, onPageClick, movePage]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -224,54 +52,21 @@ export function RecollectionSidebar() {
|
|||||||
>
|
>
|
||||||
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
|
<SidebarContent className="flex-1 overflow-y-auto border-0 bg-transparent">
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
|
<div className="flex items-center justify-between gap-2 py-1.5">
|
||||||
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel>
|
<SidebarGroupLabel className="py-0">Logos</SidebarGroupLabel>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
onClick={addPage}
|
onClick={handleAddPage}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
New page
|
New page
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<TreeBrowser />
|
||||||
{pages.length === 0 ? (
|
|
||||||
<div className="px-2 py-2 text-center text-xs text-sidebar-foreground/70">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="link"
|
|
||||||
className="h-auto p-0 text-xs text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
|
||||||
onClick={addPage}
|
|
||||||
>
|
|
||||||
Create your first page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
pages.map((page) => {
|
|
||||||
const canMoveUp = pages.indexOf(page) > 0
|
|
||||||
const canMoveDown = pages.indexOf(page) < pages.length - 1
|
|
||||||
const isActive = pathname.startsWith(baseLogos) && !isKatalogosView && activePageId === page.id
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={page.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-active={isActive}
|
|
||||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
|
||||||
onClick={() => onPageClick(page.id)}
|
|
||||||
>
|
|
||||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
|
||||||
<span className="truncate">{page.title || DEFAULT_PAGE_TITLE}</span>
|
|
||||||
</button>
|
|
||||||
{renderPageActions(page, { canMoveUp, canMoveDown })}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
@@ -286,7 +81,7 @@ export function RecollectionSidebar() {
|
|||||||
onClick={() => navigate(`${base}/logos/katalogos`)}
|
onClick={() => navigate(`${base}/logos/katalogos`)}
|
||||||
>
|
>
|
||||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
<span className="truncate">Katalogos</span>
|
<span className="truncate">Artifacts</span>
|
||||||
</button>
|
</button>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -304,76 +99,13 @@ export function RecollectionSidebar() {
|
|||||||
onClick={() => navigate(`${base}/flux`)}
|
onClick={() => navigate(`${base}/flux`)}
|
||||||
>
|
>
|
||||||
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
<span className="truncate">Flux</span>
|
<span className="truncate">Canvas</span>
|
||||||
</button>
|
</button>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={!!renameTarget}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setRenameTarget(null)
|
|
||||||
setRenameTitle('')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Rename page</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full rounded border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
value={renameTitle}
|
|
||||||
onChange={(e) => setRenameTitle(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') commitRename()
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
setRenameTarget(null)
|
|
||||||
setRenameTitle('')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
aria-label="Page title"
|
|
||||||
/>
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
|
||||||
<Button type="button" variant="outline" onClick={() => { setRenameTarget(null); setRenameTitle('') }}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={commitRename}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Delete page?</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
{deleteTarget && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
"{deleteTarget.title}" 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
609
frontend/src/app/recollections/layout/TreeBrowser.tsx
Normal file
609
frontend/src/app/recollections/layout/TreeBrowser.tsx
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
/**
|
||||||
|
* TreeBrowser: A tree browser component using react-arborist for managing
|
||||||
|
* Logos pages in a hierarchical folder structure.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Drag and drop reordering
|
||||||
|
* - Folder expansion/collapse
|
||||||
|
* - Context menu for actions
|
||||||
|
* - Shadcn theme integration
|
||||||
|
* - Inline editing for page titles
|
||||||
|
* - Smooth animations
|
||||||
|
* - Search functionality
|
||||||
|
* - Expand/collapse all
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||||
|
import {
|
||||||
|
Tree,
|
||||||
|
NodeApi,
|
||||||
|
NodeRendererProps,
|
||||||
|
RowRendererProps,
|
||||||
|
TreeApi,
|
||||||
|
type CursorProps,
|
||||||
|
} from 'react-arborist'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
|
MoreHorizontal,
|
||||||
|
Trash2,
|
||||||
|
Pencil,
|
||||||
|
Search,
|
||||||
|
ChevronsDownUp,
|
||||||
|
ChevronsUpDown,
|
||||||
|
X,
|
||||||
|
GripVertical,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||||
|
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import type { LogosPageMeta } from '../state/recollectionStore'
|
||||||
|
import { removeLogosPageContent } from '../state/recollectionStore'
|
||||||
|
|
||||||
|
// Tree node: page metadata plus tree shape (react-arborist `node.data` is this whole object).
|
||||||
|
type TreeNode = LogosPageMeta & {
|
||||||
|
children?: TreeNode[]
|
||||||
|
isFolder: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert flat tree to hierarchical structure
|
||||||
|
function buildTree(pages: LogosPageMeta[]): TreeNode[] {
|
||||||
|
const pageMap = new Map<string, TreeNode>()
|
||||||
|
const rootNodes: TreeNode[] = []
|
||||||
|
|
||||||
|
// First pass: create all nodes
|
||||||
|
pages.forEach((page) => {
|
||||||
|
pageMap.set(page.id, {
|
||||||
|
...page,
|
||||||
|
children: [],
|
||||||
|
isFolder: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Second pass: build parent-child relationships
|
||||||
|
pages.forEach((page) => {
|
||||||
|
const node = pageMap.get(page.id)!
|
||||||
|
if (page.parentId === null) {
|
||||||
|
rootNodes.push(node)
|
||||||
|
} else {
|
||||||
|
const parent = pageMap.get(page.parentId)
|
||||||
|
if (parent) {
|
||||||
|
parent.children?.push(node)
|
||||||
|
parent.isFolder = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort children by position
|
||||||
|
function sortNodes(nodes: TreeNode[]) {
|
||||||
|
nodes.sort((a, b) => a.position - b.position)
|
||||||
|
nodes.forEach((node) => sortNodes(node.children || []))
|
||||||
|
}
|
||||||
|
sortNodes(rootNodes)
|
||||||
|
|
||||||
|
return rootNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoize the buildTree result to avoid unnecessary re-creation
|
||||||
|
function useTreeNodes(tree: LogosPageMeta[]) {
|
||||||
|
return useMemo(() => buildTree(tree), [tree])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline editable title component
|
||||||
|
function EditableTitle({
|
||||||
|
title,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
onSave: (newTitle: string) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
inputRef.current?.select()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onSave(inputRef.current?.value || '')
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
defaultValue={title}
|
||||||
|
onBlur={(e) => onSave(e.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROW_INDENT_PX = 20
|
||||||
|
const ROW_GUTTER_PX = 8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line cursor = “insert as sibling at this indent” (not “drop into folder”).
|
||||||
|
* Triangle + dashed rail read as a slot between rows; matches sidebar theme.
|
||||||
|
*/
|
||||||
|
const LogosDropCursor = React.memo(function LogosDropCursor({ top, left, indent }: CursorProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute z-20 flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
top: top - 5,
|
||||||
|
left: left + ROW_GUTTER_PX,
|
||||||
|
right: Math.max(indent, ROW_GUTTER_PX),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex h-3 w-2.5 shrink-0 items-center justify-center text-primary drop-shadow-sm">
|
||||||
|
<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M0 5 L9 1.5 L9 8.5 Z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="flex h-3 min-w-[2rem] flex-1 items-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-0 w-full border-t-2 border-dashed border-primary',
|
||||||
|
'shadow-[0_1px_0_0_hsl(var(--primary)/0.35)]',
|
||||||
|
'motion-safe:animate-[tree-drop-line-pulse_1.8s_ease-in-out_infinite]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag ref on a compact handle at the start of the row; handle is a floating pill shown on row hover
|
||||||
|
* (always visible on coarse pointers). Title/icon strip is click-to-navigate only.
|
||||||
|
*/
|
||||||
|
function LogosTreeNode({ node, dragHandle, style }: NodeRendererProps<TreeNode>) {
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||||
|
|
||||||
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
|
const baseLogos = `${base}/logos`
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
node.toggle()
|
||||||
|
},
|
||||||
|
[node]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!node.state.isEditing && !node.state.isDragging) {
|
||||||
|
handleSelectPage(node.data.id)
|
||||||
|
navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRename = useCallback(() => {
|
||||||
|
node.edit()
|
||||||
|
}, [node])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
const toDelete = new Set<string>()
|
||||||
|
function collectDescendants(id: string) {
|
||||||
|
toDelete.add(id)
|
||||||
|
tree.forEach((p) => {
|
||||||
|
if (p.parentId === id) collectDescendants(p.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
collectDescendants(node.data.id)
|
||||||
|
if (recollectionId) {
|
||||||
|
toDelete.forEach((pageId) => {
|
||||||
|
removeLogosPageContent(recollectionId, pageId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
handleTreeChange(tree.filter((p) => !toDelete.has(p.id)))
|
||||||
|
}, [tree, handleTreeChange, node.data.id, recollectionId])
|
||||||
|
|
||||||
|
const handleAddChild = useCallback(() => {
|
||||||
|
const maxPos = Math.max(
|
||||||
|
0,
|
||||||
|
...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position),
|
||||||
|
-1
|
||||||
|
)
|
||||||
|
const newPage: LogosPageMeta = {
|
||||||
|
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
title: 'Untitled',
|
||||||
|
parentId: node.data.id,
|
||||||
|
position: maxPos + 1,
|
||||||
|
}
|
||||||
|
handleTreeChange([...tree, newPage])
|
||||||
|
if (!node.isOpen) node.open()
|
||||||
|
}, [tree, handleTreeChange, node])
|
||||||
|
|
||||||
|
const hasChildren = (node.children?.length || 0) > 0
|
||||||
|
const pageTitle = node.data.title || 'Untitled'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||||
|
{/* Floating drag pill: expands on row hover; coarse pointers keep it tappable */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center justify-center overflow-hidden',
|
||||||
|
'transition-[width,opacity] duration-200 ease-out',
|
||||||
|
'w-0 opacity-0 group-hover:w-4 group-hover:opacity-100',
|
||||||
|
'[@media(pointer:coarse)]:w-5 [@media(pointer:coarse)]:opacity-100',
|
||||||
|
node.state.isDragging && 'w-4 opacity-100 [@media(pointer:coarse)]:w-5',
|
||||||
|
node.state.isEditing && 'pointer-events-none w-0 opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dragHandle}
|
||||||
|
style={{ ...style, paddingLeft: 0 }}
|
||||||
|
title={node.state.isEditing ? undefined : 'Drag to reorder'}
|
||||||
|
className={cn(
|
||||||
|
'logos-tree-drag-handle group/drag',
|
||||||
|
'relative flex h-6 w-4 shrink-0 touch-none items-center justify-center rounded-md',
|
||||||
|
'[@media(pointer:coarse)]:h-7 [@media(pointer:coarse)]:w-5',
|
||||||
|
'border border-sidebar-border/50 bg-sidebar-accent/40',
|
||||||
|
'text-sidebar-foreground/70',
|
||||||
|
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out',
|
||||||
|
'hover:border-sidebar-border hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
|
'hover:shadow-sm',
|
||||||
|
'active:scale-[0.97] active:cursor-grabbing',
|
||||||
|
'cursor-grab outline-none',
|
||||||
|
'dark:border-sidebar-border/55 dark:bg-sidebar-accent/35 dark:text-sidebar-foreground/75',
|
||||||
|
'dark:hover:bg-sidebar-accent dark:hover:text-sidebar-accent-foreground',
|
||||||
|
node.state.isDragging &&
|
||||||
|
'cursor-grabbing border-primary/55 bg-primary/18 text-primary shadow-sm ring-1 ring-primary/30 dark:border-primary/50 dark:bg-primary/22 dark:text-primary'
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<GripVertical
|
||||||
|
className={cn(
|
||||||
|
'size-3 shrink-0 text-current transition-opacity duration-150 opacity-85 group-hover/drag:opacity-100',
|
||||||
|
node.state.isDragging && 'opacity-100'
|
||||||
|
)}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||||
|
onClick={handleToggle}
|
||||||
|
>
|
||||||
|
{node.isOpen ? (
|
||||||
|
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[28px] min-w-0 flex-1 items-center gap-1 rounded-md px-0.5 -mx-0.5',
|
||||||
|
node.state.isEditing ? 'cursor-text' : 'cursor-pointer hover:bg-sidebar-accent/40'
|
||||||
|
)}
|
||||||
|
onClick={handleSelect}
|
||||||
|
>
|
||||||
|
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{node.state.isEditing ? (
|
||||||
|
<EditableTitle
|
||||||
|
title={node.data.title}
|
||||||
|
onSave={(newTitle) => {
|
||||||
|
if (newTitle.trim()) {
|
||||||
|
handleTreeChange(
|
||||||
|
tree.map((p) => (p.id === node.data.id ? { ...p, title: newTitle.trim() } : p))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
node.submit(newTitle)
|
||||||
|
}}
|
||||||
|
onCancel={() => node.reset()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="block truncate select-none">{pageTitle}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleAddChild()
|
||||||
|
}}
|
||||||
|
title="Add child page"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
</button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenuItem onClick={handleRename}>
|
||||||
|
<Pencil className="mr-2 size-3.5" />
|
||||||
|
Rename
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleDelete} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="mr-2 size-3.5" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop target + layout shell; `children` must be the Node renderer (drag layer). */
|
||||||
|
function TreeRow({ node, innerRef, attrs, children }: RowRendererProps<TreeNode>) {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
const { activePageId } = useRecollectionSidebar()
|
||||||
|
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||||
|
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||||
|
const baseLogos = `${base}/logos`
|
||||||
|
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
|
||||||
|
const level = node.level
|
||||||
|
const indentPx = ROW_GUTTER_PX + level * ROW_INDENT_PX
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={innerRef}
|
||||||
|
{...attrs}
|
||||||
|
style={{
|
||||||
|
...attrs.style,
|
||||||
|
paddingLeft: `${indentPx}px`,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md transition-[box-shadow,background-color,ring-color] duration-150',
|
||||||
|
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||||
|
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
||||||
|
node.state.isDragging && 'cursor-grabbing opacity-45',
|
||||||
|
// Folder drop target: index === null in library — reads as “open container”, not a line between rows
|
||||||
|
node.state.willReceiveDrop &&
|
||||||
|
'cursor-copy bg-sidebar-accent/30 ring-2 ring-inset ring-dashed ring-sidebar-ring/80 shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border))]'
|
||||||
|
)}
|
||||||
|
title={node.state.willReceiveDrop ? 'Release to drop inside this folder' : undefined}
|
||||||
|
>
|
||||||
|
{level > 0 && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute bottom-0 top-0 border-l-2 border-sidebar-border/45"
|
||||||
|
style={{ left: `${ROW_GUTTER_PX + (level - 1) * ROW_INDENT_PX + ROW_INDENT_PX / 2}px` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TreeBrowser() {
|
||||||
|
const { tree, handleTreeChange } = useRecollectionSidebar()
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||||
|
const [treeContainerHeight, treeContainerRef] = useResizeHeight(600)
|
||||||
|
|
||||||
|
// Build tree from flat structure
|
||||||
|
const treeNodes = useTreeNodes(tree)
|
||||||
|
|
||||||
|
// Filter tree based on search query
|
||||||
|
const filteredTree = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) return treeNodes
|
||||||
|
|
||||||
|
const searchTerm = searchQuery.toLowerCase()
|
||||||
|
|
||||||
|
function filterNodes(nodes: TreeNode[]): TreeNode[] {
|
||||||
|
const result: TreeNode[] = []
|
||||||
|
for (const node of nodes) {
|
||||||
|
const matches = node.title.toLowerCase().includes(searchTerm)
|
||||||
|
const children = filterNodes(node.children || [])
|
||||||
|
|
||||||
|
if (matches || children.length > 0) {
|
||||||
|
result.push({
|
||||||
|
...node,
|
||||||
|
children: children.length > 0 ? children : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return filterNodes(treeNodes)
|
||||||
|
}, [treeNodes, searchQuery])
|
||||||
|
|
||||||
|
// Get all folder IDs for initial open state
|
||||||
|
const initialOpenState = useMemo(() => {
|
||||||
|
const folderIds: Record<string, boolean> = {}
|
||||||
|
function collectIds(nodes: TreeNode[]) {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.isFolder) {
|
||||||
|
folderIds[node.id] = true
|
||||||
|
collectIds(node.children || [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
collectIds(treeNodes)
|
||||||
|
return folderIds
|
||||||
|
}, [treeNodes])
|
||||||
|
|
||||||
|
// Handle drag and drop reordering (react-arborist onMove)
|
||||||
|
const handleMove = useCallback(
|
||||||
|
({
|
||||||
|
dragIds,
|
||||||
|
parentId: newParentId,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
dragIds: string[]
|
||||||
|
parentId: string | null
|
||||||
|
index: number
|
||||||
|
}) => {
|
||||||
|
const dragId = dragIds[0]
|
||||||
|
if (!dragId) return
|
||||||
|
|
||||||
|
const dragged = tree.find((p) => p.id === dragId)
|
||||||
|
if (!dragged) return
|
||||||
|
|
||||||
|
const oldParentId = dragged.parentId
|
||||||
|
const moved: LogosPageMeta = { ...dragged, parentId: newParentId }
|
||||||
|
|
||||||
|
const newSiblings = tree
|
||||||
|
.filter((p) => p.parentId === newParentId && p.id !== dragId)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
newSiblings.splice(index, 0, moved)
|
||||||
|
|
||||||
|
const updates = new Map<string, LogosPageMeta>()
|
||||||
|
newSiblings.forEach((p, i) => {
|
||||||
|
updates.set(p.id, { ...p, parentId: newParentId, position: i })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (oldParentId !== newParentId) {
|
||||||
|
tree
|
||||||
|
.filter((p) => p.parentId === oldParentId && p.id !== dragId)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.forEach((p, i) => {
|
||||||
|
updates.set(p.id, { ...p, position: i })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTreeChange(tree.map((p) => updates.get(p.id) ?? p))
|
||||||
|
},
|
||||||
|
[tree, handleTreeChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expand all folders
|
||||||
|
const handleExpandAll = useCallback(() => {
|
||||||
|
treeRef.current?.openAll()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Collapse all folders
|
||||||
|
const handleCollapseAll = useCallback(() => {
|
||||||
|
treeRef.current?.closeAll()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchQuery('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Fixed prop shape: react-arborist TreeProvider uses [...Object.values(treeProps), …] as useMemo deps;
|
||||||
|
// varying key counts on the props object trigger "dependency array changed size" warnings.
|
||||||
|
const arboristTreeProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
data: filteredTree,
|
||||||
|
idAccessor: 'id' as const,
|
||||||
|
childrenAccessor: 'children' as const,
|
||||||
|
width: '100%' as const,
|
||||||
|
height: Math.max(treeContainerHeight, 100),
|
||||||
|
rowHeight: 32,
|
||||||
|
indent: ROW_INDENT_PX,
|
||||||
|
renderRow: TreeRow,
|
||||||
|
initialOpenState,
|
||||||
|
onMove: handleMove,
|
||||||
|
disableDrag: Boolean(searchQuery),
|
||||||
|
disableDrop: Boolean(searchQuery),
|
||||||
|
className: 'react-arborist-tree',
|
||||||
|
renderCursor: LogosDropCursor,
|
||||||
|
children: LogosTreeNode,
|
||||||
|
}),
|
||||||
|
[filteredTree, initialOpenState, handleMove, searchQuery, treeContainerHeight]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 h-full">
|
||||||
|
{/* Search Toolbar */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search pages..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
title="Expand/Collapse options"
|
||||||
|
>
|
||||||
|
<ChevronsUpDown className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={handleExpandAll}>
|
||||||
|
<ChevronsDownUp className="mr-2 size-3.5" />
|
||||||
|
Expand all
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||||
|
<ChevronsUpDown className="mr-2 size-3.5" />
|
||||||
|
Collapse all
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree */}
|
||||||
|
<div ref={treeContainerRef} className="flex-1 min-h-0 overflow-hidden">
|
||||||
|
<Tree ref={treeRef} {...arboristTreeProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -73,6 +73,66 @@ function getRenderCacheKey(recollectionId: string): string {
|
|||||||
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
|
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 {
|
export function getGraph(recollectionId: string): StoredGraphState | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(getGraphKey(recollectionId))
|
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))
|
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Logos Storage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function getLogosContent(recollectionId: string): StoredLogosContent | null {
|
export function getLogosContent(recollectionId: string): StoredLogosContent | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(getLogosKey(recollectionId))
|
const raw = localStorage.getItem(getLogosKey(recollectionId))
|
||||||
@@ -167,6 +231,10 @@ export function removeLogosPageContent(recollectionId: string, pageId: LogosPage
|
|||||||
localStorage.removeItem(getLogosPageContentKey(recollectionId, pageId))
|
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. */
|
/** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */
|
||||||
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
|
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
|
||||||
try {
|
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. */
|
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */
|
||||||
export function removeRecollectionData(recollectionId: string): void {
|
export function removeRecollectionData(recollectionId: string): void {
|
||||||
localStorage.removeItem(getGraphKey(recollectionId))
|
localStorage.removeItem(getGraphKey(recollectionId))
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/g
|
|||||||
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
|
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
|
||||||
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike } from '@/lib/graph/templateRefs'
|
import { isReachable, resolveExtendsRef, getTemplateRefs, type NodeLike } from '@/lib/graph/templateRefs'
|
||||||
|
|
||||||
|
// Module-level cache for resolved Nunjucks output.
|
||||||
|
// Keyed by a fingerprint of all inputs (template contents + variable/data values + function bodies).
|
||||||
|
// Avoids re-running the Nunjucks environment when the same inputs are seen again (e.g. undo/redo,
|
||||||
|
// multiple rendering nodes sharing the same config, rapid edits cycling back to a prior value).
|
||||||
|
const MAX_RESOLVE_CACHE = 200
|
||||||
|
const resolveCache = new Map<string, ResolvedContentResult>()
|
||||||
|
|
||||||
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
|
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
|
||||||
const { nodes, edges, sourceNodeId, renderNodeId } = context
|
const { nodes, edges, sourceNodeId, renderNodeId } = context
|
||||||
const srcId = sourceNodeId
|
const srcId = sourceNodeId
|
||||||
@@ -107,6 +114,24 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build cache key from all inputs that affect the resolved output.
|
||||||
|
const allTemplateContents = [...configIdsUsed]
|
||||||
|
.map((cid) => {
|
||||||
|
const node = nodes.find((n) => n.id === cid)
|
||||||
|
return getConfigContent((node?.data ?? undefined) as Record<string, unknown> | undefined)
|
||||||
|
})
|
||||||
|
.join('\x00')
|
||||||
|
const allFunctionBodies = [...functionIdsToRegister]
|
||||||
|
.map((fid) => {
|
||||||
|
const node = nodes.find((n) => n.id === fid)
|
||||||
|
return ((node?.data as Record<string, unknown>)?.body as string) ?? ''
|
||||||
|
})
|
||||||
|
.join('\x00')
|
||||||
|
const cacheKey = `${srcId}\x01${JSON.stringify(nunjucksContext)}\x01${allTemplateContents}\x01${allFunctionBodies}`
|
||||||
|
|
||||||
|
const cached = resolveCache.get(cacheKey)
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
const env = new nunjucks.Environment([configLoader], { autoescape: false })
|
const env = new nunjucks.Environment([configLoader], { autoescape: false })
|
||||||
|
|
||||||
const formatFilterResult = (r: unknown): string => {
|
const formatFilterResult = (r: unknown): string => {
|
||||||
@@ -248,7 +273,13 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
|
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
|
||||||
resolve({ resolved, outputTypeId })
|
const result: ResolvedContentResult = { resolved, outputTypeId }
|
||||||
|
// FIFO eviction when cache is full
|
||||||
|
if (resolveCache.size >= MAX_RESOLVE_CACHE) {
|
||||||
|
resolveCache.delete(resolveCache.keys().next().value as string)
|
||||||
|
}
|
||||||
|
resolveCache.set(cacheKey, result)
|
||||||
|
resolve(result)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,61 @@
|
|||||||
* runs the resolve → render pipeline, manages streaming/cache,
|
* runs the resolve → render pipeline, manages streaming/cache,
|
||||||
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
||||||
* pipeline interface.
|
* pipeline interface.
|
||||||
|
*
|
||||||
|
* ## Auto vs Manual Mode Decision Tree
|
||||||
|
*
|
||||||
|
* The hook supports two update modes: 'auto' and 'manual'. The mode is determined by:
|
||||||
|
*
|
||||||
|
* 1. **Node data override**: Check `data?.updateMode` first
|
||||||
|
* 2. **Source logic default**: Fall back to `sourceLogic?.defaultUpdateMode`
|
||||||
|
* 3. **Hardcoded default**: Default to 'auto' if neither is set
|
||||||
|
*
|
||||||
|
* ### Auto Mode Behavior
|
||||||
|
* - Runs automatically when upstream data changes (sourceSignature changes)
|
||||||
|
* - Debounces runs by 250ms to avoid excessive computation
|
||||||
|
* - Skips runs when node becomes visible with cache (visibility grace period: 400ms)
|
||||||
|
* - Updates path trigger on each run
|
||||||
|
*
|
||||||
|
* ### Manual Mode Behavior
|
||||||
|
* - Only runs when `runTrigger` increments (user clicks "Run")
|
||||||
|
* - Requires explicit user action to update output
|
||||||
|
* - Caches last run signature to prevent re-runs on unrelated changes
|
||||||
|
*
|
||||||
|
* ## Effect Dependencies
|
||||||
|
*
|
||||||
|
* The main effect depends on:
|
||||||
|
* - `id`: Node ID (re-run when node changes)
|
||||||
|
* - `srcId`: Source node ID (re-run when connection changes)
|
||||||
|
* - `srcNode?.type`: Source node type (re-run when source type changes)
|
||||||
|
* - `effectiveUpdateMode`: Auto vs manual mode
|
||||||
|
* - `runTrigger`: Run trigger counter (for manual mode)
|
||||||
|
* - `sourceSignature`: Combined signature of all upstream nodes
|
||||||
|
* - `viewportWidth/Height`: Viewport dimensions (affects rendering)
|
||||||
|
* - `retryCount`: Retry counter (for error recovery)
|
||||||
|
* - `incomingIds.length`: Number of incoming connections
|
||||||
|
*
|
||||||
|
* ## Rendering Pipeline
|
||||||
|
*
|
||||||
|
* 1. **Resolve**: Source node (config/agent) produces resolved content
|
||||||
|
* 2. **Render**: Output type renderer (plantuml/markdown/wireframe) produces HTML/SVG
|
||||||
|
* 3. **Cache**: Results are cached in node data for persistence
|
||||||
|
* 4. **Display**: UI consumes the rendered content
|
||||||
|
*
|
||||||
|
* ## Streaming Support
|
||||||
|
*
|
||||||
|
* When the source node supports streaming (e.g., agent nodes):
|
||||||
|
* - `onStreamingStart`: Called when streaming begins
|
||||||
|
* - `onStreamingChunk`: Called for each chunk of markdown content
|
||||||
|
* - Streaming content is parsed with marked.js for preview
|
||||||
|
*
|
||||||
|
* ## Error Handling
|
||||||
|
*
|
||||||
|
* Errors during resolve or render are caught and stored in the `error` state.
|
||||||
|
* The error includes a `kind` (e.g., 'render', 'no-content') and `message`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||||
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
@@ -124,8 +176,11 @@ export function useRenderingNodeState(
|
|||||||
|
|
||||||
// Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data).
|
// Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data).
|
||||||
// Context only exposes a ref, so we wouldn't re-render when another node updates otherwise.
|
// Context only exposes a ref, so we wouldn't re-render when another node updates otherwise.
|
||||||
const storeNodes = useCanvasStore((s) => s.graph.nodes)
|
// Single combined subscription (vs two separate) halves listener overhead; useShallow prevents
|
||||||
const storeEdges = useCanvasStore((s) => s.graph.edges)
|
// re-renders when non-graph slices (ui/path) change.
|
||||||
|
const { storeNodes, storeEdges } = useCanvasStore(
|
||||||
|
useShallow((s) => ({ storeNodes: s.graph.nodes, storeEdges: s.graph.edges }))
|
||||||
|
)
|
||||||
const nodes = storeNodes.length > 0 ? storeNodes : contextNodes
|
const nodes = storeNodes.length > 0 ? storeNodes : contextNodes
|
||||||
const edges = storeEdges.length > 0 ? storeEdges : contextEdges
|
const edges = storeEdges.length > 0 ? storeEdges : contextEdges
|
||||||
|
|
||||||
|
|||||||
86
frontend/src/hooks/useStreamingContent.ts
Normal file
86
frontend/src/hooks/useStreamingContent.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Hook for parsing and streaming markdown content.
|
||||||
|
* Handles marked.js parsing with proper cleanup and cancellation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown content to HTML using marked.js.
|
||||||
|
* Returns parsed HTML string and handles cleanup on unmount.
|
||||||
|
*
|
||||||
|
* @param markdown - The markdown string to parse
|
||||||
|
* @returns HTML string from parsed markdown
|
||||||
|
*/
|
||||||
|
export function useStreamingContent(markdown: string | null): {
|
||||||
|
html: string
|
||||||
|
loading: boolean
|
||||||
|
} {
|
||||||
|
const [html, setHtml] = useState<string>('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (markdown === null) {
|
||||||
|
setHtml('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
// Lazy load marked.js to avoid bundling if not needed
|
||||||
|
import('marked')
|
||||||
|
.then(async ({ marked }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed =
|
||||||
|
typeof marked.parse === 'function'
|
||||||
|
? await (marked.parse as (s: string) => Promise<string>)(markdown)
|
||||||
|
: (marked as (s: string) => string)(markdown)
|
||||||
|
const result = typeof parsed === 'string' ? parsed : String(parsed)
|
||||||
|
if (!cancelled) {
|
||||||
|
setHtml(result)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
// Fallback: use raw markdown if parsing fails
|
||||||
|
setHtml(markdown)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
// Fallback: use raw markdown if import fails
|
||||||
|
setHtml(markdown)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [markdown])
|
||||||
|
|
||||||
|
return { html, loading }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown content to HTML using marked.js (synchronous version).
|
||||||
|
* Use this when you need immediate parsing without React state.
|
||||||
|
*
|
||||||
|
* @param markdown - The markdown string to parse
|
||||||
|
* @returns HTML string from parsed markdown, or raw markdown on error
|
||||||
|
*/
|
||||||
|
export function parseMarkdownToHtml(markdown: string): string {
|
||||||
|
try {
|
||||||
|
// Use dynamic import for marked.js
|
||||||
|
// Note: This is a helper for non-React contexts
|
||||||
|
// In React components, use useStreamingContent hook instead
|
||||||
|
return markdown // Placeholder - actual implementation requires async import
|
||||||
|
} catch {
|
||||||
|
return markdown
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/src/hooks/useThinkSections.ts
Normal file
30
frontend/src/hooks/useThinkSections.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Hook for parsing markdown content with think sections.
|
||||||
|
* Extracts <think>...</think> blocks and returns main content and reasoning separately.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { parseThinkSections } from '@/lib/graph/renderingUtils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse markdown content and extract think sections.
|
||||||
|
* Returns main content (without think blocks) and reasoning content (from think blocks).
|
||||||
|
*
|
||||||
|
* @param content - The HTML/markdown content to parse
|
||||||
|
* @param outputType - The output type ('image' or 'string')
|
||||||
|
* @returns Object with main and think content
|
||||||
|
*/
|
||||||
|
export function useThinkSections(
|
||||||
|
content: string | null,
|
||||||
|
outputType: 'image' | 'string'
|
||||||
|
): {
|
||||||
|
main: string
|
||||||
|
think: string
|
||||||
|
} {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!content || outputType === 'image') {
|
||||||
|
return { main: '', think: '' }
|
||||||
|
}
|
||||||
|
return parseThinkSections(content)
|
||||||
|
}, [content, outputType])
|
||||||
|
}
|
||||||
169
frontend/src/lib/errorBoundary.tsx
Normal file
169
frontend/src/lib/errorBoundary.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Error boundary utilities for React components.
|
||||||
|
* Provides consistent error handling across the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error state for error boundary components
|
||||||
|
*/
|
||||||
|
export type ErrorState = {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
errorInfo: React.ErrorInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial error state
|
||||||
|
*/
|
||||||
|
export const initialErrorState: ErrorState = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary props
|
||||||
|
*/
|
||||||
|
export type ErrorBoundaryProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
fallback?: React.ReactNode
|
||||||
|
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary state
|
||||||
|
*/
|
||||||
|
export type ErrorBoundaryState = ErrorState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary component
|
||||||
|
* Catches JavaScript errors anywhere in the child component tree
|
||||||
|
* and displays a fallback UI instead of crashing
|
||||||
|
*/
|
||||||
|
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props)
|
||||||
|
this.state = initialErrorState
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return {
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
errorInfo: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||||
|
this.setState({
|
||||||
|
hasError: true,
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log error to console or error reporting service
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||||
|
|
||||||
|
// Call custom error handler if provided
|
||||||
|
if (this.props.onError) {
|
||||||
|
this.props.onError(error, errorInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReset = (): void => {
|
||||||
|
this.setState(initialErrorState)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
// You can render any custom fallback UI
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="error-boundary">
|
||||||
|
<h1>Something went wrong.</h1>
|
||||||
|
<details>
|
||||||
|
<summary>Error Details</summary>
|
||||||
|
<pre>{this.state.error?.toString()}</pre>
|
||||||
|
<pre>{this.state.errorInfo?.componentStack}</pre>
|
||||||
|
</details>
|
||||||
|
<button onClick={this.handleReset}>Try Again</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for error handling in functional components
|
||||||
|
*/
|
||||||
|
export function useErrorHandler<T extends Error = Error>(): {
|
||||||
|
error: T | null
|
||||||
|
setError: (error: T | null) => void
|
||||||
|
hasError: boolean
|
||||||
|
resetError: () => void
|
||||||
|
} {
|
||||||
|
const [error, setError] = React.useState<T | null>(null)
|
||||||
|
|
||||||
|
const hasError = error !== null
|
||||||
|
const resetError = React.useCallback(() => setError(null), [])
|
||||||
|
|
||||||
|
return { error, setError, hasError, resetError }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to update error state consistently
|
||||||
|
*/
|
||||||
|
export function updateErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null | { kind: string; message: string }) => void,
|
||||||
|
error: unknown
|
||||||
|
): void {
|
||||||
|
setError({
|
||||||
|
kind: 'render',
|
||||||
|
message: (error as { message?: string })?.message ?? 'Render error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to clear error state
|
||||||
|
*/
|
||||||
|
export function clearErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null) => void
|
||||||
|
): void {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to check if an error is recoverable
|
||||||
|
*/
|
||||||
|
export function isRecoverableError(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Error)) return false
|
||||||
|
// Common recoverable errors
|
||||||
|
const recoverableMessages = [
|
||||||
|
'Network error',
|
||||||
|
'Timeout',
|
||||||
|
'Connection refused',
|
||||||
|
'Rate limit exceeded',
|
||||||
|
]
|
||||||
|
return recoverableMessages.some((msg) => (error as Error).message.includes(msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to format error message for display
|
||||||
|
*/
|
||||||
|
export function formatErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
return 'An unknown error occurred'
|
||||||
|
}
|
||||||
155
frontend/src/lib/errorHandling.ts
Normal file
155
frontend/src/lib/errorHandling.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Error handling utilities for consistent error management.
|
||||||
|
* Provides centralized error handling logic across the application.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error kind for categorizing errors
|
||||||
|
*/
|
||||||
|
export type ErrorKind =
|
||||||
|
| 'render'
|
||||||
|
| 'network'
|
||||||
|
| 'validation'
|
||||||
|
| 'authentication'
|
||||||
|
| 'authorization'
|
||||||
|
| 'notFound'
|
||||||
|
| 'server'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error object with kind and message
|
||||||
|
*/
|
||||||
|
export type AppError = {
|
||||||
|
kind: ErrorKind
|
||||||
|
message: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an error object with consistent structure
|
||||||
|
*/
|
||||||
|
export function createAppError(
|
||||||
|
kind: ErrorKind,
|
||||||
|
message: string,
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
): AppError {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract error message from unknown error
|
||||||
|
*/
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||||
|
return String(error.message)
|
||||||
|
}
|
||||||
|
return 'An unknown error occurred'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract error kind from error
|
||||||
|
*/
|
||||||
|
export function getErrorKind(error: unknown): ErrorKind {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase()
|
||||||
|
if (message.includes('network')) return 'network'
|
||||||
|
if (message.includes('validation') || message.includes('invalid')) return 'validation'
|
||||||
|
if (message.includes('auth')) return 'authentication'
|
||||||
|
if (message.includes('forbidden') || message.includes('unauthorized')) return 'authorization'
|
||||||
|
if (message.includes('not found') || message.includes('404')) return 'notFound'
|
||||||
|
if (message.includes('server') || message.includes('500')) return 'server'
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format error for display
|
||||||
|
*/
|
||||||
|
export function formatError(error: AppError | Error | string): string {
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is recoverable
|
||||||
|
*/
|
||||||
|
export function isRecoverableError(error: AppError | Error | string): boolean {
|
||||||
|
if (typeof error === 'string') return false
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase()
|
||||||
|
return ['network', 'timeout', 'connection'].some((term) => message.includes(term))
|
||||||
|
}
|
||||||
|
// error is AppError at this point
|
||||||
|
return ['network', 'server'].includes((error as AppError).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update error state consistently
|
||||||
|
*/
|
||||||
|
export function updateErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null | { kind: string; message: string }) => void,
|
||||||
|
error: unknown
|
||||||
|
): void {
|
||||||
|
setError({
|
||||||
|
kind: 'render',
|
||||||
|
message: getErrorMessage(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear error state
|
||||||
|
*/
|
||||||
|
export function clearErrorState<T extends { error?: unknown }>(
|
||||||
|
state: T,
|
||||||
|
setError: (error: null) => void
|
||||||
|
): void {
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle error with consistent logging
|
||||||
|
*/
|
||||||
|
export function handleError(
|
||||||
|
error: unknown,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): AppError {
|
||||||
|
const appError = createAppError(
|
||||||
|
getErrorKind(error),
|
||||||
|
getErrorMessage(error),
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
console.error('[Error]', appError, context)
|
||||||
|
return appError
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create error handler for async functions
|
||||||
|
*/
|
||||||
|
export function createErrorHandler<T>(
|
||||||
|
onError?: (error: AppError) => void
|
||||||
|
): (error: unknown) => T {
|
||||||
|
return (error: unknown) => {
|
||||||
|
const appError = handleError(error)
|
||||||
|
onError?.(appError)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,13 @@ export type ConfigType = {
|
|||||||
|
|
||||||
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
||||||
const KROKI_TIMEOUT_MS = 15000
|
const KROKI_TIMEOUT_MS = 15000
|
||||||
|
const KROKI_CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
// In-flight deduplication: if the same PlantUML content is already being fetched,
|
||||||
|
// reuse the existing promise instead of firing a duplicate request.
|
||||||
|
const krokiInflight = new Map<string, Promise<string>>()
|
||||||
|
// TTL result cache: avoids re-fetching identical content within the TTL window.
|
||||||
|
const krokiCache = new Map<string, { result: string; cachedAt: number }>()
|
||||||
|
|
||||||
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
const PLANTUML_INSERT_BLOCKS: InsertBlockOrGroup[] = [
|
||||||
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
{ label: 'Diagram start/end', snippet: '@startuml\n\n@enduml' },
|
||||||
@@ -172,6 +179,18 @@ const BUILTIN_CONFIG_TYPES: ConfigType[] = [
|
|||||||
language: 'plantuml',
|
language: 'plantuml',
|
||||||
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
insertBlocks: PLANTUML_INSERT_BLOCKS,
|
||||||
render: async (content: string) => {
|
render: async (content: string) => {
|
||||||
|
// 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 controller = new AbortController()
|
||||||
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
|
const timeoutId = setTimeout(() => controller.abort(), KROKI_TIMEOUT_MS)
|
||||||
try {
|
try {
|
||||||
@@ -189,7 +208,9 @@ const BUILTIN_CONFIG_TYPES: ConfigType[] = [
|
|||||||
}
|
}
|
||||||
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
throw new Error(res.status === 400 ? err || 'Invalid PlantUML' : `Kroki error: ${res.status}`)
|
||||||
}
|
}
|
||||||
return res.text()
|
const svg = await res.text()
|
||||||
|
krokiCache.set(content, { result: svg, cachedAt: Date.now() })
|
||||||
|
return svg
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
@@ -201,7 +222,13 @@ const BUILTIN_CONFIG_TYPES: ConfigType[] = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
|
} finally {
|
||||||
|
krokiInflight.delete(content)
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
krokiInflight.set(content, fetchPromise)
|
||||||
|
return fetchPromise
|
||||||
},
|
},
|
||||||
outputMenuDescriptor: {
|
outputMenuDescriptor: {
|
||||||
submenuLabel: 'Export',
|
submenuLabel: 'Export',
|
||||||
|
|||||||
182
frontend/src/lib/graph/nodeFactory.ts
Normal file
182
frontend/src/lib/graph/nodeFactory.ts
Normal 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
|
||||||
|
}
|
||||||
48
frontend/src/lib/svgUtils.ts
Normal file
48
frontend/src/lib/svgUtils.ts
Normal 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}`
|
||||||
|
}
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
import React from 'react'
|
import React, { lazy, Suspense } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { ThemeProvider } from './lib/themeContext'
|
import { ThemeProvider } from './lib/themeContext'
|
||||||
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
|
import { registerBuiltinNodes } from './lib/graph/registerBuiltinNodes'
|
||||||
import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
|
import { registerBuiltinConfigTypes } from './lib/graph/configTypes'
|
||||||
import { KosmosPage } from './app/kosmos/KosmosPage'
|
|
||||||
import { RecollectionsPage } from './app/recollections/RecollectionsPage'
|
|
||||||
import { RecollectionLayout } from './app/recollections/RecollectionLayout'
|
import { RecollectionLayout } from './app/recollections/RecollectionLayout'
|
||||||
import { LogosPage } from './app/recollections/logos/LogosPage'
|
|
||||||
import { FluxRoute } from './app/recollections/flux/FluxRoute'
|
|
||||||
import { NotFoundPage } from './app/NotFoundPage'
|
import { NotFoundPage } from './app/NotFoundPage'
|
||||||
|
|
||||||
import './lib/prismSetup'
|
import './lib/prismSetup'
|
||||||
import 'prismjs/themes/prism.css'
|
import 'prismjs/themes/prism.css'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
import '@xyflow/react/dist/style.css'
|
import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
|
// Lazy-load heavy route components so their bundles are fetched on first navigation,
|
||||||
|
// not at initial app load. RecollectionLayout and NotFoundPage stay eager (lightweight).
|
||||||
|
const KosmosPage = lazy(() => import('./app/kosmos/KosmosPage').then(m => ({ default: m.KosmosPage })))
|
||||||
|
const RecollectionsPage = lazy(() => import('./app/recollections/RecollectionsPage').then(m => ({ default: m.RecollectionsPage })))
|
||||||
|
const LogosPage = lazy(() => import('./app/recollections/logos/LogosPage').then(m => ({ default: m.LogosPage })))
|
||||||
|
const FluxRoute = lazy(() => import('./app/recollections/flux/FluxRoute').then(m => ({ default: m.FluxRoute })))
|
||||||
|
|
||||||
registerBuiltinConfigTypes()
|
registerBuiltinConfigTypes()
|
||||||
registerBuiltinNodes()
|
registerBuiltinNodes()
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground text-sm">Loading…</div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<KosmosPage />}>
|
<Route path="/" element={<KosmosPage />}>
|
||||||
<Route index element={<Navigate to="/recollections" replace />} />
|
<Route index element={<Navigate to="/recollections" replace />} />
|
||||||
@@ -37,6 +42,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
<Toaster richColors position="bottom-right" />
|
<Toaster richColors position="bottom-right" />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -400,3 +400,76 @@ pre {
|
|||||||
.code-editor .token.url {
|
.code-editor .token.url {
|
||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Logos tree drop line (LogosDropCursor in TreeBrowser.tsx) */
|
||||||
|
@keyframes tree-drop-line-pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* React Arborist tree styling for sidebar integration */
|
||||||
|
.react-arborist-tree {
|
||||||
|
background: transparent !important;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-arborist-tree * {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user