Compare commits
2 Commits
084863909a
...
f5b12949d3
| Author | SHA1 | Date | |
|---|---|---|---|
| f5b12949d3 | |||
| e29c5d643c |
50
frontend/docs/IMPROVEMENTS.md
Normal file
50
frontend/docs/IMPROVEMENTS.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Codebase simplification and design patterns
|
||||
|
||||
This doc summarizes recent improvements and suggested next steps for readability, extension, and consistency.
|
||||
|
||||
## Done
|
||||
|
||||
### 1. Single place for template/reachability (DRY)
|
||||
|
||||
- **Added** `lib/graph/templateRefs.ts`: `isReachable`, `resolveExtendsRef`, `getTemplateRefs`.
|
||||
- **Refactored** `config/renderingLogic.ts` and `useRenderingNodeState.ts` to use these helpers instead of duplicating the same logic.
|
||||
- **Pattern:** Extract shared pure helpers into a small lib module; keep call sites thin and consistent.
|
||||
|
||||
### 2. Naming and comments
|
||||
|
||||
- **Renamed** `outputMenuRegistry.tsx` → `outputMenuHandlers.tsx` (no registry, only helpers).
|
||||
- **Updated** `rendering.ts` and `sourceRenderingLogic.ts`: output menu is described as coming from the node descriptor (`getOutputMenuContent`), not a separate registry.
|
||||
- **Documented** `NodeMenubar`: extra content can come from props or from the descriptor (`getNodeMenuExtraContent`).
|
||||
- **Documented** `nodeTypes.ts`: clarifies React Flow types vs node type id (nodeRegistry).
|
||||
|
||||
### 3. Central pipeline entry
|
||||
|
||||
- **rendering.ts** documents the 3-step pipeline, how to add a source/output type, and points to `templateRefs.ts` for shared helpers.
|
||||
|
||||
### 4. Rendering signatures (pure module)
|
||||
|
||||
- **Added** `lib/graph/renderingSignatures.ts`: `buildConnectedNodeIds`, `buildSourceSignatures` (connectedNodeIds + all five signatures + sourceSignature).
|
||||
- **Refactored** `useRenderingNodeState` to call `buildSourceSignatures` in a single useMemo; hook is shorter and signature logic is testable in isolation.
|
||||
|
||||
### 5. Canvas split
|
||||
|
||||
- **CanvasContextMenuContent** (`app/canvas/CanvasContextMenuContent.tsx`): context menu content (Create Node grouped by classification, Paste). CanvasPage passes `onCreateNode` and `onPaste`.
|
||||
- **useCanvasConnectionPath** (`app/canvas/useCanvasConnectionPath.ts`): all connection-path state and callbacks (updating/trigger/paused/error node ids, path node ids, start/end update, add/remove paused/error). CanvasPage calls the hook with `edges` and passes the result into FlowContext.
|
||||
|
||||
## Design patterns in use
|
||||
|
||||
| Pattern | Where |
|
||||
|----------------|--------------------------------------------|
|
||||
| **Registry** | nodeRegistry, sourceRenderingLogic |
|
||||
| **Builder** | nodeTypeBuilder (descriptor per node type) |
|
||||
| **Pipeline** | Resolve → Render → Display (rendering.ts) |
|
||||
| **Strategy** | Source logic per node type; output menu per descriptor |
|
||||
| **Shared helpers** | templateRefs, outputMenuHandlers, renderingUtils |
|
||||
|
||||
## Suggested next steps
|
||||
|
||||
1. **CanvasPage**: Further split optional: e.g. `useCanvasGraph()` for graph state + persistence + connection rules, so the page is mostly composition and layout.
|
||||
|
||||
2. **Config types**: If you add more output types (e.g. Mermaid), consider a small registry API (`registerConfigType`, `getConfigType`) instead of a single large `CONFIG_TYPES` array.
|
||||
|
||||
3. **Consistent node shape in lib**: `templateRefs` and `renderingSignatures` use `EdgeLike` / `NodeLike`; standardize where appropriate to reduce casts.
|
||||
62
frontend/src/app/canvas/CanvasContextMenuContent.tsx
Normal file
62
frontend/src/app/canvas/CanvasContextMenuContent.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Context menu content for the canvas: Create Node (grouped by classification) and Paste.
|
||||
* Used inside CanvasPage so the menu structure lives in one place.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuGroup,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from '@/components/ui/context-menu'
|
||||
import { ClipboardPaste } from 'lucide-react'
|
||||
import { getRegisteredNodeTypesGroupedByClassification } from '@/lib/graph/nodeRegistry'
|
||||
|
||||
export type CanvasContextMenuContentProps = {
|
||||
onCreateNode: (type: string) => void
|
||||
onPaste: () => void
|
||||
}
|
||||
|
||||
export function CanvasContextMenuContent({ onCreateNode, onPaste }: CanvasContextMenuContentProps) {
|
||||
const groups = getRegisteredNodeTypesGroupedByClassification()
|
||||
return (
|
||||
<ContextMenuContent className="w-48" aria-label="Canvas menu: create node or paste">
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-44">
|
||||
{groups.map(
|
||||
(group, groupIndex) =>
|
||||
group.types.length > 0 && (
|
||||
<React.Fragment key={group.classification}>
|
||||
{groupIndex > 0 && <ContextMenuSeparator />}
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLabel className="text-muted-foreground">
|
||||
{group.label}
|
||||
</ContextMenuLabel>
|
||||
{group.types.map((desc) => (
|
||||
<ContextMenuItem key={desc.id} onSelect={() => onCreateNode(desc.id)}>
|
||||
{desc.menuIcon}
|
||||
{desc.menuLabel}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuGroup>
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={onPaste}>
|
||||
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
)
|
||||
}
|
||||
@@ -27,18 +27,9 @@ import FlowContext from '@/lib/graph/flowContext'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuGroup,
|
||||
} from '@/components/ui/context-menu'
|
||||
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
||||
import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
|
||||
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
|
||||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||
import { FlowKeyboardShortcuts } from '@/components/graph/FlowKeyboardShortcuts'
|
||||
@@ -56,17 +47,15 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ClipboardPaste, FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
|
||||
import { FolderOpen, FileStack, CircleDotDashed } from 'lucide-react'
|
||||
import { DEFAULT_NODE_STYLE, getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||
import {
|
||||
getRegisteredNodeTypes,
|
||||
getRegisteredNodeTypesGroupedByClassification,
|
||||
getRegisteredNodeTypeIds,
|
||||
getDefaultStyle,
|
||||
getNodeType,
|
||||
isConnectionAllowed,
|
||||
} from '@/lib/graph/nodeRegistry'
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -78,8 +67,6 @@ import {
|
||||
|
||||
const SNAP_GRID: [number, number] = [15, 15]
|
||||
const DUPLICATE_OFFSET = { x: 30, y: 30 }
|
||||
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
|
||||
const CONNECTION_PATH_UPDATE_MIN_MS = 1500
|
||||
const snapToGrid = (x: number, y: number): { x: number; y: number } => ({
|
||||
x: Math.round(x / SNAP_GRID[0]) * SNAP_GRID[0],
|
||||
y: Math.round(y / SNAP_GRID[1]) * SNAP_GRID[1],
|
||||
@@ -267,82 +254,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const [isSelecting, setIsSelecting] = React.useState(false)
|
||||
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||||
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
|
||||
const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = React.useState<string[]>([])
|
||||
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = React.useState<string[]>([])
|
||||
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
const pathUpdateStartTimeRef = useRef<number | null>(null)
|
||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = React.useState<string[]>([])
|
||||
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = React.useState<string[]>([])
|
||||
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
|
||||
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
|
||||
|
||||
const clearPathUpdateSession = useCallback(() => {
|
||||
setConnectionPathUpdatingNodeIds([])
|
||||
if (connectionPathPausedNodeIdsRef.current.length === 0) {
|
||||
setConnectionPathTriggerNodeIds([])
|
||||
setConnectionPathPausedNodeIds([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.add(nodeId)
|
||||
if (ref.size === 1) {
|
||||
pathUpdateStartTimeRef.current = Date.now()
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
}, [])
|
||||
|
||||
const endConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.delete(nodeId)
|
||||
if (ref.size > 0) {
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
return
|
||||
}
|
||||
const startedAt = pathUpdateStartTimeRef.current ?? 0
|
||||
const elapsed = Date.now() - startedAt
|
||||
const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed)
|
||||
if (remaining === 0) {
|
||||
clearPathUpdateSession()
|
||||
} else {
|
||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
clearPathUpdateSession()
|
||||
}, remaining)
|
||||
}
|
||||
}, [clearPathUpdateSession])
|
||||
|
||||
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
|
||||
const pathTriggerScheduledRef = useRef(false)
|
||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||
pathTriggerBatchRef.current.add(nodeId)
|
||||
if (pathTriggerScheduledRef.current) return
|
||||
pathTriggerScheduledRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
pathTriggerScheduledRef.current = false
|
||||
const batch = new Set(pathTriggerBatchRef.current)
|
||||
pathTriggerBatchRef.current = new Set()
|
||||
if (batch.size === 0) return
|
||||
setConnectionPathTriggerNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
batch.forEach((id) => next.add(id))
|
||||
return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
const connectionPath = useCanvasConnectionPath(edges)
|
||||
|
||||
const nodesRef = useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
@@ -551,50 +463,6 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef.current?.pasteAtViewportCenter?.()
|
||||
}, [])
|
||||
|
||||
const connectionPathNodeIds = useMemo(
|
||||
() =>
|
||||
getPathNodeIds(
|
||||
edges,
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
connectionPathPausedNodeIds
|
||||
),
|
||||
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
||||
)
|
||||
|
||||
const connectionPathPausedSegmentNodeIds = useMemo(
|
||||
() =>
|
||||
getPausedSegmentNodeIds(
|
||||
edges,
|
||||
connectionPathNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
connectionPathPausedNodeIds
|
||||
),
|
||||
[edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
||||
)
|
||||
|
||||
const connectionPathActiveSegmentNodeIds = useMemo(() => {
|
||||
const active = new Set(connectionPathNodeIds)
|
||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
||||
return active
|
||||
}, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds])
|
||||
|
||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
||||
}, [])
|
||||
|
||||
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId))
|
||||
}, [])
|
||||
|
||||
const addConnectionPathError = useCallback((nodeId: string) => {
|
||||
setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
||||
}, [])
|
||||
|
||||
const removeConnectionPathError = useCallback((nodeId: string) => {
|
||||
setConnectionPathErrorNodeIds((prev) => prev.filter((id) => id !== nodeId))
|
||||
}, [])
|
||||
|
||||
const flowContextValue = useMemo(
|
||||
() => ({
|
||||
nodes,
|
||||
@@ -609,20 +477,20 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef,
|
||||
fullscreenNodeId,
|
||||
setFullscreenNodeId,
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
addConnectionPathTrigger,
|
||||
connectionPathNodeIds,
|
||||
connectionPathPausedSegmentNodeIds,
|
||||
connectionPathActiveSegmentNodeIds,
|
||||
connectionPathPausedNodeIds,
|
||||
addConnectionPathPausedNode,
|
||||
removeConnectionPathPausedNode,
|
||||
connectionPathErrorNodeIds,
|
||||
addConnectionPathError,
|
||||
removeConnectionPathError,
|
||||
startConnectionPathUpdate,
|
||||
endConnectionPathUpdate,
|
||||
connectionPathUpdatingNodeIds: connectionPath.connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds: connectionPath.connectionPathTriggerNodeIds,
|
||||
addConnectionPathTrigger: connectionPath.addConnectionPathTrigger,
|
||||
connectionPathNodeIds: connectionPath.connectionPathNodeIds,
|
||||
connectionPathPausedSegmentNodeIds: connectionPath.connectionPathPausedSegmentNodeIds,
|
||||
connectionPathActiveSegmentNodeIds: connectionPath.connectionPathActiveSegmentNodeIds,
|
||||
connectionPathPausedNodeIds: connectionPath.connectionPathPausedNodeIds,
|
||||
addConnectionPathPausedNode: connectionPath.addConnectionPathPausedNode,
|
||||
removeConnectionPathPausedNode: connectionPath.removeConnectionPathPausedNode,
|
||||
connectionPathErrorNodeIds: connectionPath.connectionPathErrorNodeIds,
|
||||
addConnectionPathError: connectionPath.addConnectionPathError,
|
||||
removeConnectionPathError: connectionPath.removeConnectionPathError,
|
||||
startConnectionPathUpdate: connectionPath.startConnectionPathUpdate,
|
||||
endConnectionPathUpdate: connectionPath.endConnectionPathUpdate,
|
||||
}),
|
||||
[
|
||||
nodes,
|
||||
@@ -637,20 +505,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
flowActionsRef,
|
||||
fullscreenNodeId,
|
||||
setFullscreenNodeId,
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
addConnectionPathTrigger,
|
||||
connectionPathNodeIds,
|
||||
connectionPathPausedSegmentNodeIds,
|
||||
connectionPathActiveSegmentNodeIds,
|
||||
connectionPathPausedNodeIds,
|
||||
addConnectionPathPausedNode,
|
||||
removeConnectionPathPausedNode,
|
||||
connectionPathErrorNodeIds,
|
||||
addConnectionPathError,
|
||||
removeConnectionPathError,
|
||||
startConnectionPathUpdate,
|
||||
endConnectionPathUpdate,
|
||||
connectionPath,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -900,39 +755,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-48" aria-label="Canvas menu: create node or paste">
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Create Node</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-44">
|
||||
{getRegisteredNodeTypesGroupedByClassification().map(
|
||||
(group, groupIndex) =>
|
||||
group.types.length > 0 && (
|
||||
<React.Fragment key={group.classification}>
|
||||
{groupIndex > 0 && <ContextMenuSeparator />}
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLabel className="text-muted-foreground">
|
||||
{group.label}
|
||||
</ContextMenuLabel>
|
||||
{group.types.map((desc) => (
|
||||
<ContextMenuItem key={desc.id} onSelect={() => createNode(desc.id)}>
|
||||
{desc.menuIcon}
|
||||
{desc.menuLabel}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</ContextMenuGroup>
|
||||
</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => pasteNode()}>
|
||||
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
</ContextMenuGroup>
|
||||
</ContextMenuContent>
|
||||
<CanvasContextMenuContent onCreateNode={createNode} onPaste={pasteNode} />
|
||||
</ContextMenu>
|
||||
{fullscreenNodeId && (
|
||||
<FullscreenNodeOverlay
|
||||
|
||||
174
frontend/src/app/canvas/useCanvasConnectionPath.ts
Normal file
174
frontend/src/app/canvas/useCanvasConnectionPath.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Hook that holds all connection-path state and callbacks for the canvas.
|
||||
* Used to show the "ant trail" along the path of an update (e.g. config → agent → render).
|
||||
* Extracted from CanvasPage to keep the page focused on composition.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
|
||||
export type EdgeLike = { source: string; target: string }
|
||||
|
||||
/** Minimum time (ms) the connection ant trail runs when a path update is in progress. */
|
||||
const CONNECTION_PATH_UPDATE_MIN_MS = 1500
|
||||
|
||||
export type UseCanvasConnectionPathResult = {
|
||||
connectionPathUpdatingNodeIds: string[]
|
||||
connectionPathTriggerNodeIds: string[]
|
||||
connectionPathPausedNodeIds: string[]
|
||||
connectionPathErrorNodeIds: string[]
|
||||
connectionPathNodeIds: Set<string>
|
||||
connectionPathPausedSegmentNodeIds: Set<string>
|
||||
connectionPathActiveSegmentNodeIds: Set<string>
|
||||
startConnectionPathUpdate: (nodeId: string) => void
|
||||
endConnectionPathUpdate: (nodeId: string) => void
|
||||
addConnectionPathTrigger: (nodeId: string) => void
|
||||
addConnectionPathPausedNode: (nodeId: string) => void
|
||||
removeConnectionPathPausedNode: (nodeId: string) => void
|
||||
addConnectionPathError: (nodeId: string) => void
|
||||
removeConnectionPathError: (nodeId: string) => void
|
||||
}
|
||||
|
||||
export function useCanvasConnectionPath(edges: EdgeLike[]): UseCanvasConnectionPathResult {
|
||||
const [connectionPathUpdatingNodeIds, setConnectionPathUpdatingNodeIds] = useState<string[]>([])
|
||||
const [connectionPathTriggerNodeIds, setConnectionPathTriggerNodeIds] = useState<string[]>([])
|
||||
const [connectionPathPausedNodeIds, setConnectionPathPausedNodeIds] = useState<string[]>([])
|
||||
const [connectionPathErrorNodeIds, setConnectionPathErrorNodeIds] = useState<string[]>([])
|
||||
|
||||
const pathUpdateNodeIdsRef = useRef<Set<string>>(new Set())
|
||||
const pathUpdateStartTimeRef = useRef<number | null>(null)
|
||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const connectionPathPausedNodeIdsRef = useRef<string[]>([])
|
||||
connectionPathPausedNodeIdsRef.current = connectionPathPausedNodeIds
|
||||
|
||||
const pathTriggerBatchRef = useRef<Set<string>>(new Set())
|
||||
const pathTriggerScheduledRef = useRef(false)
|
||||
|
||||
const clearPathUpdateSession = useCallback(() => {
|
||||
setConnectionPathUpdatingNodeIds([])
|
||||
if (connectionPathPausedNodeIdsRef.current.length === 0) {
|
||||
setConnectionPathTriggerNodeIds([])
|
||||
setConnectionPathPausedNodeIds([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.add(nodeId)
|
||||
if (ref.size === 1) {
|
||||
pathUpdateStartTimeRef.current = Date.now()
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
}, [])
|
||||
|
||||
const endConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
const ref = pathUpdateNodeIdsRef.current
|
||||
ref.delete(nodeId)
|
||||
if (ref.size > 0) {
|
||||
setConnectionPathUpdatingNodeIds(Array.from(ref))
|
||||
return
|
||||
}
|
||||
const startedAt = pathUpdateStartTimeRef.current ?? 0
|
||||
const elapsed = Date.now() - startedAt
|
||||
const remaining = Math.max(0, CONNECTION_PATH_UPDATE_MIN_MS - elapsed)
|
||||
if (remaining === 0) {
|
||||
clearPathUpdateSession()
|
||||
} else {
|
||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
clearPathUpdateSession()
|
||||
}, remaining)
|
||||
}
|
||||
}, [clearPathUpdateSession])
|
||||
|
||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||
pathTriggerBatchRef.current.add(nodeId)
|
||||
if (pathTriggerScheduledRef.current) return
|
||||
pathTriggerScheduledRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
pathTriggerScheduledRef.current = false
|
||||
const batch = new Set(pathTriggerBatchRef.current)
|
||||
pathTriggerBatchRef.current = new Set()
|
||||
if (batch.size === 0) return
|
||||
setConnectionPathTriggerNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
batch.forEach((id) => next.add(id))
|
||||
return next.size === prev.length && prev.every((id) => next.has(id)) ? prev : Array.from(next)
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const connectionPathNodeIds = useMemo(
|
||||
() =>
|
||||
getPathNodeIds(
|
||||
edges,
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
connectionPathPausedNodeIds
|
||||
),
|
||||
[edges, connectionPathUpdatingNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
||||
)
|
||||
|
||||
const connectionPathPausedSegmentNodeIds = useMemo(
|
||||
() =>
|
||||
getPausedSegmentNodeIds(
|
||||
edges,
|
||||
connectionPathNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
connectionPathPausedNodeIds
|
||||
),
|
||||
[edges, connectionPathNodeIds, connectionPathTriggerNodeIds, connectionPathPausedNodeIds]
|
||||
)
|
||||
|
||||
const connectionPathActiveSegmentNodeIds = useMemo(() => {
|
||||
const active = new Set(connectionPathNodeIds)
|
||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
||||
return active
|
||||
}, [connectionPathNodeIds, connectionPathPausedSegmentNodeIds])
|
||||
|
||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
setConnectionPathPausedNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
||||
}, [])
|
||||
|
||||
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
setConnectionPathPausedNodeIds((prev) => prev.filter((id) => id !== nodeId))
|
||||
}, [])
|
||||
|
||||
const addConnectionPathError = useCallback((nodeId: string) => {
|
||||
setConnectionPathErrorNodeIds((prev) => (prev.includes(nodeId) ? prev : [...prev, nodeId]))
|
||||
}, [])
|
||||
|
||||
const removeConnectionPathError = useCallback((nodeId: string) => {
|
||||
setConnectionPathErrorNodeIds((prev) => prev.filter((id) => id !== nodeId))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
connectionPathUpdatingNodeIds,
|
||||
connectionPathTriggerNodeIds,
|
||||
connectionPathPausedNodeIds,
|
||||
connectionPathErrorNodeIds,
|
||||
connectionPathNodeIds,
|
||||
connectionPathPausedSegmentNodeIds,
|
||||
connectionPathActiveSegmentNodeIds,
|
||||
startConnectionPathUpdate,
|
||||
endConnectionPathUpdate,
|
||||
addConnectionPathTrigger,
|
||||
addConnectionPathPausedNode,
|
||||
removeConnectionPathPausedNode,
|
||||
addConnectionPathError,
|
||||
removeConnectionPathError,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* Per-node menubar (Node, Output, Data, Inputs, Insert). Content can be passed as props
|
||||
* or resolved from the node type descriptor: when nodeMenuExtraContent is not provided,
|
||||
* it is taken from getNodeType(nodeType)?.getNodeMenuExtraContent?.(nodeId, data).
|
||||
*/
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import FlowContext from '@/lib/graph/flowContext'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { getResolvedContentForConfig } from './renderingLogic'
|
||||
import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry'
|
||||
import ConfigNode from './ConfigNode'
|
||||
import { createImageExportHandlers, type OutputMenuContext } from '@/components/nodes/render/outputMenuRegistry'
|
||||
import { createImageExportHandlers, type OutputMenuContext } from '@/components/nodes/render/outputMenuHandlers'
|
||||
|
||||
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||
|
||||
|
||||
@@ -7,43 +7,11 @@
|
||||
import nunjucks from 'nunjucks'
|
||||
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
|
||||
import { isReachable, resolveExtendsRef, getTemplateRefs } from '@/lib/graph/templateRefs'
|
||||
|
||||
type Node = { id: string; type?: string; data?: unknown }
|
||||
type Edge = { id: string; source: string; target: string }
|
||||
|
||||
function isReachable(edges: Edge[], startId: string, targetId: string): boolean {
|
||||
const q: string[] = [startId]
|
||||
const seen = new Set<string>([startId])
|
||||
while (q.length) {
|
||||
const cur = q.shift()!
|
||||
if (cur === targetId) return true
|
||||
for (const e of edges) {
|
||||
if (e.source === cur && !seen.has(e.target)) {
|
||||
seen.add(e.target)
|
||||
q.push(e.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function resolveExtendsRef(nodes: Node[], name: string): string {
|
||||
const refName = name.replace(/\.(puml|html)$/, '').trim()
|
||||
return nodes.find((n) => n.id === refName || (n.data as Record<string, unknown>)?.title === refName)?.id ?? refName
|
||||
}
|
||||
|
||||
function getTemplateRefs(content: string): string[] {
|
||||
const refs: string[] = []
|
||||
const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/)
|
||||
if (extendMatch) refs.push(extendMatch[1].trim())
|
||||
const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g
|
||||
let m
|
||||
while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g
|
||||
while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
return refs
|
||||
}
|
||||
|
||||
export async function getResolvedContentForConfig(context: SourceRenderingLogicContext): Promise<ResolvedContentResult> {
|
||||
const { nodes, edges, sourceNodeId, renderNodeId } = context
|
||||
const srcId = sourceNodeId
|
||||
@@ -56,11 +24,11 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
||||
const configIdsUsed = new Set<string>()
|
||||
|
||||
const addConfigAndRefs = (templateName: string, visited = new Set<string>()) => {
|
||||
const refId = resolveExtendsRef(nodes, templateName)
|
||||
const refId = resolveExtendsRef(nodes as { id: string; data?: unknown }[], templateName)
|
||||
if (visited.has(refId)) throw new Error(`Circular reference detected: ${templateName}`)
|
||||
const node = nodes.find((n) => n.id === refId && n.type === 'config')
|
||||
if (!node) throw new Error(`Config not found: ${templateName}`)
|
||||
if (refId !== srcId && !isReachable(edges, refId, id))
|
||||
if (refId !== srcId && !isReachable(edges as { source: string; target: string }[], refId, id))
|
||||
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
|
||||
visited.add(refId)
|
||||
configIdsUsed.add(refId)
|
||||
@@ -72,10 +40,10 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
|
||||
|
||||
const configLoader = {
|
||||
getSource: (name: string): { src: string; path: string } | null => {
|
||||
const refId = resolveExtendsRef(nodes, name)
|
||||
const refId = resolveExtendsRef(nodes as { id: string; data?: unknown }[], name)
|
||||
const node = nodes.find((n) => n.id === refId && n.type === 'config')
|
||||
if (!node) return null
|
||||
if (refId !== srcId && !isReachable(edges, refId, id))
|
||||
if (refId !== srcId && !isReachable(edges as { source: string; target: string }[], refId, id))
|
||||
throw new Error(`Referenced config not connected to renderer: ${name}`)
|
||||
return {
|
||||
src: getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { default as RenderingNode, type RenderingNodeData } from './RenderingNode'
|
||||
export { getRenderNodeDescriptor } from './descriptor'
|
||||
export { createImageExportHandlers } from './outputMenuRegistry'
|
||||
export type { OutputMenuContext } from './outputMenuRegistry'
|
||||
export { createImageExportHandlers } from './outputMenuHandlers'
|
||||
export type { OutputMenuContext } from './outputMenuHandlers'
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
processSvgDisplay,
|
||||
stripTemplateSyntax,
|
||||
} from '@/lib/graph/rendering'
|
||||
import { buildSourceSignatures } from '@/lib/graph/renderingSignatures'
|
||||
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||
|
||||
export type RenderingNodeData = {
|
||||
@@ -135,133 +136,25 @@ export function useRenderingNodeState(
|
||||
? agentOutputMarkdown
|
||||
: ''
|
||||
|
||||
const connectedNodeIds = useMemo(() => {
|
||||
const out = new Set<string>()
|
||||
const isReachable = (startId: string, targetId: string) => {
|
||||
const q: string[] = [startId]
|
||||
const seen = new Set<string>([startId])
|
||||
while (q.length) {
|
||||
const cur = q.shift()!
|
||||
if (cur === targetId) return true
|
||||
for (const e of edges as { source: string; target: string }[]) {
|
||||
if (e.source === cur && !seen.has(e.target)) {
|
||||
seen.add(e.target)
|
||||
q.push(e.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
const resolveRef = (name: string) => {
|
||||
const refName = name.replace(/\.(puml|html)$/, '').trim()
|
||||
return (nodes as { id: string; data?: { title?: string } }[]).find(
|
||||
(n) => n.id === refName || n.data?.title === refName
|
||||
)?.id ?? refName
|
||||
}
|
||||
const getTemplateRefs = (content: string): string[] => {
|
||||
const refs: string[] = []
|
||||
const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/)
|
||||
if (extendMatch) refs.push(extendMatch[1].trim())
|
||||
const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g
|
||||
let m
|
||||
while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g
|
||||
while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
return refs
|
||||
}
|
||||
const addConfigRefs = (nodeId: string, visited: Set<string>) => {
|
||||
if (visited.has(nodeId)) return
|
||||
const node = (nodes as { id: string; type?: string; data?: unknown }[]).find(
|
||||
(n) => n.id === nodeId && n.type === 'config'
|
||||
)
|
||||
if (!node) return
|
||||
visited.add(nodeId)
|
||||
out.add(nodeId)
|
||||
const content = getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined)
|
||||
for (const ref of getTemplateRefs(content)) {
|
||||
const refId = resolveRef(ref)
|
||||
if (
|
||||
refId &&
|
||||
(nodes as { id: string; type?: string }[]).some((n) => n.id === refId && n.type === 'config') &&
|
||||
isReachable(refId, id)
|
||||
) {
|
||||
addConfigRefs(refId, visited)
|
||||
}
|
||||
}
|
||||
}
|
||||
const configVisited = new Set<string>()
|
||||
for (const nid of incomingIds) {
|
||||
const node = (nodes as { id: string; type?: string }[]).find((n) => n.id === nid)
|
||||
if (node?.type === 'config') addConfigRefs(nid, configVisited)
|
||||
else out.add(nid)
|
||||
}
|
||||
for (const e of edges as { source: string; target: string }[]) {
|
||||
if (out.has(e.target)) out.add(e.source)
|
||||
}
|
||||
return out
|
||||
}, [nodes, edges, id, incomingIds])
|
||||
|
||||
const configSignature = useMemo(
|
||||
const signatures = useMemo(
|
||||
() =>
|
||||
(nodes as { id: string; type?: string; data?: unknown }[])
|
||||
.filter((n) => n.type === 'config' && connectedNodeIds.has(n.id))
|
||||
.map((n) => `${n.id}:${(n.data as { title?: string })?.title ?? ''}:${getConfigContent(n.data as Record<string, unknown>)}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
const edgesSignature = useMemo(
|
||||
() =>
|
||||
(edges as { source: string; target: string }[])
|
||||
.filter(
|
||||
(e) =>
|
||||
connectedNodeIds.has(e.source) &&
|
||||
(connectedNodeIds.has(e.target) || e.target === id)
|
||||
)
|
||||
.map((e) => `${e.source}->${e.target}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[edges, connectedNodeIds, id]
|
||||
)
|
||||
const variablesSignature = useMemo(
|
||||
() =>
|
||||
(nodes as { id: string; type?: string; data?: { value?: unknown } }[])
|
||||
.filter((n) => n.type === 'variable' && connectedNodeIds.has(n.id))
|
||||
.map((n) => `${n.id}:${n.data?.value}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
const functionsSignature = useMemo(
|
||||
() =>
|
||||
(nodes as { id: string; type?: string; data?: { body?: string } }[])
|
||||
.filter((n) => n.type === 'function' && connectedNodeIds.has(n.id))
|
||||
.map((n) => `${n.id}:${n.data?.body ?? ''}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
const dataSignature = useMemo(
|
||||
() =>
|
||||
(nodes as { id: string; type?: string; data?: { rows?: unknown[]; hiddenColumns?: unknown[] } }[])
|
||||
.filter((n) => n.type === 'data' && connectedNodeIds.has(n.id))
|
||||
.map((n) => `${n.id}:${JSON.stringify(n.data?.rows ?? [])}:${JSON.stringify(n.data?.hiddenColumns ?? [])}`)
|
||||
.sort()
|
||||
.join('|'),
|
||||
[nodes, connectedNodeIds]
|
||||
)
|
||||
|
||||
const sourceSignature = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
configSignature,
|
||||
edgesSignature,
|
||||
variablesSignature,
|
||||
functionsSignature,
|
||||
dataSignature,
|
||||
}),
|
||||
[configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature]
|
||||
buildSourceSignatures(
|
||||
nodes as { id: string; type?: string; data?: unknown }[],
|
||||
edges as { source: string; target: string }[],
|
||||
id,
|
||||
incomingIds
|
||||
),
|
||||
[nodes, edges, id, incomingIds]
|
||||
)
|
||||
const {
|
||||
connectedNodeIds,
|
||||
configSignature,
|
||||
edgesSignature,
|
||||
variablesSignature,
|
||||
functionsSignature,
|
||||
dataSignature,
|
||||
sourceSignature,
|
||||
} = signatures
|
||||
|
||||
const lastRunSourceSignature = data?.lastRunSourceSignature
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Central node and edge types for the app. Use AppNode / AppEdge in graph state and context.
|
||||
* React Flow node/edge types for the app (graph state and context). AppNode / AppEdge
|
||||
* describe the shape of nodes and edges; the node type *id* (e.g. 'config', 'render')
|
||||
* lives in nodeRegistry and is stored as node.type.
|
||||
*/
|
||||
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
|
||||
@@ -13,15 +13,20 @@
|
||||
*
|
||||
* 3. **Display** — The Rendering node picks a view (image viewport vs markdown content)
|
||||
* from `outputType` on the renderer and shows the result. Output menu items (e.g. Export)
|
||||
* come from the source/renderer via `outputMenuDescriptor`.
|
||||
* come from the source node descriptor's getOutputMenuContent (see NodeTypeDescriptor).
|
||||
*
|
||||
* ## Implementing a new source
|
||||
* - Implement `IRenderingSource` (resolve + optional output menu).
|
||||
* - Register with `registerSourceRenderingLogic(nodeType, logic)`.
|
||||
* - Implement source rendering logic (resolve step) and set it on the node descriptor
|
||||
* via .sourceRenderingLogic(). Optionally set .outputMenuContent() for the Output menu.
|
||||
* - The descriptor is registered via registerNodeType(); source logic is registered automatically.
|
||||
*
|
||||
* ## Implementing a new output type
|
||||
* - Add a config type (or equivalent) fulfilling `IOutputTypeRenderer`.
|
||||
* - Register in CONFIG_TYPES and use `getConfigType(id)` in the resolve step.
|
||||
*
|
||||
* ## Shared helpers
|
||||
* - templateRefs.ts: isReachable, resolveExtendsRef, getTemplateRefs (Nunjucks extends/include/import).
|
||||
* Used by config resolve and the rendering node hook so template/reachability logic stays in one place.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -60,8 +65,7 @@ export interface IResolveResult {
|
||||
|
||||
/**
|
||||
* Contract for a node type that can feed the Rendering node (config, agent, etc.).
|
||||
* Register via registerSourceRenderingLogic(nodeType, logic).
|
||||
* Output menu content is registered per output type in the frontend (outputMenuRegistry).
|
||||
* Set on the node descriptor via .sourceRenderingLogic(); output menu via .outputMenuContent().
|
||||
*/
|
||||
export interface IRenderingSource {
|
||||
/** When to re-run: 'auto' on upstream changes, 'manual' only on Run. */
|
||||
|
||||
142
frontend/src/lib/graph/renderingSignatures.ts
Normal file
142
frontend/src/lib/graph/renderingSignatures.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Pure functions to build "connected node ids" and source signatures for the
|
||||
* rendering pipeline. Used by useRenderingNodeState so the hook stays thin and
|
||||
* this logic is testable in isolation.
|
||||
*/
|
||||
|
||||
import { getConfigContent } from '@/lib/graph/rendering'
|
||||
import { isReachable, resolveExtendsRef, getTemplateRefs } from '@/lib/graph/templateRefs'
|
||||
|
||||
export type NodeLike = { id: string; type?: string; data?: unknown }
|
||||
export type EdgeLike = { source: string; target: string }
|
||||
|
||||
/**
|
||||
* Collects all node ids that affect the render: direct incoming nodes plus
|
||||
* config nodes reached via template refs (extends/include/import) that are
|
||||
* reachable to the render node.
|
||||
*/
|
||||
export function buildConnectedNodeIds(
|
||||
nodes: NodeLike[],
|
||||
edges: EdgeLike[],
|
||||
renderNodeId: string,
|
||||
incomingIds: string[],
|
||||
getContent: (data: Record<string, unknown> | undefined) => string = getConfigContent
|
||||
): Set<string> {
|
||||
const out = new Set<string>()
|
||||
const addConfigRefs = (nodeId: string, visited: Set<string>) => {
|
||||
if (visited.has(nodeId)) return
|
||||
const node = nodes.find((n) => n.id === nodeId && n.type === 'config')
|
||||
if (!node) return
|
||||
visited.add(nodeId)
|
||||
out.add(nodeId)
|
||||
const content = getContent((node.data ?? undefined) as Record<string, unknown> | undefined)
|
||||
for (const ref of getTemplateRefs(content)) {
|
||||
const refId = resolveExtendsRef(nodes, ref)
|
||||
if (
|
||||
refId &&
|
||||
nodes.some((n) => n.id === refId && n.type === 'config') &&
|
||||
isReachable(edges, refId, renderNodeId)
|
||||
) {
|
||||
addConfigRefs(refId, visited)
|
||||
}
|
||||
}
|
||||
}
|
||||
const configVisited = new Set<string>()
|
||||
for (const nid of incomingIds) {
|
||||
const node = nodes.find((n) => n.id === nid)
|
||||
if (node?.type === 'config') addConfigRefs(nid, configVisited)
|
||||
else out.add(nid)
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (out.has(e.target)) out.add(e.source)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export type SourceSignatures = {
|
||||
connectedNodeIds: Set<string>
|
||||
configSignature: string
|
||||
edgesSignature: string
|
||||
variablesSignature: string
|
||||
functionsSignature: string
|
||||
dataSignature: string
|
||||
sourceSignature: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds connected node set and all signatures used to decide when to re-run
|
||||
* the resolve → render pipeline.
|
||||
*/
|
||||
export function buildSourceSignatures(
|
||||
nodes: NodeLike[],
|
||||
edges: EdgeLike[],
|
||||
renderNodeId: string,
|
||||
incomingIds: string[],
|
||||
getContent: (data: Record<string, unknown> | undefined) => string = getConfigContent
|
||||
): SourceSignatures {
|
||||
const connectedNodeIds = buildConnectedNodeIds(
|
||||
nodes,
|
||||
edges,
|
||||
renderNodeId,
|
||||
incomingIds,
|
||||
getContent
|
||||
)
|
||||
|
||||
const configSignature = nodes
|
||||
.filter((n) => n.type === 'config' && connectedNodeIds.has(n.id))
|
||||
.map(
|
||||
(n) =>
|
||||
`${n.id}:${(n.data as { title?: string })?.title ?? ''}:${getContent(n.data as Record<string, unknown>)}`
|
||||
)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
const edgesSignature = edges
|
||||
.filter(
|
||||
(e) =>
|
||||
connectedNodeIds.has(e.source) &&
|
||||
(connectedNodeIds.has(e.target) || e.target === renderNodeId)
|
||||
)
|
||||
.map((e) => `${e.source}->${e.target}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
const variablesSignature = nodes
|
||||
.filter((n) => n.type === 'variable' && connectedNodeIds.has(n.id))
|
||||
.map((n) => `${n.id}:${(n.data as { value?: unknown })?.value}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
const functionsSignature = nodes
|
||||
.filter((n) => n.type === 'function' && connectedNodeIds.has(n.id))
|
||||
.map((n) => `${n.id}:${(n.data as { body?: string })?.body ?? ''}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
const dataSignature = nodes
|
||||
.filter((n) => n.type === 'data' && connectedNodeIds.has(n.id))
|
||||
.map(
|
||||
(n) =>
|
||||
`${n.id}:${JSON.stringify((n.data as { rows?: unknown[] })?.rows ?? [])}:${JSON.stringify((n.data as { hiddenColumns?: unknown[] })?.hiddenColumns ?? [])}`
|
||||
)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
const sourceSignature = JSON.stringify({
|
||||
configSignature,
|
||||
edgesSignature,
|
||||
variablesSignature,
|
||||
functionsSignature,
|
||||
dataSignature,
|
||||
})
|
||||
|
||||
return {
|
||||
connectedNodeIds,
|
||||
configSignature,
|
||||
edgesSignature,
|
||||
variablesSignature,
|
||||
functionsSignature,
|
||||
dataSignature,
|
||||
sourceSignature,
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export type ResolvedContentResult = {
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
/** Source logic implementation. Implements {@link IRenderingSource}. Output menu content is provided per output type via the frontend registry (see outputMenuRegistry). */
|
||||
/** Source logic implementation. Implements {@link IRenderingSource}. Output menu is provided by the node descriptor's getOutputMenuContent. */
|
||||
export type SourceRenderingLogic = {
|
||||
defaultUpdateMode: 'auto' | 'manual'
|
||||
getResolvedContent: (context: SourceRenderingLogicContext) => Promise<ResolvedContentResult>
|
||||
|
||||
54
frontend/src/lib/graph/templateRefs.ts
Normal file
54
frontend/src/lib/graph/templateRefs.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Shared helpers for Nunjucks template references (extends/include/import) and
|
||||
* graph reachability. Used by config resolve logic and the rendering node hook
|
||||
* so template parsing and "connected configs" stay in one place.
|
||||
*/
|
||||
|
||||
export type EdgeLike = { source: string; target: string }
|
||||
export type NodeLike = { id: string; data?: unknown }
|
||||
|
||||
/** BFS: is target reachable from start following directed edges? */
|
||||
export function isReachable(
|
||||
edges: EdgeLike[],
|
||||
startId: string,
|
||||
targetId: string
|
||||
): boolean {
|
||||
const q: string[] = [startId]
|
||||
const seen = new Set<string>([startId])
|
||||
while (q.length) {
|
||||
const cur = q.shift()!
|
||||
if (cur === targetId) return true
|
||||
for (const e of edges) {
|
||||
if (e.source === cur && !seen.has(e.target)) {
|
||||
seen.add(e.target)
|
||||
q.push(e.target)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Resolve a template name to a node id (by id or by data.title). */
|
||||
export function resolveExtendsRef(nodes: NodeLike[], name: string): string {
|
||||
const refName = name.replace(/\.(puml|html)$/, '').trim()
|
||||
return (
|
||||
nodes.find(
|
||||
(n) =>
|
||||
n.id === refName ||
|
||||
(n.data as Record<string, unknown>)?.title === refName
|
||||
)?.id ?? refName
|
||||
)
|
||||
}
|
||||
|
||||
/** Extract template refs from content: extends "x", include "y", import "z" as ... */
|
||||
export function getTemplateRefs(content: string): string[] {
|
||||
const refs: string[] = []
|
||||
const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/)
|
||||
if (extendMatch) refs.push(extendMatch[1].trim())
|
||||
const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g
|
||||
let m
|
||||
while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g
|
||||
while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim())
|
||||
return refs
|
||||
}
|
||||
Reference in New Issue
Block a user