lint: change folder names

This commit is contained in:
2026-03-12 11:21:10 +01:00
parent 86238b7efc
commit 4184a60706
35 changed files with 104 additions and 105 deletions

View File

@@ -0,0 +1,143 @@
/**
* Abstract node layer: shared types, hook, and factory for flow node components.
*
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData()
* also reports this node as a trigger for connection path (lifecycle "trigger").
* - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual.
*
* **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should
* call useSyncConnectionStatus(id, { updating, error, paused }) from @/lib/graph/nodeLifecycle so edge
* colors and path animation stay correct. See nodeLifecycle.ts for the full contract.
*
* Example: define NodeData type, Props = AbstractNodeProps<NodeData>, use useAbstractNode in the
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
*/
import React, { useCallback, useContext, useMemo } from 'react'
import FlowContext from './flowContext'
import { nodePropsAreEqual } from './flowUtils'
import type { AppNode } from './nodeTypes'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Props passed by React Flow to custom node components. Extend data with your node's shape. */
export type AbstractNodeProps<TData = Record<string, unknown>> = {
id: string
data: TData
width?: number
height?: number
selected?: boolean
}
/** Edge shape used in flow context (minimal for connection logic). */
export type FlowEdge = { id: string; source: string; target: string; [k: string]: unknown }
/** Node shape used in flow context (minimal for reading graph). */
export type FlowNode = { id: string; type?: string; data?: unknown; position?: { x: number; y: number }; [k: string]: unknown }
/** Result of useAbstractNode: flow context plus helpers scoped to this node. */
export type AbstractNodeContext<TData = Record<string, unknown>> = {
id: string
data: TData
nodes: FlowNode[]
edges: FlowEdge[]
setNodes: (updater: (nodes: FlowNode[]) => FlowNode[]) => void
setEdges: (updater: (edges: FlowEdge[]) => FlowEdge[]) => void
/** Merge partial data into this node's data. Stable reference. */
updateData: (partial: Partial<TData>) => void
/** Incoming edge IDs (edges whose target is this node). */
incomingEdges: FlowEdge[]
/** Outgoing edge IDs (edges whose source is this node). */
outgoingEdges: FlowEdge[]
/** Source node IDs connected to this node (incoming). */
sourceIds: string[]
/** Target node IDs this node connects to (outgoing). */
targetIds: string[]
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Provides flow context and helpers for the current node. Use in any node component
* that receives id and data; updateData(partial) merges into this node's data.
*/
export function useAbstractNode<TData = Record<string, unknown>>(
id: string,
data: TData
): AbstractNodeContext<TData> {
const ctx = useContext(FlowContext)
const nodes = ctx?.nodes ?? []
const edges = ctx?.edges ?? []
const setNodes = ctx?.setNodes
const setEdges = ctx?.setEdges
const addConnectionPathTrigger = ctx?.addConnectionPathTrigger
const updateData = useCallback(
(partial: Partial<TData>) => {
if (!setNodes) return
setNodes((nds: AppNode[]) =>
nds.map((n) =>
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
) as AppNode[]
)
addConnectionPathTrigger?.(id)
},
[id, setNodes, addConnectionPathTrigger]
)
const incomingEdges = useMemo(
() => (edges as FlowEdge[]).filter((e) => e.target === id),
[edges, id]
)
const outgoingEdges = useMemo(
() => (edges as FlowEdge[]).filter((e) => e.source === id),
[edges, id]
)
const sourceIds = useMemo(
() => incomingEdges.map((e) => e.source).sort(),
[incomingEdges]
)
const targetIds = useMemo(
() => outgoingEdges.map((e) => e.target).sort(),
[outgoingEdges]
)
return {
id,
data,
nodes,
edges,
setNodes: ((setNodes ?? (() => {})) as AbstractNodeContext<TData>['setNodes']),
setEdges: setEdges ?? (() => {}),
updateData,
incomingEdges,
outgoingEdges,
sourceIds,
targetIds,
}
}
// ---------------------------------------------------------------------------
// Component factory
// ---------------------------------------------------------------------------
/**
* Wraps a node component with React.memo and nodePropsAreEqual so only id/data/width/height/selected
* changes trigger re-renders. Use with AbstractNodeProps<TData> for typed props.
*/
export function createAbstractNodeComponent<TData = Record<string, unknown>>(
displayName: string,
Component: React.ComponentType<AbstractNodeProps<TData>>
): React.MemoExoticComponent<React.ComponentType<AbstractNodeProps<TData>>> {
const Wrapped = React.memo(Component, nodePropsAreEqual) as React.MemoExoticComponent<
React.ComponentType<AbstractNodeProps<TData>>
>
Wrapped.displayName = displayName
return Wrapped
}

View File

@@ -0,0 +1,81 @@
import React, { useMemo } from 'react'
import type { Connection } from '@xyflow/react'
import type { AppNode, AppEdge } from './nodeTypes'
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
/** Role of a node in the current connection path update: pushing data, receiving/loading, or just on path. */
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path'
export type FlowActions = {
pasteAtViewportCenter: () => void
fitView: () => void
}
export type FlowContextValue = {
nodes: AppNode[]
setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void
edges: AppEdge[]
setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void
renamingNodeId: string | null
setRenamingNodeId: (id: string | null) => void
/** Set when user starts dragging from an output handle; cleared on connect end. Used to highlight valid targets. */
connectionFrom: ConnectionFrom
setConnectionFrom: (v: ConnectionFrom) => void
isValidConnection: (connection: Connection) => boolean
/** Set by FlowKeyboardShortcuts so Node menubar can trigger paste / fit view. */
flowActionsRef: React.MutableRefObject<FlowActions | null>
/** When set, graph centers on this node and a fullscreen dialog shows the node. Cleared on close. */
fullscreenNodeId: string | null
setFullscreenNodeId: (id: string | null) => void
/** Node ids currently updating (e.g. render loading, agent running). Only edges on those paths show the ant trail. */
connectionPathUpdatingNodeIds: string[]
/** Node ids that triggered the current update (e.g. variable/config that changed). Path is restricted to downstream(trigger) ∩ upstream(updating). */
connectionPathTriggerNodeIds: string[]
/** Call when this node's output changed and may trigger downstream updates (e.g. variable value, config content). */
addConnectionPathTrigger: (nodeId: string) => void
/** All node ids on the path of an update. Edges with both endpoints in this set animate. */
connectionPathNodeIds: Set<string>
/** Path nodes in the "paused" segment (from trigger up to Archon on hold). Those edges are yellow. */
connectionPathPausedSegmentNodeIds: Set<string>
/** Path nodes not in the paused segment (downstream of pause). Only those edges are blue (updating). */
connectionPathActiveSegmentNodeIds: Set<string>
/** Archon-type nodes that are on hold (e.g. Agent waiting for Run). */
connectionPathPausedNodeIds: string[]
/** Add this node as paused (on hold); remove when user continues. */
addConnectionPathPausedNode: (nodeId: string) => void
/** Remove this node from paused. */
removeConnectionPathPausedNode: (nodeId: string) => void
/** Node ids that have an error (e.g. Render node). Incoming edges show error status (red). */
connectionPathErrorNodeIds: string[]
/** Call when this node has an error; remove when error is cleared. */
addConnectionPathError: (nodeId: string) => void
/** Call when this node's error is cleared. */
removeConnectionPathError: (nodeId: string) => void
/** Call when a path update starts for this node. Animation runs at least CONNECTION_PATH_UPDATE_MIN_MS. */
startConnectionPathUpdate: (nodeId: string) => void
/** Call when a path update ends for this node. If min duration not reached, animation continues until then. */
endConnectionPathUpdate: (nodeId: string) => void
}
const FlowContext = React.createContext<FlowContextValue | null>(null)
export default FlowContext
/**
* Returns this node's role in the current path update for styling (pushing vs receiving).
* Use with BaseNode's connectionPathRole prop or data-path-role for CSS.
*/
export function useConnectionPathRole(nodeId: string | undefined): ConnectionPathRole | null {
const ctx = React.useContext(FlowContext)
return useMemo(() => {
if (!nodeId) return null
const triggers = ctx?.connectionPathTriggerNodeIds
const updating = ctx?.connectionPathUpdatingNodeIds
const path = ctx?.connectionPathNodeIds
if (!path?.has(nodeId)) return null
if (triggers?.includes(nodeId)) return 'trigger'
if (updating?.includes(nodeId)) return 'updating'
return 'on-path'
}, [nodeId, ctx?.connectionPathTriggerNodeIds, ctx?.connectionPathUpdatingNodeIds, ctx?.connectionPathNodeIds])
}

View File

@@ -0,0 +1,85 @@
/**
* Use as second argument to React.memo() for node components.
* Skips re-render when only position (or other unrelated props) changed,
* so dragging one node doesn't force other nodes to re-render.
*/
import {
getIdPrefix,
getDefaultDataForType as getDefaultDataFromRegistry,
getResetDataForType as getResetDataFromRegistry,
} from './nodeRegistry'
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
prev: P,
next: P
): boolean {
return (
prev.id === next.id &&
prev.data === next.data &&
prev.width === next.width &&
prev.height === next.height &&
prev.selected === next.selected
)
}
/** Next node id for type: prefix + 3-digit increasing number (001, 002, …). Uses nodeRegistry for prefix when available. */
export function getNextNodeId(type: string, existingIds: string[]): string {
const prefix = getIdPrefix(type)
const re = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)
let max = 0
for (const id of existingIds) {
const m = id.match(re)
if (m) max = Math.max(max, parseInt(m[1], 10))
}
return `${prefix}${String(max + 1).padStart(3, '0')}`
}
/** Recursively replace oldId with newId in string values (for rename propagation) */
function replaceInData(value: unknown, oldId: string, newId: string): unknown {
if (typeof value === 'string') return value.split(oldId).join(newId)
if (value === null || typeof value !== 'object') return value
if (Array.isArray(value)) return value.map((v) => replaceInData(v, oldId, newId))
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = replaceInData(v, oldId, newId)
return out
}
/** Update graph after renaming a node: change node id and all references in data and edges */
export function replaceNodeIdInGraph(
nodes: Array<{ id: string; data?: any; [k: string]: any }>,
edges: Array<{ id: string; source: string; target: string; [k: string]: any }>,
oldId: string,
newId: string
): { nodes: typeof nodes; edges: typeof edges } {
const newNodes = nodes.map((n) =>
n.id === oldId ? { ...n, id: newId } : { ...n, data: replaceInData(n.data, oldId, newId) as any }
)
const newEdges = edges.map((e) => ({
...e,
id: e.id.includes(oldId) ? e.id.split(oldId).join(newId) : e.id,
source: e.source === oldId ? newId : e.source,
target: e.target === oldId ? newId : e.target,
}))
return { nodes: newNodes, edges: newEdges }
}
/** @deprecated Use getDefaultStyle from nodeRegistry. Kept for compatibility. */
export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number }> = {
config: { width: 320, height: 320 },
render: { width: 384, height: 320 },
variable: { width: 224, height: 180 },
function: { width: 288, height: 260 },
data: { width: 360, height: 280 },
agent: { width: 360, height: 320 },
}
/** Default data for a new node. Uses nodeRegistry when type is registered. */
export function getDefaultDataForType(type: string, newId?: string): any {
return getDefaultDataFromRegistry(type, newId)
}
/** Data for Reset action. Uses nodeRegistry when type is registered. */
export function getResetDataForType(type: string, nodeId?: string): any {
return getResetDataFromRegistry(type, nodeId)
}

