An infinite-canvas mind-map with four AI personalities that read your thinking, stream their reasoning live, and restructure the board with a click. Here's how it works under the hood.
It's a single-board mind-map you draw on an infinite SVG canvas — pan, zoom, branch, connect, rename. When you hit ask AI, the current graph is serialized and sent to a personality (cold analyst, weary maintenance bot, ancient oracle, chaotic repair droid). It streams a dry, opinionated analysis of your thinking, then proposes concrete edits — add a node, link two ideas, expand a thin branch, tidy the layout — each one a card you Apply with a click.
Server-Sent Events push the reasoning token-by-token. You watch the AI think, not a spinner.
Suggestions aren't text — they're typed actions that mutate the board and ride the same undo stack as your own edits.
Data, AI, API and UI live in separate folders with a one-way import rule — no cross-layer shortcuts.
Code is organized by responsibility, and dependencies flow in one direction. The UI talks to the API; the API runs the AI; the AI and data layers know nothing about HTTP or Vue.
analyzer → suggester), the four personality prompts, Zod schemas, and the moderation pass. Pure orchestration — emits typed events, knows nothing about Nitro or components./api/ai/analyze route (keys → rate-limit → moderation → stream) and the useAIAnalysis composable that consumes the SSE stream with an AbortController.lib/config.ts — no magic numbers in logic.From click to applied edit. Colours mark the segment: client, server pipeline, model loop, stream back.
SerializedGraph. The Fireworks key rides in a request header (never the body); an AbortSignal backs the stop button.thinking text, then a suggestion per action, closed by a done frame carrying latency, tokens, cost and a truncation flag.applier.ts, mutating the graph store — on the same undo stack as manual edits.Every server→client message is one SSE frame: data: <JSON>\n\n. The JSON is one variant of the BoardStreamEvent discriminated union — defined once as a Zod schema, so a switch on event.type stays exhaustive at compile time.
| Event | Fires when… | Key payload |
|---|---|---|
| thinking | The analyzer streams a chunk of reasoning | text |
| suggestion | The suggester emits one board action | action: MindMapAction |
| done | The run completes | latencyMs, tokens, costUsd, truncated? |
| error | Validation, moderation or model failure | message |
The graph is handed an emit() callback; each node calls it, and the route writes data: …\n\n straight to the Node response. A client disconnect aborts the in-flight model call.
useAIAnalysis reads the body through a TextDecoderStream, buffers partial frames, splits on \n\n, and routes each parsed event into the AI store.
A tiny LangGraph workflow: analyzer → suggester → END. The analyzer writes prose for a human; the suggester turns that prose into machine-applicable actions. Both call Fireworks through the OpenAI-compatible SDK.
Streams an opinionated read of the board — what it's about, structural gaps, hidden connections, next moves. Identifies itself as Nexus Forge, never the model. Flags finish_reason: 'length' as truncation.
Reads the analysis and returns a typed MindMapAction[], validated against the Zod action schema before it ever reaches the client.
Each wraps the same base prompt with a distinct voice — the analysis is identical in substance, wildly different in tone.
The suggester can only speak in this vocabulary — a discriminated union so every action is fully typed and the applier's switch is exhaustive. highlight touches AI state; the rest mutate the graph store (and ride its undo stack).
Adds a child under a parent, auto-placed to avoid siblings.
Draws a cross-link — a non-tree association between two ideas.
Renames a vague node to something sharper.
Pulses a ring around nodes for a few seconds to draw the eye.
Adds several children to a thin branch in one move.
Re-runs the radial layout to untangle the whole board.
State is split by concern across three Pinia stores. Components import only what they need; there's no god-store and no facade in between.
Nodes, cross-links, selection, the current tool, undo/redo history, and debounced localStorage persistence.
Streaming reasoning, suggestions, highlights, the active agent, and the cached last result.
The single user-selectable accent colour, persisted and pushed to a CSS variable.
skipHydrate() so the SPA's client-only state never trips Nuxt's SSR hydration. Viewport, node-drag and label-edit logic live in their own composables, keeping the 400-line SVG canvas about rendering, not bookkeeping.Several independent checks run before the model sees a request — and the optional ones degrade gracefully when their backing service is missing.
1. A fast, always-on jailbreak regex scan (zero cost). 2. If an OpenAI key is set, the text goes to the Moderation API. It fails open — an outage never blocks a legit user. Blocks come back as an SSE error frame, not a 4xx.
Prod: Upstash Redis, keyed by API-key suffix or IP. Dev: an in-memory bucket. The shared demo key gets a tighter budget than your own key; a 429 carries Retry-After.
Plus a strict CSP, exact-origin CORS, and a key that's server-only — it travels in a header and is never logged. microphone=(self) keeps voice input working under the Permissions-Policy.
A core rule of the codebase: no magic numbers. Every constant below lives in lib/config.ts.