View File

@@ -0,0 +1,123 @@
/**
* Graph path utilities: compute which nodes/edges are "on the path" of an update.
* Used to show connection ant trail only along the full chain (upstream → updating → downstream).
* Works with any node types; any node can signal it is updating via startConnectionPathUpdate(id).
*/
export type GraphEdge = { source: string; target: string }
/** Nodes reachable from seedIds by following edges forward (source → target). */
export function getDownstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
const out = new Set<string>(seedIds)
let added = true
while (added) {
added = false
for (const e of edges) {
if (out.has(e.source) && !out.has(e.target)) {
out.add(e.target)
added = true
}
}
}
return out
}
/** Nodes that can reach any seed by following edges backward (target → source). */
export function getUpstreamNodeIds(edges: GraphEdge[], seedIds: string[]): Set<string> {
const out = new Set<string>(seedIds)
let added = true
while (added) {
added = false
for (const e of edges) {
if (out.has(e.target) && !out.has(e.source)) {
out.add(e.source)
added = true
}
}
}
return out
}
/**
* All node ids that lie on the path of an update.
* - If updatingNodeIds non-empty: path = downstream(trigger) ∩ (upstream(updating) downstream(updating))
* so we include config→agent→rendering when agent is running.
* - Else if triggerNodeIds and pausedNodeIds non-empty: path = downstream(trigger) ∩ upstream(paused)
* so we show yellow (config→agent) when config changed and agent is on hold.
* An edge should show color iff both its source and target are in this set.
*/
export function getPathNodeIds(
edges: GraphEdge[],
updatingNodeIds: string[],
triggerNodeIds?: string[],
pausedNodeIds?: string[]
): Set<string> {
const hasUpdating = updatingNodeIds.length > 0
const hasPausedPath =
pausedNodeIds != null &&
pausedNodeIds.length > 0 &&
triggerNodeIds != null &&
triggerNodeIds.length > 0
if (hasUpdating && triggerNodeIds != null && triggerNodeIds.length > 0) {
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds)
const upstreamOfUpdating = getUpstreamNodeIds(edges, updatingNodeIds)
const downstreamOfUpdating = getDownstreamNodeIds(edges, updatingNodeIds)
const path = new Set<string>()
downstreamOfTrigger.forEach((id) => {
if (upstreamOfUpdating.has(id) || downstreamOfUpdating.has(id)) path.add(id)
})
return path
}
if (hasPausedPath && !hasUpdating) {
const upstream = getUpstreamNodeIds(edges, pausedNodeIds!)
const downstreamOfTrigger = getDownstreamNodeIds(edges, triggerNodeIds!)
const path = new Set<string>()
upstream.forEach((id) => {
if (downstreamOfTrigger.has(id)) path.add(id)
})
return path
}
if (hasUpdating) {
const upstream = getUpstreamNodeIds(edges, updatingNodeIds)
const downstream = getDownstreamNodeIds(edges, updatingNodeIds)
const path = new Set<string>(upstream)
downstream.forEach((id) => path.add(id))
return path
}
return new Set()
}
/**
* Path nodes from triggers up to and including the first paused node (Archon on hold).
* Used to color those edges yellow; rest of path stays blue.
*/
export function getPausedSegmentNodeIds(
edges: GraphEdge[],
pathNodeIds: Set<string>,
triggerNodeIds: string[],
pausedNodeIds: string[]
): Set<string> {
if (pausedNodeIds.length === 0 || triggerNodeIds.length === 0) return new Set()
const pausedSet = new Set(pausedNodeIds)
const seeds = triggerNodeIds.filter((id) => pathNodeIds.has(id))
if (seeds.length === 0) return new Set()
const out = new Set<string>(seeds)
const frontier: string[] = [...seeds]
const visited = new Set<string>(seeds)
while (frontier.length > 0) {
const n = frontier.shift()!
if (pausedSet.has(n)) continue
for (const e of edges) {
if (e.source !== n || !pathNodeIds.has(e.target) || visited.has(e.target)) continue
visited.add(e.target)
out.add(e.target)
if (pausedSet.has(e.target)) continue
frontier.push(e.target)
}
}
return out
}

View File

@@ -0,0 +1,110 @@
import type React from 'react'
export type NodeType = 'config' | 'render' | 'variable' | 'function' | 'data' | 'agent'
export type NodeHelpEntry = {
title: string
content: React.ReactNode
}
const Code = ({ children }: { children: React.ReactNode }) => (
<code className="rounded bg-muted px-1 py-0.5 text-xs font-mono">{children}</code>
)
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div className="mt-3 first:mt-0">
<h4 className="text-xs font-semibold text-foreground">{title}</h4>
<div className="mt-1 text-xs text-muted-foreground">{children}</div>
</div>
)
export const NODE_HELP: Record<NodeType, NodeHelpEntry> = {
config: {
title: 'Config node',
content: (
<>
<Section title="How to use">
<p>Config nodes hold PlantUML + Nunjucks template content. Connect one config to a Render node to display the diagram. Use the editor to write <Code>@startuml</Code> blocks and Nunjucks tags (<Code>{'{{ }}'}</Code>, <Code>{'{% %}'}</Code>).</p>
</Section>
<Section title="Using in another node">
<p>From another config, reference this template:</p>
<ul className="list-disc pl-4 mt-1 space-y-0.5">
<li><Code>{'{% extends "configId" %}'}</Code> inherit layout</li>
<li><Code>{'{% include "configId" %}'}</Code> inline content</li>
<li><Code>{'{% import "configId" as alias %}'}</Code> use as macro namespace</li>
</ul>
<p className="mt-2">Replace <Code>configId</Code> with this nodes id or its title. Connect variables/functions to this config; they are available as <Code>{'{{ varId }}'}</Code> and <Code>{'{{ x | fnId }}'}</Code> in the template.</p>
</Section>
</>
),
},
render: {
title: 'Renderer node',
content: (
<>
<Section title="How to use">
<p>Connect a single Config node (input) to this Render node. It resolves the configs PlantUML + Nunjucks (variables, function filters, extends/include), sends the result to the diagram service, and shows the SVG here.</p>
</Section>
<Section title="Using in another node">
<p>Renderer nodes are terminal: they only consume configs. They are not referenced from other nodes. To reuse a diagram, reference the Config node from another Config (extends/include), then connect that config to a Render node.</p>
</Section>
</>
),
},
variable: {
title: 'Variable node',
content: (
<>
<Section title="How to use">
<p>Variables hold a value (string, number, or boolean). Connect a Variable node to a Config node to expose it in that configs template.</p>
</Section>
<Section title="Using in another node">
<p>In a Config template connected to this variable, use <Code>{'{{ '}<em>nodeId</em>{' }}'}</Code> where <em>nodeId</em> is this nodes id. Example: if the variable node id is <Code>var_001</Code>, write <Code>{'{{ var_001 }}'}</Code> in the config.</p>
</Section>
</>
),
},
function: {
title: 'Function node',
content: (
<>
<Section title="How to use">
<p>Functions are Nunjucks custom filters. Write a body using either named parameters (<Code>function(num, x, kwargs) &#123; ... &#125;</Code>) or the <Code>args</Code> array. Connect this node to a Config to use the filter in that configs template.</p>
</Section>
<Section title="Using in another node">
<p>In a Config template, use the filter syntax: <Code>{'{{ value | '}<em>nodeId</em>{' }}'}</Code> or <Code>{'{{ value | '}<em>nodeId</em>{'(arg1, key=val) }}'}</Code>. The first argument is the value before <Code>|</Code>; extra arguments and keyword args are passed as in Nunjucks. Return a value or a Promise for async filters.</p>
</Section>
</>
),
},
data: {
title: 'Data node',
content: (
<>
<Section title="How to use">
<p>Data nodes hold CSV data. Drop a .csv file onto the node to load it; the table is shown in the node. Connect this node to a Config node to use the data in Nunjucks templates.</p>
</Section>
<Section title="Using in templates">
<p>In a Config template connected to this data node, the variable <Code>{'{{ '}<em>nodeId</em>{' }}'}</Code> is an array of row objects (one per CSV row). Each row has keys from the CSV header. Example: <Code>{'{% for row in data_001 %}{{ row.name }}, {{ row.value }}{% endfor %}'}</Code></p>
</Section>
</>
),
},
agent: {
title: 'Agent node',
content: (
<>
<Section title="How to use">
<p>Agent nodes use AI to produce structured markdown. Connect Config nodes as the <strong>prompt</strong> (their content is sent as the main prompt). Optionally connect Variable or Data nodes; they are passed as context. Define additional context in the text area. Click Run to execute the agent; output is markdown. Connect this node to a Renderer to display the result.</p>
</Section>
<Section title="Output">
<p>When connected to a Renderer node, the agents markdown output is rendered there. Ensure the backend <Code>/api/agent</Code> is running and (if using OpenAI) <Code>OPENAI_API_KEY</Code> is set.</p>
</Section>
</>
),
},
}
export function getNodeHelp(nodeType: NodeType): NodeHelpEntry {
return NODE_HELP[nodeType] ?? { title: nodeType, content: null }
}

View File

@@ -0,0 +1,98 @@
/**
* Node lifecycle: contract that nodes implement so the graph can show the right
* connection status (edge colors) and path animation.
*
* ## Lifecycle phases (conceptual)
*
* - **Idle** Node is not on an active path; edges to/from it use default style.
* - **Trigger** Node's output changed (e.g. config content, variable value). Reported
* automatically when the node calls `updateData()` from useAbstractNode. Downstream
* path is computed from triggers + updating nodes.
* - **Updating** Node is doing async work (e.g. agent running, renderer loading).
* Report `updating: true` at start, `updating: false` when done. Incoming/outgoing
* edges on the path show "updating" (blue).
* - **Paused** Node is on hold (e.g. agent waiting for Run after inputs changed).
* Report `paused: true` when waiting, `paused: false` when not. Edges in the paused
* segment show "paused" (yellow).
* - **Error** Node has an error to show. Report `error: true` when error is set,
* `error: false` when cleared. Incoming edges to this node show "error" (red).
*
* ## Connection status integration
*
* Edge status is derived in getConnectionStatus() from the sets that this lifecycle
* feeds: connectionPathUpdatingNodeIds, connectionPathPausedNodeIds,
* connectionPathErrorNodeIds (plus path/paused segment from graphPath). Priority:
* error > paused > updating > default.
*
* Nodes that can be updating, paused, or in error should call useSyncConnectionStatus()
* with their current state so edges update correctly.
*/
import { useContext, useEffect, useRef } from 'react'
import FlowContext from './flowContext'
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
/**
* State that drives connection status for this node.
* Pass the current values from your node; the hook syncs them to FlowContext.
*/
export type NodeConnectionStatusState = {
/** Node is doing async work (e.g. loading, running). Incoming/outgoing path edges show blue. */
updating?: boolean
/** Node has an error. Incoming edges to this node show red. */
error?: boolean
/** Node is on hold (e.g. agent waiting for Run). Path edges in paused segment show yellow. */
paused?: boolean
}
/**
* Syncs this node's lifecycle state to FlowContext so connection status (edge colors)
* and path animation are correct. Call once per node with the current updating/error/paused
* state; the hook will add/remove this node from the appropriate sets.
*
* Use in any node that can be updating, in error, or paused:
*
* const [loading, setLoading] = useState(false)
* const [error, setError] = useState(null)
* const hasPendingInputs = ...
* useSyncConnectionStatus(id, { updating: loading, error: !!error, paused: hasPendingInputs })
*/
export function useSyncConnectionStatus(
nodeId: string,
state: NodeConnectionStatusState
): void {
const ctx = useContext(FlowContext)
const { updating, error, paused } = state
const prevRef = useRef({ updating: false, error: false, paused: false })
useEffect(() => {
const prev = prevRef.current
const nowUpdating = Boolean(updating)
const nowError = Boolean(error)
const nowPaused = Boolean(paused)
if (prev.updating !== nowUpdating) {
if (nowUpdating) ctx?.startConnectionPathUpdate?.(nodeId)
else ctx?.endConnectionPathUpdate?.(nodeId)
prev.updating = nowUpdating
}
if (prev.error !== nowError) {
if (nowError) ctx?.addConnectionPathError?.(nodeId)
else ctx?.removeConnectionPathError?.(nodeId)
prev.error = nowError
}
if (prev.paused !== nowPaused) {
if (nowPaused) ctx?.addConnectionPathPausedNode?.(nodeId)
else ctx?.removeConnectionPathPausedNode?.(nodeId)
prev.paused = nowPaused
}
return () => {
if (prevRef.current.updating) ctx?.endConnectionPathUpdate?.(nodeId)
if (prevRef.current.error) ctx?.removeConnectionPathError?.(nodeId)
if (prevRef.current.paused) ctx?.removeConnectionPathPausedNode?.(nodeId)
prevRef.current = { updating: false, error: false, paused: false }
}
}, [nodeId, updating, error, paused, ctx])
}

View File

@@ -0,0 +1,164 @@
/**
* Extensible node type registry. Register node types with registerNodeType() or use NodeTypeBuilder.
* Built-in types are registered in registerBuiltinNodes.ts.
* Use getRegisteredNodeTypes() / getNodeType(id) for defaults, validation, and UI.
*/
import type React from 'react'
import { registerSourceRenderingLogic } from '../sourceRenderingLogic'
import type { SourceRenderingLogic } from '../sourceRenderingLogic'
/** Node classification for UI: Psyche, Pneuma, Physis, Archon (AI Agent). */
export type NodeClassification = 'psyche' | 'pneuma' | 'physis' | 'archon'
export const NODE_CLASSIFICATION_LABELS: Record<NodeClassification, string> = {
psyche: 'Psyche',
pneuma: 'Pneuma',
physis: 'Physis',
archon: 'Archon',
}
export type NodeHelpEntry = {
title: string
content: React.ReactNode
}
export type NodeTypeDescriptor = {
id: string
component: React.ComponentType<any>
defaultStyle: { width: number; height: number }
defaultData: Record<string, unknown>
idPrefix: string
hasInput: boolean
hasOutput: boolean
/** Classification for creation menu and node footer: Psyche, Pneuma, Physis. */
classification?: NodeClassification
/** When this type is the connection target, which source types are allowed. Omit = all. */
allowedSourceTypes?: string[]
/** When this type is the connection source, which target types are allowed. Omit = all. */
allowedTargetTypes?: string[]
help: NodeHelpEntry
menuLabel: string
menuIcon: React.ReactNode
/** Optional: override default data for new nodes (e.g. set title from newId). */
getDefaultData?: (newId?: string) => Record<string, unknown>
/** Optional: data for Reset action; if omitted, getDefaultData(nodeId) or defaultData is used. */
getResetData?: (nodeId?: string) => Record<string, unknown>
/** Optional: label shown on edge when this type is the target (e.g. "render", "add input"). */
connectionLabel?: string
/** When true, double-clicking the node header opens a fullscreen dialog for this node. */
supportsFullscreen?: boolean
/** When set, this type can feed the Renderer; registration will also register source rendering logic. */
sourceRenderingLogic?: SourceRenderingLogic
}
const registry = new Map<string, NodeTypeDescriptor>()
export function registerNodeType(descriptor: NodeTypeDescriptor): void {
if (registry.has(descriptor.id)) {
console.warn(`[nodeRegistry] Overwriting existing node type: ${descriptor.id}`)
}
if (descriptor.sourceRenderingLogic) {
registerSourceRenderingLogic(descriptor.id, descriptor.sourceRenderingLogic)
}
registry.set(descriptor.id, descriptor)
}
export function getNodeType(id: string): NodeTypeDescriptor | undefined {
return registry.get(id)
}
const CLASSIFICATION_ORDER: NodeClassification[] = ['psyche', 'pneuma', 'physis', 'archon']
export function getRegisteredNodeTypes(): NodeTypeDescriptor[] {
return Array.from(registry.values())
}
/** Node types grouped by classification for the Create Node menu. Order: Psyche, Pneuma, Physis. */
export function getRegisteredNodeTypesGroupedByClassification(): {
classification: NodeClassification
label: string
types: NodeTypeDescriptor[]
}[] {
const byClass = new Map<NodeClassification, NodeTypeDescriptor[]>()
for (const c of CLASSIFICATION_ORDER) byClass.set(c, [])
for (const desc of registry.values()) {
const c = desc.classification ?? 'physis'
byClass.get(c)!.push(desc)
}
return CLASSIFICATION_ORDER.map((classification) => ({
classification,
label: NODE_CLASSIFICATION_LABELS[classification],
types: byClass.get(classification)!,
}))
}
export function getRegisteredNodeTypeIds(): string[] {
return Array.from(registry.keys())
}
/** Default data for a new node of this type. Uses getDefaultData from descriptor if provided. */
export function getDefaultDataForType(type: string, newId?: string): Record<string, unknown> {
const desc = registry.get(type)
if (!desc) return {}
if (desc.getDefaultData) return { ...desc.getDefaultData(newId) }
const base = { ...desc.defaultData }
if (type === 'config' && newId) (base as any).title = `${newId}`
return base
}
/** Data for Reset action. Uses getResetData from descriptor if provided. */
export function getResetDataForType(type: string, nodeId?: string): Record<string, unknown> {
const desc = registry.get(type)
if (!desc) return getDefaultDataForType(type, nodeId)
if (desc.getResetData) return { ...desc.getResetData(nodeId) }
return getDefaultDataForType(type, nodeId)
}
/** Id prefix for this type (e.g. cfg_, rnd_). Used by getNextNodeId. */
export function getIdPrefix(type: string): string {
return getNodeType(type)?.idPrefix ?? 'node_'
}
/** Default style for this type. */
export function getDefaultStyle(type: string): { width: number; height: number } {
const desc = getNodeType(type)
if (desc) return desc.defaultStyle
return { width: 320, height: 320 }
}
/** Whether a connection from source to target is allowed based on registered types. */
export function isConnectionAllowed(
sourceType: string,
targetType: string,
sourceNodeId: string,
targetNodeId: string
): boolean {
if (sourceNodeId === targetNodeId) return false
const sourceDesc = getNodeType(sourceType)
const targetDesc = getNodeType(targetType)
if (!sourceDesc || !targetDesc) return false
if (!targetDesc.hasInput) return false
if (!sourceDesc.hasOutput) return false
if (targetDesc.allowedSourceTypes != null && !targetDesc.allowedSourceTypes.includes(sourceType)) return false
if (sourceDesc.allowedTargetTypes != null && !sourceDesc.allowedTargetTypes.includes(targetType)) return false
return true
}
/** Edge label when target is of this type (for AnimatedEdge). */
export function getConnectionLabelForTarget(targetType: string): string | undefined {
return getNodeType(targetType)?.connectionLabel
}
/** Help entry for a node type. Use in NodeHelpPopover. */
export function getNodeHelp(nodeType: string): NodeHelpEntry {
const desc = getNodeType(nodeType)
return desc?.help ?? { title: nodeType, content: null }
}
/** Classification label for a node type (e.g. "Psyche"). Shown in node footer before help button. */
export function getNodeClassificationLabel(nodeType: string): string | null {
const desc = getNodeType(nodeType)
const c = desc?.classification
return c ? NODE_CLASSIFICATION_LABELS[c] : null
}

View File

@@ -0,0 +1,165 @@
/**
* Fluent builder for NodeTypeDescriptor. Use to define node types with common behavior
* and optional source rendering logic (for types that feed the Renderer).
*
* Example:
* createNodeTypeBuilder('config', ConfigNode, { width: 320, height: 320 }, { configType: 'plantuml', content: '', title: '' })
* .idPrefix('cfg_')
* .withInputOutput(true, true)
* .classification('psyche')
* .allowedSourceTypes(['config', 'variable', 'function', 'data'])
* .allowedTargetTypes(['config', 'render', 'agent'])
* .help(NODE_HELP.config)
* .menu('Config', <ScrollText />)
* .connectionLabel('adding input')
* .withFullscreen()
* .sourceRenderingLogic({ defaultUpdateMode: 'auto', getResolvedContent: ... })
* .build()
*/
import type React from 'react'
import type { NodeTypeDescriptor, NodeClassification, NodeHelpEntry } from './nodeRegistry'
import type { SourceRenderingLogic } from '../sourceRenderingLogic'
type OptionalDescriptor = Partial<
Omit<
NodeTypeDescriptor,
'id' | 'component' | 'defaultStyle' | 'defaultData' | 'idPrefix' | 'hasInput' | 'hasOutput' | 'help' | 'menuLabel' | 'menuIcon'
>
>
export class NodeTypeBuilder {
private readonly id: string
private readonly component: React.ComponentType<any>
private readonly defaultStyle: { width: number; height: number }
private readonly defaultData: Record<string, unknown>
private partial: OptionalDescriptor & {
idPrefix?: string
hasInput?: boolean
hasOutput?: boolean
help?: NodeHelpEntry
menuLabel?: string
menuIcon?: React.ReactNode
} = {}
constructor(
id: string,
component: React.ComponentType<any>,
defaultStyle: { width: number; height: number },
defaultData: Record<string, unknown>
) {
this.id = id
this.component = component
this.defaultStyle = defaultStyle
this.defaultData = defaultData
}
idPrefix(prefix: string): this {
this.partial.idPrefix = prefix
return this
}
withInputOutput(hasInput: boolean, hasOutput: boolean): this {
this.partial.hasInput = hasInput
this.partial.hasOutput = hasOutput
return this
}
classification(c: NodeClassification): this {
this.partial.classification = c
return this
}
allowedSourceTypes(types: string[]): this {
this.partial.allowedSourceTypes = types
return this
}
allowedTargetTypes(types: string[]): this {
this.partial.allowedTargetTypes = types
return this
}
help(help: NodeHelpEntry): this {
this.partial.help = help
return this
}
menu(label: string, icon: React.ReactNode): this {
this.partial.menuLabel = label
this.partial.menuIcon = icon
return this
}
getDefaultData(fn: (newId?: string) => Record<string, unknown>): this {
this.partial.getDefaultData = fn
return this
}
getResetData(fn: (nodeId?: string) => Record<string, unknown>): this {
this.partial.getResetData = fn
return this
}
connectionLabel(label: string): this {
this.partial.connectionLabel = label
return this
}
withFullscreen(): this {
this.partial.supportsFullscreen = true
return this
}
sourceRenderingLogic(logic: SourceRenderingLogic): this {
this.partial.sourceRenderingLogic = logic
return this
}
build(): NodeTypeDescriptor {
const {
idPrefix,
hasInput,
hasOutput,
help,
menuLabel,
menuIcon,
sourceRenderingLogic,
...rest
} = this.partial
if (idPrefix == null || hasInput == null || hasOutput == null || !help || !menuLabel || menuIcon == null) {
throw new Error(
`NodeTypeBuilder.build(): missing required fields for "${this.id}". Set idPrefix, withInputOutput, help, and menu.`
)
}
const descriptor: NodeTypeDescriptor = {
id: this.id,
component: this.component,
defaultStyle: this.defaultStyle,
defaultData: this.defaultData,
idPrefix,
hasInput,
hasOutput,
help,
menuLabel,
menuIcon,
...rest,
}
if (sourceRenderingLogic != null) {
descriptor.sourceRenderingLogic = sourceRenderingLogic
}
return descriptor
}
}
/**
* Start building a node type descriptor. Required chain: idPrefix, withInputOutput, help, menu, then build().
*/
export function createNodeTypeBuilder(
id: string,
component: React.ComponentType<any>,
defaultStyle: { width: number; height: number },
defaultData: Record<string, unknown>
): NodeTypeBuilder {
return new NodeTypeBuilder(id, component, defaultStyle, defaultData)
}

View File

@@ -0,0 +1,15 @@
/**
* Central node and edge types for the app. Use AppNode / AppEdge in graph state and context.
*/
import type { Node, Edge } from '@xyflow/react'
import type { ConfigNodeData } from '@/components/nodes/config'
import type { RenderingNodeData } from '@/components/nodes/render'
import type { VariableNodeData } from '@/components/nodes/variable'
import type { FunctionNodeData } from '@/components/nodes/function'
import type { DataNodeData } from '@/components/nodes/data'
import type { AgentNodeData } from '@/components/nodes/agent'
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData | DataNodeData | AgentNodeData
export type AppNode = Node<AppNodeData>
export type AppEdge = Edge

View File

@@ -0,0 +1,22 @@
/**
* Registers built-in node types using the node type builder.
* Each node type is defined in its own folder under components/nodes/<type>/ and
* provides a getXxxNodeDescriptor() that uses the builder (including optional source rendering logic).
*/
import { registerNodeType } from './nodeRegistry'
import { getConfigNodeDescriptor } from '@/components/nodes/config'
import { getAgentNodeDescriptor } from '@/components/nodes/agent'
import { getRenderNodeDescriptor } from '@/components/nodes/render'
import { getVariableNodeDescriptor } from '@/components/nodes/variable'
import { getFunctionNodeDescriptor } from '@/components/nodes/function'
import { getDataNodeDescriptor } from '@/components/nodes/data'
export function registerBuiltinNodes(): void {
registerNodeType(getConfigNodeDescriptor())
registerNodeType(getAgentNodeDescriptor())
registerNodeType(getRenderNodeDescriptor())
registerNodeType(getVariableNodeDescriptor())
registerNodeType(getFunctionNodeDescriptor())
registerNodeType(getDataNodeDescriptor())
}