refactor: nodes as builder pattern
This commit is contained in:
@@ -16,6 +16,7 @@ import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndica
|
|||||||
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
|
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
|
||||||
import { InputHandle, OutputHandle } from '@/components/base/NodeHandles'
|
import { InputHandle, OutputHandle } from '@/components/base/NodeHandles'
|
||||||
import FlowContext from '@/lib/flowContext'
|
import FlowContext from '@/lib/flowContext'
|
||||||
|
import { useSyncConnectionStatus } from '@/lib/nodeLifecycle'
|
||||||
import { getNodeType } from '@/lib/nodeRegistry'
|
import { getNodeType } from '@/lib/nodeRegistry'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
@@ -57,10 +58,6 @@ function serializeNodeForContext(nodes: { id: string; type?: string; data?: unkn
|
|||||||
function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
||||||
const flowContext = useContext(FlowContext)
|
const flowContext = useContext(FlowContext)
|
||||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||||
const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate
|
|
||||||
const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate
|
|
||||||
const addConnectionPathPausedNode = flowContext?.addConnectionPathPausedNode
|
|
||||||
const removeConnectionPathPausedNode = flowContext?.removeConnectionPathPausedNode
|
|
||||||
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
|
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
|
||||||
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
|
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
|
||||||
const { aiConnection } = usePlatform()
|
const { aiConnection } = usePlatform()
|
||||||
@@ -103,9 +100,7 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
const prompt = configContents.length > 0 ? configContents.join('\n\n---\n\n') : 'No prompt provided. Please describe what you want in structured markdown.'
|
const prompt = configContents.length > 0 ? configContents.join('\n\n---\n\n') : 'No prompt provided. Please describe what you want in structured markdown.'
|
||||||
const contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) }))
|
const contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) }))
|
||||||
|
|
||||||
removeConnectionPathPausedNode?.(id)
|
|
||||||
updateData({ error: undefined, loading: true })
|
updateData({ error: undefined, loading: true })
|
||||||
startConnectionPathUpdate?.(id)
|
|
||||||
setRunning(true)
|
setRunning(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/agent', {
|
const res = await fetch('/api/agent', {
|
||||||
@@ -125,7 +120,6 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
error: (json as { error?: string }).error ?? `Request failed: ${res.status}`,
|
error: (json as { error?: string }).error ?? `Request failed: ${res.status}`,
|
||||||
outputMarkdown: undefined,
|
outputMarkdown: undefined,
|
||||||
})
|
})
|
||||||
endConnectionPathUpdate?.(id)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const markdown = (json as { markdown?: string }).markdown ?? ''
|
const markdown = (json as { markdown?: string }).markdown ?? ''
|
||||||
@@ -135,18 +129,16 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
outputMarkdown: markdown,
|
outputMarkdown: markdown,
|
||||||
lastRunSourceSignature: sourceSignature,
|
lastRunSourceSignature: sourceSignature,
|
||||||
})
|
})
|
||||||
endConnectionPathUpdate?.(id)
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
updateData({
|
updateData({
|
||||||
loading: false,
|
loading: false,
|
||||||
error: err instanceof Error ? err.message : 'Agent request failed',
|
error: err instanceof Error ? err.message : 'Agent request failed',
|
||||||
outputMarkdown: undefined,
|
outputMarkdown: undefined,
|
||||||
})
|
})
|
||||||
endConnectionPathUpdate?.(id)
|
|
||||||
} finally {
|
} finally {
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
}
|
}
|
||||||
}, [sourceIds, nodes, contextText, sourceSignature, updateData, aiConnection, removeConnectionPathPausedNode, startConnectionPathUpdate, endConnectionPathUpdate])
|
}, [sourceIds, nodes, contextText, sourceSignature, updateData, aiConnection])
|
||||||
|
|
||||||
const onContextChange = useCallback(
|
const onContextChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => updateData({ context: e.target.value }),
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => updateData({ context: e.target.value }),
|
||||||
@@ -161,10 +153,12 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
triggerNodeIds.length > 0 &&
|
triggerNodeIds.length > 0 &&
|
||||||
!loading &&
|
!loading &&
|
||||||
sourceSignature !== lastRunSourceSignature
|
sourceSignature !== lastRunSourceSignature
|
||||||
useEffect(() => {
|
|
||||||
if (hasPendingInputs) addConnectionPathPausedNode?.(id)
|
useSyncConnectionStatus(id, {
|
||||||
else removeConnectionPathPausedNode?.(id)
|
updating: running || loading,
|
||||||
}, [id, hasPendingInputs, addConnectionPathPausedNode, removeConnectionPathPausedNode])
|
error: !!error,
|
||||||
|
paused: hasPendingInputs && !running && !loading,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseNode
|
<BaseNode
|
||||||
24
frontend/src/components/nodes/agent/descriptor.tsx
Normal file
24
frontend/src/components/nodes/agent/descriptor.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Bot } from 'lucide-react'
|
||||||
|
import { createNodeTypeBuilder } from '@/lib/nodeTypeBuilder'
|
||||||
|
import { NODE_HELP } from '@/lib/nodeHelp'
|
||||||
|
import type { NodeTypeDescriptor } from '@/lib/nodeRegistry'
|
||||||
|
import AgentNode from './AgentNode'
|
||||||
|
import { agentRenderingLogic } from './renderingLogic'
|
||||||
|
|
||||||
|
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||||
|
|
||||||
|
export function getAgentNodeDescriptor(): NodeTypeDescriptor {
|
||||||
|
return createNodeTypeBuilder('agent', AgentNode, { width: 360, height: 320 }, { context: '' })
|
||||||
|
.idPrefix('agt_')
|
||||||
|
.withInputOutput(true, true)
|
||||||
|
.classification('archon')
|
||||||
|
.allowedSourceTypes(['config', 'variable', 'data'])
|
||||||
|
.allowedTargetTypes(['render'])
|
||||||
|
.help(NODE_HELP.agent)
|
||||||
|
.menu('Agent', <Bot className={ICON_CLASS} />)
|
||||||
|
.connectionLabel('prompt/context')
|
||||||
|
.withFullscreen()
|
||||||
|
.sourceRenderingLogic(agentRenderingLogic)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
2
frontend/src/components/nodes/agent/index.ts
Normal file
2
frontend/src/components/nodes/agent/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as AgentNode, type AgentNodeData } from './AgentNode'
|
||||||
|
export { getAgentNodeDescriptor } from './descriptor'
|
||||||
15
frontend/src/components/nodes/agent/renderingLogic.ts
Normal file
15
frontend/src/components/nodes/agent/renderingLogic.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Agent node rendering logic: provides the agent's markdown output to the Rendering node.
|
||||||
|
* Update mode is manual (user clicks Run on the renderer).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SourceRenderingLogic } from '@/lib/sourceRenderingLogic'
|
||||||
|
|
||||||
|
export const agentRenderingLogic: SourceRenderingLogic = {
|
||||||
|
defaultUpdateMode: 'manual',
|
||||||
|
getResolvedContent: async (context) => {
|
||||||
|
const sourceNode = context.nodes.find((n) => n.id === context.sourceNodeId)
|
||||||
|
const outputMarkdown = (sourceNode?.data as { outputMarkdown?: string } | undefined)?.outputMarkdown ?? ''
|
||||||
|
return { resolved: outputMarkdown, outputTypeId: 'markdown' }
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
createAbstractNodeComponent,
|
createAbstractNodeComponent,
|
||||||
useAbstractNode,
|
useAbstractNode,
|
||||||
type FlowNode,
|
type FlowNode,
|
||||||
} from '../../lib/abstractNode'
|
} from '@/lib/abstractNode'
|
||||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||||
import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete'
|
import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete'
|
||||||
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
import { plantumlLanguage } from '@/lib/plantumlLanguage'
|
||||||
import { useTheme } from '../../lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import {
|
import {
|
||||||
CONFIG_TYPES,
|
CONFIG_TYPES,
|
||||||
getConfigContent,
|
getConfigContent,
|
||||||
@@ -20,13 +20,13 @@ import {
|
|||||||
getConfigTypeId,
|
getConfigTypeId,
|
||||||
isGroup,
|
isGroup,
|
||||||
type ConfigTypeId,
|
type ConfigTypeId,
|
||||||
} from '../../lib/configTypes'
|
} from '@/lib/configTypes'
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
BaseNodeContent,
|
BaseNodeContent,
|
||||||
BaseNodeFooter,
|
BaseNodeFooter,
|
||||||
BaseNodeHeaderRow,
|
BaseNodeHeaderRow,
|
||||||
} from '../base/BaseNode'
|
} from '@/components/base/BaseNode'
|
||||||
import { Code2, Database, ScrollText, Variable } from 'lucide-react'
|
import { Code2, Database, ScrollText, Variable } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
MenubarItem,
|
MenubarItem,
|
||||||
@@ -35,15 +35,15 @@ import {
|
|||||||
MenubarSub,
|
MenubarSub,
|
||||||
MenubarSubContent,
|
MenubarSubContent,
|
||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
} from '../ui/menubar'
|
} from '@/components/ui/menubar'
|
||||||
import { Kbd } from '../ui/kbd'
|
import { Kbd } from '@/components/ui/kbd'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { InputHandle, OutputHandle } from '../base/NodeHandles'
|
import { InputHandle, OutputHandle } from '@/components/base/NodeHandles'
|
||||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators'
|
||||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
|
||||||
import { NodeMenubar } from '../base/NodeMenubar'
|
import { NodeMenubar } from '@/components/base/NodeMenubar'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '@/lib/flowContext'
|
||||||
import { getNodeType } from '../../lib/nodeRegistry'
|
import { getNodeType } from '@/lib/nodeRegistry'
|
||||||
|
|
||||||
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string }
|
export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string }
|
||||||
|
|
||||||
42
frontend/src/components/nodes/config/descriptor.tsx
Normal file
42
frontend/src/components/nodes/config/descriptor.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { ScrollText } from 'lucide-react'
|
||||||
|
import { createNodeTypeBuilder } from '@/lib/nodeTypeBuilder'
|
||||||
|
import { NODE_HELP } from '@/lib/nodeHelp'
|
||||||
|
import { getResolvedContentForConfig } from './renderingLogic'
|
||||||
|
import type { NodeTypeDescriptor } from '@/lib/nodeRegistry'
|
||||||
|
import ConfigNode from './ConfigNode'
|
||||||
|
|
||||||
|
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||||
|
|
||||||
|
export function getConfigNodeDescriptor(): NodeTypeDescriptor {
|
||||||
|
return createNodeTypeBuilder(
|
||||||
|
'config',
|
||||||
|
ConfigNode,
|
||||||
|
{ width: 320, height: 320 },
|
||||||
|
{ configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' }
|
||||||
|
)
|
||||||
|
.idPrefix('cfg_')
|
||||||
|
.withInputOutput(true, true)
|
||||||
|
.classification('psyche')
|
||||||
|
.allowedSourceTypes(['config', 'variable', 'function', 'data'])
|
||||||
|
.allowedTargetTypes(['config', 'render', 'agent'])
|
||||||
|
.help(NODE_HELP.config)
|
||||||
|
.menu('Config', <ScrollText className={ICON_CLASS} />)
|
||||||
|
.getDefaultData((newId) => ({
|
||||||
|
configType: 'plantuml',
|
||||||
|
content: '@startuml\n\n@enduml\n',
|
||||||
|
title: newId ?? '',
|
||||||
|
}))
|
||||||
|
.getResetData((nodeId) => ({
|
||||||
|
configType: 'plantuml',
|
||||||
|
content: '@startuml\n\n@enduml\n',
|
||||||
|
title: nodeId ?? '',
|
||||||
|
}))
|
||||||
|
.connectionLabel('adding input')
|
||||||
|
.withFullscreen()
|
||||||
|
.sourceRenderingLogic({
|
||||||
|
defaultUpdateMode: 'auto',
|
||||||
|
getResolvedContent: getResolvedContentForConfig,
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
}
|
||||||
2
frontend/src/components/nodes/config/index.ts
Normal file
2
frontend/src/components/nodes/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ConfigNode, type ConfigNodeData } from './ConfigNode'
|
||||||
|
export { getConfigNodeDescriptor } from './descriptor'
|
||||||
284
frontend/src/components/nodes/config/renderingLogic.ts
Normal file
284
frontend/src/components/nodes/config/renderingLogic.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Config node rendering logic: resolves Nunjucks (extends/include/import, variables, data, functions)
|
||||||
|
* and returns the resolved string and output type for the Rendering node.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import nunjucks from 'nunjucks'
|
||||||
|
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/sourceRenderingLogic'
|
||||||
|
import { getConfigContent, getConfigTypeId, type ConfigTypeId } from '@/lib/configTypes'
|
||||||
|
|
||||||
|
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
|
||||||
|
const id = renderNodeId
|
||||||
|
const sourceNode = nodes.find((n) => n.id === sourceNodeId)
|
||||||
|
const outputTypeId: ConfigTypeId = sourceNode
|
||||||
|
? getConfigTypeId((sourceNode.data ?? undefined) as Record<string, unknown> | undefined)
|
||||||
|
: 'plantuml'
|
||||||
|
|
||||||
|
const configIdsUsed = new Set<string>()
|
||||||
|
|
||||||
|
const addConfigAndRefs = (templateName: string, visited = new Set<string>()) => {
|
||||||
|
const refId = resolveExtendsRef(nodes, 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))
|
||||||
|
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
|
||||||
|
visited.add(refId)
|
||||||
|
configIdsUsed.add(refId)
|
||||||
|
const content = getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined)
|
||||||
|
for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited)
|
||||||
|
}
|
||||||
|
|
||||||
|
addConfigAndRefs(srcId)
|
||||||
|
|
||||||
|
const configLoader = {
|
||||||
|
getSource: (name: string): { src: string; path: string } | null => {
|
||||||
|
const refId = resolveExtendsRef(nodes, name)
|
||||||
|
const node = nodes.find((n) => n.id === refId && n.type === 'config')
|
||||||
|
if (!node) return null
|
||||||
|
if (refId !== srcId && !isReachable(edges, refId, id))
|
||||||
|
throw new Error(`Referenced config not connected to renderer: ${name}`)
|
||||||
|
return {
|
||||||
|
src: getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined),
|
||||||
|
path: name,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const nunjucksContext = Object.create(null) as Record<string, unknown>
|
||||||
|
const setVarInContext = (src: Node) => {
|
||||||
|
const v = (src.data as Record<string, unknown>)?.value
|
||||||
|
const str = v === undefined || v === null ? '' : String(v)
|
||||||
|
nunjucksContext[src.id] =
|
||||||
|
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
|
||||||
|
}
|
||||||
|
for (const e of edges) {
|
||||||
|
if (!configIdsUsed.has(e.target)) continue
|
||||||
|
const src = nodes.find((n) => n.id === e.source)
|
||||||
|
if (src?.type === 'variable') setVarInContext(src)
|
||||||
|
if (src?.type === 'data') {
|
||||||
|
const rows = ((src.data as Record<string, unknown>)?.rows as Record<string, string>[]) ?? []
|
||||||
|
const hidden = ((src.data as Record<string, unknown>)?.hiddenColumns as string[]) ?? []
|
||||||
|
const visibleCols = ((src.data as Record<string, unknown>)?.columns as string[] | undefined) ?? []
|
||||||
|
const filtered = (visibleCols as string[]).filter((c) => !hidden.includes(c))
|
||||||
|
const filteredRows = rows.map((row) => {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const col of filtered) {
|
||||||
|
if (col in row) out[col] = row[col]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
nunjucksContext[src.id] = filteredRows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const functionIdsToRegister = new Set<string>()
|
||||||
|
let added = true
|
||||||
|
while (added) {
|
||||||
|
added = false
|
||||||
|
for (const e of edges) {
|
||||||
|
const src = nodes.find((n) => n.id === e.source)
|
||||||
|
if (src?.type !== 'function') continue
|
||||||
|
const targetInScope = configIdsUsed.has(e.target) || functionIdsToRegister.has(e.target)
|
||||||
|
if (!targetInScope) continue
|
||||||
|
if (!functionIdsToRegister.has(src.id)) {
|
||||||
|
functionIdsToRegister.add(src.id)
|
||||||
|
added = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const e of edges) {
|
||||||
|
if (!configIdsUsed.has(e.target) && !functionIdsToRegister.has(e.target)) continue
|
||||||
|
const src = nodes.find((n) => n.id === e.source)
|
||||||
|
if (src?.type === 'function') {
|
||||||
|
for (const e2 of edges) {
|
||||||
|
if (e2.target !== src.id) continue
|
||||||
|
const vNode = nodes.find((n) => n.id === e2.source)
|
||||||
|
if (vNode?.type === 'variable') setVarInContext(vNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = new nunjucks.Environment([configLoader], { autoescape: false })
|
||||||
|
|
||||||
|
const formatFilterResult = (r: unknown): string => {
|
||||||
|
if (r === undefined || r === null) return ''
|
||||||
|
if (typeof r === 'string' || typeof r === 'number' || typeof r === 'boolean') return String(r)
|
||||||
|
return String(r)
|
||||||
|
}
|
||||||
|
const parseFunctionSignature = (body: string): { paramNames: string[]; innerBody: string } | null => {
|
||||||
|
const withCommentsStripped = body.replace(/^\s*\/\/[^\n]*\n?/gm, '').trim()
|
||||||
|
const trimmed = withCommentsStripped.trim()
|
||||||
|
const fnMatch = trimmed.match(/^function\s*\(([^)]*)\)\s*\{([\s\S]*)\}\s*$/)
|
||||||
|
if (fnMatch) {
|
||||||
|
const paramNames = fnMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
||||||
|
return { paramNames, innerBody: fnMatch[2].trim() }
|
||||||
|
}
|
||||||
|
const arrowBlockMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*\{([\s\S]*)\}\s*$/)
|
||||||
|
if (arrowBlockMatch) {
|
||||||
|
const paramNames = arrowBlockMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
||||||
|
return { paramNames, innerBody: arrowBlockMatch[2].trim() }
|
||||||
|
}
|
||||||
|
const arrowExprMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*(.+)\s*$/s)
|
||||||
|
if (arrowExprMatch) {
|
||||||
|
const paramNames = arrowExprMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
||||||
|
return { paramNames, innerBody: 'return ' + arrowExprMatch[2].trim() }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||||
|
typeof v === 'object' && v !== null && !Array.isArray(v)
|
||||||
|
|
||||||
|
const functionConnectedVariableIds = Object.create(null) as Record<string, string[]>
|
||||||
|
const functionConnectedFunctionIds = Object.create(null) as Record<string, string[]>
|
||||||
|
for (const fid of functionIdsToRegister) {
|
||||||
|
for (const e of edges) {
|
||||||
|
if (e.target !== fid) continue
|
||||||
|
const src = nodes.find((n) => n.id === e.source)
|
||||||
|
if (src?.type === 'variable') {
|
||||||
|
if (!functionConnectedVariableIds[fid]) functionConnectedVariableIds[fid] = []
|
||||||
|
functionConnectedVariableIds[fid].push(src.id)
|
||||||
|
} else if (src?.type === 'function') {
|
||||||
|
if (!functionConnectedFunctionIds[fid]) functionConnectedFunctionIds[fid] = []
|
||||||
|
functionConnectedFunctionIds[fid].push(src.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fid of functionIdsToRegister) {
|
||||||
|
const src = nodes.find((n) => n.id === fid)
|
||||||
|
if (!src || src.type !== 'function') continue
|
||||||
|
const body = ((src.data as Record<string, unknown>)?.body as string) ?? 'return args[0];'
|
||||||
|
const parsed = parseFunctionSignature(body)
|
||||||
|
const connectedVarIds = new Set<string>(functionConnectedVariableIds[fid] ?? [])
|
||||||
|
const connectedFuncIds = functionConnectedFunctionIds[fid] ?? []
|
||||||
|
env.addFilter(
|
||||||
|
src.id,
|
||||||
|
(value: unknown, ...args: unknown[]) => {
|
||||||
|
const callback = args[args.length - 1] as (err: Error | null, res: string) => void
|
||||||
|
const raw = [value, ...args.slice(0, -1)]
|
||||||
|
const hasKwargs = raw.length > 0 && isPlainObject(raw[raw.length - 1])
|
||||||
|
const positionals = hasKwargs ? raw.slice(0, -1) : raw
|
||||||
|
const kwargs = hasKwargs ? (raw[raw.length - 1] as Record<string, unknown>) : Object.create(null)
|
||||||
|
|
||||||
|
const nestedCache = new Map<string, string>()
|
||||||
|
const coerceCached = (s: string): string | number => {
|
||||||
|
const n = Number(s)
|
||||||
|
return s.trim() !== '' && !Number.isNaN(n) ? n : s
|
||||||
|
}
|
||||||
|
const makeCallable = (filterId: string) => (input: unknown) => {
|
||||||
|
const key = `${filterId}::${JSON.stringify(input)}`
|
||||||
|
if (nestedCache.has(key)) return coerceCached(nestedCache.get(key)!)
|
||||||
|
const p = new Promise<string>((resolve, reject) => {
|
||||||
|
env.getFilter(filterId)(input, (err: Error | null, res: string) =>
|
||||||
|
err ? reject(err) : resolve(res)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
p.then((res) => nestedCache.set(key, res))
|
||||||
|
const suspend = { __suspend: true as const, promise: p, key }
|
||||||
|
throw suspend
|
||||||
|
}
|
||||||
|
|
||||||
|
let invoke: () => unknown
|
||||||
|
if (parsed) {
|
||||||
|
const { paramNames, innerBody } = parsed
|
||||||
|
const lastParam = paramNames[paramNames.length - 1]
|
||||||
|
const invocationArgs = paramNames.map((name, i) => {
|
||||||
|
if (name === lastParam && lastParam === 'kwargs') return kwargs
|
||||||
|
if (connectedVarIds.has(name) && name in nunjucksContext) return nunjucksContext[name]
|
||||||
|
if (connectedFuncIds.includes(name)) return makeCallable(name)
|
||||||
|
return positionals[i]
|
||||||
|
})
|
||||||
|
const extraVarIds = [...connectedVarIds].filter((vid) => !paramNames.includes(vid))
|
||||||
|
const extraFuncIds = connectedFuncIds.filter((fid2) => !paramNames.includes(fid2))
|
||||||
|
const allParamNames = [...paramNames, ...extraVarIds, ...extraFuncIds]
|
||||||
|
const allArgs = [
|
||||||
|
...invocationArgs,
|
||||||
|
...extraVarIds.map((vid) => nunjucksContext[vid]),
|
||||||
|
...extraFuncIds.map((fid2) => makeCallable(fid2)),
|
||||||
|
]
|
||||||
|
const fn = new Function(...allParamNames, innerBody)
|
||||||
|
invoke = () => fn(...allArgs)
|
||||||
|
} else {
|
||||||
|
const fn = new Function('args', body)
|
||||||
|
invoke = () => fn(positionals)
|
||||||
|
}
|
||||||
|
|
||||||
|
const done = (err: Error | null, res: string) => callback(err, res)
|
||||||
|
const runInvoke = () => {
|
||||||
|
try {
|
||||||
|
const result = invoke()
|
||||||
|
if (result != null && typeof (result as Promise<unknown>).then === 'function') {
|
||||||
|
(result as Promise<unknown>).then(
|
||||||
|
(r) => done(null, formatFilterResult(r)),
|
||||||
|
(err) => done(err instanceof Error ? err : new Error(String(err)), '')
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
done(null, formatFilterResult(result))
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const s = e as { __suspend?: boolean; promise?: Promise<string>; key?: string }
|
||||||
|
if (s?.__suspend && s.promise) {
|
||||||
|
s.promise.then(() => runInvoke(), (err) =>
|
||||||
|
done(err instanceof Error ? err : new Error(String(err)), '')
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
done(e instanceof Error ? e : new Error(String(e)), '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runInvoke()
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
env.render(srcId, nunjucksContext, (nunjucksErr: Error | null, afterNunjucks: string) => {
|
||||||
|
if (nunjucksErr) {
|
||||||
|
reject(new Error(`Nunjucks: ${nunjucksErr.message}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
|
||||||
|
resolve({ resolved, outputTypeId })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,21 +3,21 @@ import {
|
|||||||
AbstractNodeProps,
|
AbstractNodeProps,
|
||||||
createAbstractNodeComponent,
|
createAbstractNodeComponent,
|
||||||
useAbstractNode,
|
useAbstractNode,
|
||||||
} from '../../lib/abstractNode'
|
} from '@/lib/abstractNode'
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
BaseNodeContent,
|
BaseNodeContent,
|
||||||
BaseNodeFooter,
|
BaseNodeFooter,
|
||||||
BaseNodeHeaderRow,
|
BaseNodeHeaderRow,
|
||||||
} from '../base/BaseNode'
|
} from '@/components/base/BaseNode'
|
||||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators'
|
||||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
|
||||||
import { NodeMenubar } from '../base/NodeMenubar'
|
import { NodeMenubar } from '@/components/base/NodeMenubar'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '@/lib/flowContext'
|
||||||
import { getNodeType } from '../../lib/nodeRegistry'
|
import { getNodeType } from '@/lib/nodeRegistry'
|
||||||
import { OutputHandle } from '../base/NodeHandles'
|
import { OutputHandle } from '@/components/base/NodeHandles'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { parseCsvToRows } from '../../lib/csvParse'
|
import { parseCsvToRows } from '@/lib/csvParse'
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, Database, FileUp } from 'lucide-react'
|
import { ArrowDown, ArrowUp, ArrowUpDown, Database, FileUp } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
flexRender,
|
flexRender,
|
||||||
@@ -29,9 +29,9 @@ import {
|
|||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
type SortingState,
|
type SortingState,
|
||||||
} from '@tanstack/react-table'
|
} from '@tanstack/react-table'
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { ContextMenuCheckboxItem } from '../ui/context-menu'
|
import { ContextMenuCheckboxItem } from '@/components/ui/context-menu'
|
||||||
import {
|
import {
|
||||||
MenubarCheckboxItem,
|
MenubarCheckboxItem,
|
||||||
MenubarItem,
|
MenubarItem,
|
||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
MenubarSub,
|
MenubarSub,
|
||||||
MenubarSubContent,
|
MenubarSubContent,
|
||||||
MenubarSubTrigger,
|
MenubarSubTrigger,
|
||||||
} from '../ui/menubar'
|
} from '@/components/ui/menubar'
|
||||||
|
|
||||||
export type DataNodeData = {
|
export type DataNodeData = {
|
||||||
rows?: Record<string, string>[]
|
rows?: Record<string, string>[]
|
||||||
26
frontend/src/components/nodes/data/descriptor.tsx
Normal file
26
frontend/src/components/nodes/data/descriptor.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Database } from 'lucide-react'
|
||||||
|
import { createNodeTypeBuilder } from '@/lib/nodeTypeBuilder'
|
||||||
|
import { NODE_HELP } from '@/lib/nodeHelp'
|
||||||
|
import type { NodeTypeDescriptor } from '@/lib/nodeRegistry'
|
||||||
|
import DataNode from './DataNode'
|
||||||
|
|
||||||
|
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||||
|
|
||||||
|
export function getDataNodeDescriptor(): NodeTypeDescriptor {
|
||||||
|
return createNodeTypeBuilder(
|
||||||
|
'data',
|
||||||
|
DataNode,
|
||||||
|
{ width: 360, height: 280 },
|
||||||
|
{ rows: [], columns: [], fileName: '' }
|
||||||
|
)
|
||||||
|
.idPrefix('data_')
|
||||||
|
.withInputOutput(false, true)
|
||||||
|
.classification('physis')
|
||||||
|
.allowedTargetTypes(['config', 'agent'])
|
||||||
|
.help(NODE_HELP.data)
|
||||||
|
.menu('Data', <Database className={ICON_CLASS} />)
|
||||||
|
.connectionLabel('data source')
|
||||||
|
.withFullscreen()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
2
frontend/src/components/nodes/data/index.ts
Normal file
2
frontend/src/components/nodes/data/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as DataNode, type DataNodeData } from './DataNode'
|
||||||
|
export { getDataNodeDescriptor } from './descriptor'
|
||||||
@@ -6,23 +6,23 @@ import {
|
|||||||
createAbstractNodeComponent,
|
createAbstractNodeComponent,
|
||||||
useAbstractNode,
|
useAbstractNode,
|
||||||
type FlowNode,
|
type FlowNode,
|
||||||
} from '../../lib/abstractNode'
|
} from '@/lib/abstractNode'
|
||||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||||
import { useTheme } from '../../lib/themeContext'
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
BaseNodeContent,
|
BaseNodeContent,
|
||||||
BaseNodeFooter,
|
BaseNodeFooter,
|
||||||
BaseNodeHeaderRow,
|
BaseNodeHeaderRow,
|
||||||
} from '../base/BaseNode'
|
} from '@/components/base/BaseNode'
|
||||||
import { InputHandle, OutputHandle } from '../base/NodeHandles'
|
import { InputHandle, OutputHandle } from '@/components/base/NodeHandles'
|
||||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators'
|
||||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
|
||||||
import { NodeMenubar } from '../base/NodeMenubar'
|
import { NodeMenubar } from '@/components/base/NodeMenubar'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '@/lib/flowContext'
|
||||||
import { getNodeType } from '../../lib/nodeRegistry'
|
import { getNodeType } from '@/lib/nodeRegistry'
|
||||||
import { MenubarItem, MenubarShortcut } from '../ui/menubar'
|
import { MenubarItem, MenubarShortcut } from '@/components/ui/menubar'
|
||||||
import { Kbd } from '../ui/kbd'
|
import { Kbd } from '@/components/ui/kbd'
|
||||||
import { Code2, Variable } from 'lucide-react'
|
import { Code2, Variable } from 'lucide-react'
|
||||||
|
|
||||||
export type FunctionNodeData = { body?: string }
|
export type FunctionNodeData = { body?: string }
|
||||||
27
frontend/src/components/nodes/function/descriptor.tsx
Normal file
27
frontend/src/components/nodes/function/descriptor.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Code2 } from 'lucide-react'
|
||||||
|
import { createNodeTypeBuilder } from '@/lib/nodeTypeBuilder'
|
||||||
|
import { NODE_HELP } from '@/lib/nodeHelp'
|
||||||
|
import type { NodeTypeDescriptor } from '@/lib/nodeRegistry'
|
||||||
|
import FunctionNode from './FunctionNode'
|
||||||
|
|
||||||
|
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||||
|
|
||||||
|
const DEFAULT_BODY = `function(num, kwargs) {
|
||||||
|
return num + (kwargs.bar || 0);
|
||||||
|
}`
|
||||||
|
|
||||||
|
export function getFunctionNodeDescriptor(): NodeTypeDescriptor {
|
||||||
|
return createNodeTypeBuilder('function', FunctionNode, { width: 288, height: 260 }, { body: DEFAULT_BODY })
|
||||||
|
.idPrefix('fn_')
|
||||||
|
.withInputOutput(true, true)
|
||||||
|
.classification('psyche')
|
||||||
|
.allowedSourceTypes(['config', 'variable', 'function'])
|
||||||
|
.allowedTargetTypes(['config', 'function'])
|
||||||
|
.help(NODE_HELP.function)
|
||||||
|
.menu('Function', <Code2 className={ICON_CLASS} />)
|
||||||
|
.getResetData(() => ({ body: '' }))
|
||||||
|
.connectionLabel('adding input')
|
||||||
|
.withFullscreen()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
2
frontend/src/components/nodes/function/index.ts
Normal file
2
frontend/src/components/nodes/function/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as FunctionNode, type FunctionNodeData } from './FunctionNode'
|
||||||
|
export { getFunctionNodeDescriptor } from './descriptor'
|
||||||
@@ -7,37 +7,53 @@ import {
|
|||||||
AbstractNodeProps,
|
AbstractNodeProps,
|
||||||
createAbstractNodeComponent,
|
createAbstractNodeComponent,
|
||||||
useAbstractNode,
|
useAbstractNode,
|
||||||
} from '../../lib/abstractNode'
|
} from '@/lib/abstractNode'
|
||||||
import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes'
|
import { getConfigContent, getConfigType, getConfigTypeId } from '@/lib/configTypes'
|
||||||
import { useResizeHeight } from '../../hooks/useResizeHeight'
|
import { getSourceRenderingLogic } from '@/lib/sourceRenderingLogic'
|
||||||
import { plantumlLanguage } from '../../lib/plantumlLanguage'
|
import { useSyncConnectionStatus } from '@/lib/nodeLifecycle'
|
||||||
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty'
|
import { useResizeHeight } from '@/hooks/useResizeHeight'
|
||||||
|
import { plantumlLanguage } from '@/lib/plantumlLanguage'
|
||||||
|
import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '@/components/ui/empty'
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
BaseNodeContent,
|
BaseNodeContent,
|
||||||
BaseNodeFooter,
|
BaseNodeFooter,
|
||||||
BaseNodeHeaderRow,
|
BaseNodeHeaderRow,
|
||||||
} from '../base/BaseNode'
|
} from '@/components/base/BaseNode'
|
||||||
import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils'
|
import { getDefaultDataForType, getNextNodeId } from '@/lib/flowUtils'
|
||||||
import FlowContext from '../../lib/flowContext'
|
import FlowContext from '@/lib/flowContext'
|
||||||
import { getDefaultStyle, getNodeType } from '../../lib/nodeRegistry'
|
import { getDefaultStyle, getNodeType } from '@/lib/nodeRegistry'
|
||||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators'
|
||||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
|
||||||
import { NodeMenubar } from '../base/NodeMenubar'
|
import { NodeMenubar } from '@/components/base/NodeMenubar'
|
||||||
import { NodeStatusIndicator } from '../base/NodeStatusIndicator'
|
import { NodeStatusIndicator } from '@/components/base/NodeStatusIndicator'
|
||||||
import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar'
|
import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '@/components/ui/menubar'
|
||||||
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy } from 'lucide-react'
|
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy, Play, ChevronDown } from 'lucide-react'
|
||||||
import { InputHandle } from '../base/NodeHandles'
|
import { InputHandle } from '@/components/base/NodeHandles'
|
||||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
||||||
import { Input } from '../ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '../ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group'
|
import { ButtonGroup } from '@/components/ui/button-group'
|
||||||
import { useTheme } from '../../lib/themeContext'
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||||
|
import { useTheme } from '@/lib/themeContext'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export type RenderingNodeData = {
|
export type RenderingNodeData = {
|
||||||
viewportWidth?: number
|
viewportWidth?: number
|
||||||
viewportHeight?: number
|
viewportHeight?: number
|
||||||
|
/** When set, overrides the source node's default. 'auto' = re-render on upstream changes; 'manual' = only when user clicks Run. */
|
||||||
|
updateMode?: 'auto' | 'manual'
|
||||||
|
/** Incremented when user clicks Run (manual mode). Effect runs when this changes. */
|
||||||
|
runTrigger?: number
|
||||||
|
/** Signature of inputs used in the last successful render. Used in manual mode to show paused (yellow) when upstream changed. */
|
||||||
|
lastRunSourceSignature?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_VIEWPORT_WIDTH = 1200
|
const DEFAULT_VIEWPORT_WIDTH = 1200
|
||||||
@@ -50,10 +66,6 @@ type ViewMode = 'preview' | 'raw'
|
|||||||
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||||
const flowContext = useContext(FlowContext)
|
const flowContext = useContext(FlowContext)
|
||||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||||
const startConnectionPathUpdate = flowContext?.startConnectionPathUpdate
|
|
||||||
const endConnectionPathUpdate = flowContext?.endConnectionPathUpdate
|
|
||||||
const addConnectionPathError = flowContext?.addConnectionPathError
|
|
||||||
const removeConnectionPathError = flowContext?.removeConnectionPathError
|
|
||||||
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
||||||
const [renderedContent, setRenderedContent] = useState<string | null>(null)
|
const [renderedContent, setRenderedContent] = useState<string | null>(null)
|
||||||
const [resolvedContent, setResolvedContent] = useState<string | null>(null)
|
const [resolvedContent, setResolvedContent] = useState<string | null>(null)
|
||||||
@@ -65,6 +77,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
const runIdRef = useRef(0)
|
const runIdRef = useRef(0)
|
||||||
const loadingStartedAtRef = useRef<number | null>(null)
|
const loadingStartedAtRef = useRef<number | null>(null)
|
||||||
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const lastManualRunTriggerRef = useRef<number>(0)
|
||||||
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
||||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||||
@@ -72,6 +85,9 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
const incomingIds = sourceIds
|
const incomingIds = sourceIds
|
||||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||||
const srcNode = nodes.find((n: any) => n.id === srcId)
|
const srcNode = nodes.find((n: any) => n.id === srcId)
|
||||||
|
const sourceLogic = useMemo(() => (srcNode?.type ? getSourceRenderingLogic(srcNode.type) : null), [srcNode?.type])
|
||||||
|
const effectiveUpdateMode = data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto'
|
||||||
|
const runTrigger = data?.runTrigger ?? 0
|
||||||
const isAgentSource = srcNode?.type === 'agent'
|
const isAgentSource = srcNode?.type === 'agent'
|
||||||
const agentOutputMarkdown = isAgentSource ? ((srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? '') : ''
|
const agentOutputMarkdown = isAgentSource ? ((srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? '') : ''
|
||||||
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : isAgentSource ? 'markdown' : 'plantuml'
|
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : isAgentSource ? 'markdown' : 'plantuml'
|
||||||
@@ -80,14 +96,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : isAgentSource ? agentOutputMarkdown : ''
|
const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : isAgentSource ? agentOutputMarkdown : ''
|
||||||
const srcData = srcNode?.data ?? {}
|
const srcData = srcNode?.data ?? {}
|
||||||
|
|
||||||
useEffect(() => {
|
const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? []
|
||||||
if (!addConnectionPathError || !removeConnectionPathError) return
|
|
||||||
if (error != null) {
|
|
||||||
addConnectionPathError(id)
|
|
||||||
return () => removeConnectionPathError(id)
|
|
||||||
}
|
|
||||||
removeConnectionPathError(id)
|
|
||||||
}, [id, error, addConnectionPathError, removeConnectionPathError])
|
|
||||||
|
|
||||||
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
|
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
|
||||||
const connectedNodeIds = useMemo(() => {
|
const connectedNodeIds = useMemo(() => {
|
||||||
@@ -197,385 +206,153 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
[nodes, connectedNodeIds]
|
[nodes, connectedNodeIds]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Single signature of all inputs that affect this render. Stored on successful render for manual-mode paused state. */
|
||||||
|
const sourceSignature = useMemo(
|
||||||
|
() =>
|
||||||
|
JSON.stringify({
|
||||||
|
configSignature,
|
||||||
|
edgesSignature,
|
||||||
|
variablesSignature,
|
||||||
|
functionsSignature,
|
||||||
|
dataSignature,
|
||||||
|
}),
|
||||||
|
[configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature]
|
||||||
|
)
|
||||||
|
|
||||||
|
const lastRunSourceSignature = data?.lastRunSourceSignature
|
||||||
|
/**
|
||||||
|
* In manual mode, yellow = "dirty": inputs changed since the last manual run (or never run).
|
||||||
|
* We report paused when dirty so we get added to connectionPathPausedNodeIds; the path is
|
||||||
|
* then computed as downstream(trigger) ∩ upstream(paused), so we must not require
|
||||||
|
* pathNodeIds.has(id) here (that would be a chicken-and-egg).
|
||||||
|
*/
|
||||||
|
const hasPendingInputs =
|
||||||
|
effectiveUpdateMode === 'manual' &&
|
||||||
|
!loading &&
|
||||||
|
triggerNodeIds.length > 0 &&
|
||||||
|
incomingIds.length > 0 &&
|
||||||
|
sourceSignature !== lastRunSourceSignature
|
||||||
|
|
||||||
|
useSyncConnectionStatus(id, { updating: loading, error: error != null, paused: hasPendingInputs })
|
||||||
|
|
||||||
const RENDER_DEBOUNCE_MS = 250
|
const RENDER_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sourceContent && incomingIds.length > 0) {
|
|
||||||
setRenderedContent(null)
|
|
||||||
setResolvedContent(null)
|
|
||||||
setError({
|
|
||||||
kind: 'no-content',
|
|
||||||
message: isAgentSource ? 'Run the Agent node to generate output.' : 'No content on connected configuration node',
|
|
||||||
})
|
|
||||||
setLoading(false)
|
|
||||||
endConnectionPathUpdate?.(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (incomingIds.length === 0) {
|
if (incomingIds.length === 0) {
|
||||||
setRenderedContent(null)
|
setRenderedContent(null)
|
||||||
setResolvedContent(null)
|
setResolvedContent(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
endConnectionPathUpdate?.(id)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!srcId || !srcNode) {
|
||||||
|
setRenderedContent(null)
|
||||||
|
setResolvedContent(null)
|
||||||
|
setError(null)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const logic = getSourceRenderingLogic(srcNode.type ?? '')
|
||||||
|
if (!logic) {
|
||||||
|
setRenderedContent(null)
|
||||||
|
setResolvedContent(null)
|
||||||
|
setError({ kind: 'render', message: `Unsupported source type: ${srcNode.type}` })
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (effectiveUpdateMode === 'manual' && runTrigger === 0) {
|
||||||
|
setRenderedContent(null)
|
||||||
|
setResolvedContent(null)
|
||||||
|
setError({
|
||||||
|
kind: 'no-content',
|
||||||
|
message: isAgentSource ? 'Run the Agent node to generate output, then click Run here.' : 'Click Run to render.',
|
||||||
|
})
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (effectiveUpdateMode === 'manual' && runTrigger === lastManualRunTriggerRef.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (effectiveUpdateMode === 'manual') lastManualRunTriggerRef.current = runTrigger
|
||||||
|
|
||||||
runIdRef.current += 1
|
runIdRef.current += 1
|
||||||
const thisRunId = runIdRef.current
|
const thisRunId = runIdRef.current
|
||||||
|
const signatureForThisRun = sourceSignature
|
||||||
|
const isManualMode = effectiveUpdateMode === 'manual'
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
loadingStartedAtRef.current = Date.now()
|
loadingStartedAtRef.current = Date.now()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
startConnectionPathUpdate?.(id)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
if (srcNode?.type === 'agent') {
|
const context = {
|
||||||
const md = (srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? ''
|
nodes,
|
||||||
setResolvedContent(md)
|
edges,
|
||||||
const markdownType = getConfigType('markdown')
|
sourceNodeId: srcId,
|
||||||
const html = await markdownType.render(md)
|
renderNodeId: id,
|
||||||
if (cancelled || thisRunId !== runIdRef.current) return
|
viewportWidth,
|
||||||
setRenderedContent(html)
|
viewportHeight,
|
||||||
setError(null)
|
|
||||||
setLoading(false)
|
|
||||||
endConnectionPathUpdate?.(id)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const configIdsUsed = new Set<string>()
|
const { resolved, outputTypeId } = await logic.getResolvedContent(context)
|
||||||
|
if (cancelled || thisRunId !== runIdRef.current) return
|
||||||
const isReachable = (startId: string, targetId: string) => {
|
setResolvedContent(resolved)
|
||||||
const q: string[] = [startId]
|
const typeRenderer = getConfigType(outputTypeId)
|
||||||
const seen = new Set<string>([startId])
|
const renderOptions = outputTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined
|
||||||
while (q.length) {
|
const htmlOrSvg = await typeRenderer.render(resolved, renderOptions)
|
||||||
const cur = q.shift()!
|
if (cancelled || thisRunId !== runIdRef.current) return
|
||||||
if (cur === targetId) return true
|
setRenderedContent(htmlOrSvg)
|
||||||
for (const e of edges) {
|
setError(null)
|
||||||
if (e.source === cur && !seen.has(e.target)) {
|
if (isManualMode) {
|
||||||
seen.add(e.target)
|
updateData({ lastRunSourceSignature: signatureForThisRun })
|
||||||
q.push(e.target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveExtendsRef = (name: string): string => {
|
|
||||||
const refName = name.replace(/\.(puml|html)$/, '').trim()
|
|
||||||
return nodes.find((n: any) => n.id === refName || n.data?.title === refName)?.id ?? refName
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Collect refs from {% extends %}, {% include %}, {% import %} in template content */
|
|
||||||
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 addConfigAndRefs = (templateName: string, visited = new Set<string>()) => {
|
|
||||||
const refId = resolveExtendsRef(templateName)
|
|
||||||
if (visited.has(refId)) throw new Error(`Circular reference detected: ${templateName}`)
|
|
||||||
const node = nodes.find((n: any) => n.id === refId && n.type === 'config')
|
|
||||||
if (!node) throw new Error(`Config not found: ${templateName}`)
|
|
||||||
if (refId !== srcId && !isReachable(refId, id))
|
|
||||||
throw new Error(`Referenced config not connected to renderer: ${templateName}`)
|
|
||||||
visited.add(refId)
|
|
||||||
configIdsUsed.add(refId)
|
|
||||||
const content = getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined)
|
|
||||||
for (const ref of getTemplateRefs(content)) addConfigAndRefs(ref, visited)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (srcId && srcNode?.type === 'config') addConfigAndRefs(srcId)
|
|
||||||
|
|
||||||
// Loader for Nunjucks {% extends %}, {% include %}, {% import %}: resolve template name to config node's plantuml
|
|
||||||
const configLoader = {
|
|
||||||
getSource: (name: string): { src: string; path: string } | null => {
|
|
||||||
const refId = resolveExtendsRef(name)
|
|
||||||
const node = nodes.find((n: any) => n.id === refId && n.type === 'config')
|
|
||||||
if (!node) return null
|
|
||||||
if (refId !== srcId && !isReachable(refId, id))
|
|
||||||
throw new Error(`Referenced config not connected to renderer: ${name}`)
|
|
||||||
return {
|
|
||||||
src: getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined),
|
|
||||||
path: name,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Context: variables connected to configs, plus variables connected to functions that feed configs (so they can be injected as constants).
|
|
||||||
const nunjucksContext = Object.create(null) as Record<string, unknown>
|
|
||||||
const setVarInContext = (src: any) => {
|
|
||||||
const v = src.data?.value
|
|
||||||
const str = v === undefined || v === null ? '' : String(v)
|
|
||||||
nunjucksContext[src.id] =
|
|
||||||
v === undefined || v === null ? '' : typeof v === 'boolean' || typeof v === 'number' ? v : str
|
|
||||||
}
|
|
||||||
for (const e of edges) {
|
|
||||||
if (!configIdsUsed.has(e.target)) continue
|
|
||||||
const src = nodes.find((n: any) => n.id === e.source)
|
|
||||||
if (src?.type === 'variable') setVarInContext(src)
|
|
||||||
if (src?.type === 'data') {
|
|
||||||
const rows = (src.data?.rows as Record<string, string>[] | undefined) ?? []
|
|
||||||
const hidden = (src.data?.hiddenColumns as string[] | undefined) ?? []
|
|
||||||
const visibleCols = (src.data?.columns as string[] | undefined) ?? []
|
|
||||||
.filter((c) => !hidden.includes(c))
|
|
||||||
const filteredRows = rows.map((row) => {
|
|
||||||
const out: Record<string, string> = {}
|
|
||||||
for (const col of visibleCols) {
|
|
||||||
if (col in row) out[col] = row[col]
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
nunjucksContext[src.id] = filteredRows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// All function node ids that feed (directly or transitively) into config — need to register them and collect their variables
|
|
||||||
const functionIdsToRegister = new Set<string>()
|
|
||||||
let added = true
|
|
||||||
while (added) {
|
|
||||||
added = false
|
|
||||||
for (const e of edges) {
|
|
||||||
const src = nodes.find((n: any) => n.id === e.source)
|
|
||||||
if (src?.type !== 'function') continue
|
|
||||||
const targetInScope = configIdsUsed.has(e.target) || functionIdsToRegister.has(e.target)
|
|
||||||
if (!targetInScope) continue
|
|
||||||
if (!functionIdsToRegister.has(src.id)) {
|
|
||||||
functionIdsToRegister.add(src.id)
|
|
||||||
added = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const e of edges) {
|
|
||||||
if (!configIdsUsed.has(e.target) && !functionIdsToRegister.has(e.target)) continue
|
|
||||||
const src = nodes.find((n: any) => n.id === e.source)
|
|
||||||
if (src?.type === 'function') {
|
|
||||||
for (const e2 of edges) {
|
|
||||||
if (e2.target !== src.id) continue
|
|
||||||
const vNode = nodes.find((n: any) => n.id === e2.source)
|
|
||||||
if (vNode?.type === 'variable') setVarInContext(vNode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const env = new nunjucks.Environment([configLoader], { autoescape: false })
|
|
||||||
|
|
||||||
// Register each connected function node as a Nunjucks custom filter (async so sync and async user code both work).
|
|
||||||
// Supports either named params: function(num, x, y, kwargs) { return num + (kwargs.bar || 10); } or legacy: args array.
|
|
||||||
const formatFilterResult = (r: unknown): string => {
|
|
||||||
if (r === undefined || r === null) return ''
|
|
||||||
if (typeof r === 'string' || typeof r === 'number' || typeof r === 'boolean') return String(r)
|
|
||||||
return String(r)
|
|
||||||
}
|
|
||||||
/** Parse function(num, x, y, kwargs) { body } or (num, x, y, kwargs) => body to get param names and inner body. */
|
|
||||||
const parseFunctionSignature = (body: string): { paramNames: string[]; innerBody: string } | null => {
|
|
||||||
const withCommentsStripped = body.replace(/^\s*\/\/[^\n]*\n?/gm, '').trim()
|
|
||||||
const trimmed = withCommentsStripped.trim()
|
|
||||||
const fnMatch = trimmed.match(/^function\s*\(([^)]*)\)\s*\{([\s\S]*)\}\s*$/)
|
|
||||||
if (fnMatch) {
|
|
||||||
const paramNames = fnMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
|
||||||
return { paramNames, innerBody: fnMatch[2].trim() }
|
|
||||||
}
|
|
||||||
const arrowBlockMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*\{([\s\S]*)\}\s*$/)
|
|
||||||
if (arrowBlockMatch) {
|
|
||||||
const paramNames = arrowBlockMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
|
||||||
return { paramNames, innerBody: arrowBlockMatch[2].trim() }
|
|
||||||
}
|
|
||||||
const arrowExprMatch = trimmed.match(/^\(([^)]*)\)\s*=>\s*(.+)\s*$/s)
|
|
||||||
if (arrowExprMatch) {
|
|
||||||
const paramNames = arrowExprMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
|
||||||
return { paramNames, innerBody: 'return ' + arrowExprMatch[2].trim() }
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
||||||
typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
||||||
|
|
||||||
// For each registered function: which variable/function node ids are connected to it?
|
|
||||||
const functionConnectedVariableIds = Object.create(null) as Record<string, string[]>
|
|
||||||
const functionConnectedFunctionIds = Object.create(null) as Record<string, string[]>
|
|
||||||
for (const fid of functionIdsToRegister) {
|
|
||||||
for (const e of edges) {
|
|
||||||
if (e.target !== fid) continue
|
|
||||||
const src = nodes.find((n: any) => n.id === e.source)
|
|
||||||
if (src?.type === 'variable') {
|
|
||||||
if (!functionConnectedVariableIds[fid]) functionConnectedVariableIds[fid] = []
|
|
||||||
functionConnectedVariableIds[fid].push(src.id)
|
|
||||||
} else if (src?.type === 'function') {
|
|
||||||
if (!functionConnectedFunctionIds[fid]) functionConnectedFunctionIds[fid] = []
|
|
||||||
functionConnectedFunctionIds[fid].push(src.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const fid of functionIdsToRegister) {
|
|
||||||
const src = nodes.find((n: any) => n.id === fid)
|
|
||||||
if (!src || src.type !== 'function') continue
|
|
||||||
const body = (src.data as { body?: string } | undefined)?.body ?? 'return args[0];'
|
|
||||||
const parsed = parseFunctionSignature(body)
|
|
||||||
const connectedVarIds = new Set<string>(functionConnectedVariableIds[fid] ?? [])
|
|
||||||
const connectedFuncIds = functionConnectedFunctionIds[fid] ?? []
|
|
||||||
env.addFilter(
|
|
||||||
src.id,
|
|
||||||
(value: unknown, ...args: unknown[]) => {
|
|
||||||
const callback = args[args.length - 1] as (err: Error | null, res: string) => void
|
|
||||||
const raw = [value, ...args.slice(0, -1)]
|
|
||||||
const hasKwargs = raw.length > 0 && isPlainObject(raw[raw.length - 1])
|
|
||||||
const positionals = hasKwargs ? raw.slice(0, -1) : raw
|
|
||||||
const kwargs = hasKwargs ? (raw[raw.length - 1] as Record<string, unknown>) : Object.create(null)
|
|
||||||
|
|
||||||
// Cache for nested filter results so sync-looking code like `num + fn_003(4)` works:
|
|
||||||
// callable throws Suspend when result isn't ready; we await, cache, then re-run.
|
|
||||||
// Cached values are strings (Nunjucks); coerce to number when numeric so 2 + fn_003(4) => 10 not "28".
|
|
||||||
const nestedCache = new Map<string, string>()
|
|
||||||
const coerceCached = (s: string): string | number => {
|
|
||||||
const n = Number(s)
|
|
||||||
return s.trim() !== '' && !Number.isNaN(n) ? n : s
|
|
||||||
}
|
|
||||||
const makeCallable = (filterId: string) => (input: unknown) => {
|
|
||||||
const key = `${filterId}::${JSON.stringify(input)}`
|
|
||||||
if (nestedCache.has(key)) return coerceCached(nestedCache.get(key)!)
|
|
||||||
const p = new Promise<string>((resolve, reject) => {
|
|
||||||
env.getFilter(filterId)(input, (err: Error | null, res: string) =>
|
|
||||||
err ? reject(err) : resolve(res)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
p.then((res) => nestedCache.set(key, res))
|
|
||||||
const suspend = { __suspend: true as const, promise: p, key }
|
|
||||||
throw suspend
|
|
||||||
}
|
|
||||||
|
|
||||||
let invoke: () => unknown
|
|
||||||
if (parsed) {
|
|
||||||
const { paramNames, innerBody } = parsed
|
|
||||||
const lastParam = paramNames[paramNames.length - 1]
|
|
||||||
const invocationArgs = paramNames.map((name, i) => {
|
|
||||||
if (name === lastParam && lastParam === 'kwargs') return kwargs
|
|
||||||
if (connectedVarIds.has(name) && name in nunjucksContext)
|
|
||||||
return nunjucksContext[name]
|
|
||||||
if (connectedFuncIds.includes(name)) return makeCallable(name)
|
|
||||||
return positionals[i]
|
|
||||||
})
|
|
||||||
const extraVarIds = [...connectedVarIds].filter((vid) => !paramNames.includes(vid))
|
|
||||||
const extraFuncIds = connectedFuncIds.filter((fid2) => !paramNames.includes(fid2))
|
|
||||||
const allParamNames = [...paramNames, ...extraVarIds, ...extraFuncIds]
|
|
||||||
const allArgs = [
|
|
||||||
...invocationArgs,
|
|
||||||
...extraVarIds.map((vid) => nunjucksContext[vid]),
|
|
||||||
...extraFuncIds.map((fid2) => makeCallable(fid2)),
|
|
||||||
]
|
|
||||||
const fn = new Function(...allParamNames, innerBody)
|
|
||||||
invoke = () => fn(...allArgs)
|
|
||||||
} else {
|
|
||||||
const fn = new Function('args', body)
|
|
||||||
invoke = () => fn(positionals)
|
|
||||||
}
|
|
||||||
|
|
||||||
const done = (err: Error | null, res: string) => {
|
|
||||||
callback(err, res)
|
|
||||||
}
|
|
||||||
const runInvoke = () => {
|
|
||||||
try {
|
|
||||||
const result = invoke()
|
|
||||||
if (result != null && typeof (result as Promise<unknown>).then === 'function') {
|
|
||||||
(result as Promise<unknown>).then(
|
|
||||||
(r) => done(null, formatFilterResult(r)),
|
|
||||||
(err) => done(err instanceof Error ? err : new Error(String(err)), '')
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
done(null, formatFilterResult(result))
|
|
||||||
}
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const s = e as { __suspend?: boolean; promise?: Promise<string>; key?: string }
|
|
||||||
if (s?.__suspend && s.promise) {
|
|
||||||
s.promise.then(() => runInvoke(), (err) =>
|
|
||||||
done(err instanceof Error ? err : new Error(String(err)), '')
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
done(e instanceof Error ? e : new Error(String(e)), '')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
runInvoke()
|
|
||||||
},
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
env.render(srcId!, nunjucksContext, async (nunjucksErr: Error | null, afterNunjucks: string) => {
|
|
||||||
if (cancelled || thisRunId !== runIdRef.current) return
|
|
||||||
if (nunjucksErr) {
|
|
||||||
setRenderedContent(null)
|
|
||||||
setResolvedContent(null)
|
|
||||||
setError({ kind: 'render', message: `Nunjucks: ${nunjucksErr.message}` })
|
|
||||||
setLoading(false)
|
|
||||||
endConnectionPathUpdate?.(id)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapse runs of newlines so {% for %} / {{ }} on their own lines don't leave blank lines
|
|
||||||
const resolved = afterNunjucks.replace(/(\r?\n)(\s*\r?\n)+/g, '$1').trim()
|
|
||||||
setResolvedContent(resolved)
|
|
||||||
const typeRenderer = getConfigType(configTypeId)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const renderOptions = configTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined
|
|
||||||
const htmlOrSvg = await typeRenderer.render(resolved, renderOptions)
|
|
||||||
if (cancelled || thisRunId !== runIdRef.current) return
|
|
||||||
setRenderedContent(htmlOrSvg)
|
|
||||||
setError(null)
|
|
||||||
} catch (err: any) {
|
|
||||||
if (cancelled || thisRunId !== runIdRef.current) return
|
|
||||||
const msg = err?.message ?? 'Render error'
|
|
||||||
setRenderedContent(null)
|
|
||||||
setError({ kind: 'render', message: msg })
|
|
||||||
} finally {
|
|
||||||
if (!cancelled && thisRunId === runIdRef.current) {
|
|
||||||
const startedAt = loadingStartedAtRef.current ?? 0
|
|
||||||
const elapsed = Date.now() - startedAt
|
|
||||||
const remaining = Math.max(0, 1000 - elapsed)
|
|
||||||
if (remaining > 0) {
|
|
||||||
minLoadingTimeoutRef.current = setTimeout(() => {
|
|
||||||
minLoadingTimeoutRef.current = null
|
|
||||||
if (!cancelled && thisRunId === runIdRef.current) {
|
|
||||||
setLoading(false)
|
|
||||||
endConnectionPathUpdate?.(id)
|
|
||||||
}
|
|
||||||
}, remaining)
|
|
||||||
} else {
|
|
||||||
setLoading(false)
|
|
||||||
endConnectionPathUpdate?.(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (!cancelled && thisRunId === runIdRef.current) {
|
if (!cancelled && thisRunId === runIdRef.current) {
|
||||||
setRenderedContent(null)
|
setRenderedContent(null)
|
||||||
setError({ kind: 'render', message: err?.message ?? 'Render error' })
|
setError({ kind: 'render', message: err?.message ?? 'Render error' })
|
||||||
setLoading(false)
|
}
|
||||||
endConnectionPathUpdate?.(id)
|
} finally {
|
||||||
|
if (!cancelled && thisRunId === runIdRef.current) {
|
||||||
|
const startedAt = loadingStartedAtRef.current ?? 0
|
||||||
|
const elapsed = Date.now() - startedAt
|
||||||
|
const remaining = Math.max(0, 1000 - elapsed)
|
||||||
|
if (remaining > 0) {
|
||||||
|
minLoadingTimeoutRef.current = setTimeout(() => {
|
||||||
|
minLoadingTimeoutRef.current = null
|
||||||
|
if (!cancelled && thisRunId === runIdRef.current) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, remaining)
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS)
|
if (effectiveUpdateMode === 'auto') {
|
||||||
|
const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
if (minLoadingTimeoutRef.current != null) {
|
||||||
|
clearTimeout(minLoadingTimeoutRef.current)
|
||||||
|
minLoadingTimeoutRef.current = null
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
clearTimeout(debounceTimer)
|
|
||||||
if (minLoadingTimeoutRef.current != null) {
|
if (minLoadingTimeoutRef.current != null) {
|
||||||
clearTimeout(minLoadingTimeoutRef.current)
|
clearTimeout(minLoadingTimeoutRef.current)
|
||||||
minLoadingTimeoutRef.current = null
|
minLoadingTimeoutRef.current = null
|
||||||
}
|
}
|
||||||
endConnectionPathUpdate?.(id)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
// Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry.
|
}, [id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceContent, sourceSignature, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, nodes, edges, updateData])
|
||||||
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, isAgentSource])
|
|
||||||
|
|
||||||
const dimensions =
|
const dimensions =
|
||||||
width != null && height != null && width > 0 && height > 0
|
width != null && height != null && width > 0 && height > 0
|
||||||
@@ -706,40 +483,99 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
|
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
|
||||||
right={
|
right={
|
||||||
incomingIds.length > 0 ? (
|
incomingIds.length > 0 ? (
|
||||||
<ToggleGroup
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
type="single"
|
<ButtonGroup className="nodrag nopan">
|
||||||
value={viewMode}
|
<Button
|
||||||
onValueChange={(v) => {
|
type="button"
|
||||||
if (v === 'preview' || v === 'raw') setViewMode(v)
|
size="sm"
|
||||||
}}
|
variant="outline"
|
||||||
aria-label="View mode"
|
className="h-7 gap-1.5 rounded-r-none border-r-0 px-2.5 text-xs"
|
||||||
variant="outline"
|
onClick={(e) => {
|
||||||
size="sm"
|
e.stopPropagation()
|
||||||
className="gap-0 rounded-md p-0.5 [&>button]:rounded-none [&>button:first-child]:rounded-l-md [&>button:last-child]:rounded-r-md [&>button:not(:first-child)]:border-l-0"
|
updateData({ runTrigger: (data?.runTrigger ?? 0) + 1 })
|
||||||
>
|
}}
|
||||||
<ToggleGroupItem
|
>
|
||||||
value="preview"
|
<Play className="size-3.5" />
|
||||||
aria-label="Preview"
|
Run
|
||||||
className="gap-1.5 px-2.5 h-7"
|
</Button>
|
||||||
onClick={(e) => {
|
<DropdownMenu>
|
||||||
e.stopPropagation()
|
<DropdownMenuTrigger asChild>
|
||||||
setViewMode('preview')
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 min-w-[4.5rem] gap-1 rounded-l-none pl-2 pr-1.5 text-xs font-normal"
|
||||||
|
aria-label="Update mode"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{effectiveUpdateMode === 'manual' ? 'Manual' : 'Auto'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="size-3.5 shrink-0 opacity-70" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-64" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||||
|
When to re-render
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={effectiveUpdateMode === 'auto'}
|
||||||
|
onCheckedChange={(checked) => checked && updateData({ updateMode: 'auto' })}
|
||||||
|
className="flex flex-col items-start gap-0.5 py-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium">Auto</span>
|
||||||
|
<span className="text-muted-foreground text-xs font-normal">
|
||||||
|
Re-renders when upstream content changes
|
||||||
|
</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={effectiveUpdateMode === 'manual'}
|
||||||
|
onCheckedChange={(checked) => checked && updateData({ updateMode: 'manual' })}
|
||||||
|
className="flex flex-col items-start gap-0.5 py-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium">Manual</span>
|
||||||
|
<span className="text-muted-foreground text-xs font-normal">
|
||||||
|
Re-renders only when you click Run
|
||||||
|
</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={viewMode}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === 'preview' || v === 'raw') setViewMode(v)
|
||||||
}}
|
}}
|
||||||
|
aria-label="View mode"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-0 rounded-md p-0.5 [&>button]:rounded-none [&>button:first-child]:rounded-l-md [&>button:last-child]:rounded-r-md [&>button:not(:first-child)]:border-l-0"
|
||||||
>
|
>
|
||||||
Preview
|
<ToggleGroupItem
|
||||||
</ToggleGroupItem>
|
value="preview"
|
||||||
<ToggleGroupItem
|
aria-label="Preview"
|
||||||
value="raw"
|
className="gap-1.5 px-2.5 h-7"
|
||||||
aria-label="Raw config"
|
onClick={(e) => {
|
||||||
className="gap-1.5 px-2.5 h-7"
|
e.stopPropagation()
|
||||||
onClick={(e) => {
|
setViewMode('preview')
|
||||||
e.stopPropagation()
|
}}
|
||||||
setViewMode('raw')
|
>
|
||||||
}}
|
Preview
|
||||||
>
|
</ToggleGroupItem>
|
||||||
Raw
|
<ToggleGroupItem
|
||||||
</ToggleGroupItem>
|
value="raw"
|
||||||
</ToggleGroup>
|
aria-label="Raw config"
|
||||||
|
className="gap-1.5 px-2.5 h-7"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setViewMode('raw')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Raw
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
26
frontend/src/components/nodes/render/descriptor.tsx
Normal file
26
frontend/src/components/nodes/render/descriptor.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Sparkles } from 'lucide-react'
|
||||||
|
import { createNodeTypeBuilder } from '@/lib/nodeTypeBuilder'
|
||||||
|
import { NODE_HELP } from '@/lib/nodeHelp'
|
||||||
|
import type { NodeTypeDescriptor } from '@/lib/nodeRegistry'
|
||||||
|
import RenderingNode from './RenderingNode'
|
||||||
|
|
||||||
|
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||||
|
|
||||||
|
export function getRenderNodeDescriptor(): NodeTypeDescriptor {
|
||||||
|
return createNodeTypeBuilder(
|
||||||
|
'render',
|
||||||
|
RenderingNode,
|
||||||
|
{ width: 384, height: 320 },
|
||||||
|
{ viewportWidth: 1200, viewportHeight: 800 }
|
||||||
|
)
|
||||||
|
.idPrefix('rnd_')
|
||||||
|
.withInputOutput(true, false)
|
||||||
|
.classification('pneuma')
|
||||||
|
.allowedSourceTypes(['config', 'agent'])
|
||||||
|
.help(NODE_HELP.render)
|
||||||
|
.menu('Renderer', <Sparkles className={ICON_CLASS} />)
|
||||||
|
.connectionLabel('rendering')
|
||||||
|
.withFullscreen()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
2
frontend/src/components/nodes/render/index.ts
Normal file
2
frontend/src/components/nodes/render/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as RenderingNode, type RenderingNodeData } from './RenderingNode'
|
||||||
|
export { getRenderNodeDescriptor } from './descriptor'
|
||||||
@@ -3,20 +3,20 @@ import {
|
|||||||
AbstractNodeProps,
|
AbstractNodeProps,
|
||||||
createAbstractNodeComponent,
|
createAbstractNodeComponent,
|
||||||
useAbstractNode,
|
useAbstractNode,
|
||||||
} from '../../lib/abstractNode'
|
} from '@/lib/abstractNode'
|
||||||
import {
|
import {
|
||||||
BaseNode,
|
BaseNode,
|
||||||
BaseNodeContent,
|
BaseNodeContent,
|
||||||
BaseNodeFooter,
|
BaseNodeFooter,
|
||||||
BaseNodeHeaderRow,
|
BaseNodeHeaderRow,
|
||||||
} from '../base/BaseNode'
|
} from '@/components/base/BaseNode'
|
||||||
import { NodeMenubar } from '../base/NodeMenubar'
|
import { NodeMenubar } from '@/components/base/NodeMenubar'
|
||||||
import { Input } from '../ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Switch } from '../ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators'
|
import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators'
|
||||||
import { NodeHeaderTitle } from '../base/NodeHeaderTitle'
|
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
|
||||||
import { OutputHandle } from '../base/NodeHandles'
|
import { OutputHandle } from '@/components/base/NodeHandles'
|
||||||
import { Variable } from 'lucide-react'
|
import { Variable } from 'lucide-react'
|
||||||
|
|
||||||
export type ValueType = 'string' | 'number' | 'boolean'
|
export type ValueType = 'string' | 'number' | 'boolean'
|
||||||
24
frontend/src/components/nodes/variable/descriptor.tsx
Normal file
24
frontend/src/components/nodes/variable/descriptor.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Variable } from 'lucide-react'
|
||||||
|
import { createNodeTypeBuilder } from '@/lib/nodeTypeBuilder'
|
||||||
|
import { NODE_HELP } from '@/lib/nodeHelp'
|
||||||
|
import type { NodeTypeDescriptor } from '@/lib/nodeRegistry'
|
||||||
|
import VariableNode from './VariableNode'
|
||||||
|
|
||||||
|
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||||
|
|
||||||
|
export function getVariableNodeDescriptor(): NodeTypeDescriptor {
|
||||||
|
return createNodeTypeBuilder(
|
||||||
|
'variable',
|
||||||
|
VariableNode,
|
||||||
|
{ width: 224, height: 240 },
|
||||||
|
{ value: '', valueType: 'string' }
|
||||||
|
)
|
||||||
|
.idPrefix('var_')
|
||||||
|
.withInputOutput(false, true)
|
||||||
|
.classification('psyche')
|
||||||
|
.allowedTargetTypes(['config', 'function', 'agent'])
|
||||||
|
.help(NODE_HELP.variable)
|
||||||
|
.menu('Variable', <Variable className={ICON_CLASS} />)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
2
frontend/src/components/nodes/variable/index.ts
Normal file
2
frontend/src/components/nodes/variable/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as VariableNode, type VariableNodeData, type ValueType } from './VariableNode'
|
||||||
|
export { getVariableNodeDescriptor } from './descriptor'
|
||||||
@@ -3,9 +3,14 @@
|
|||||||
*
|
*
|
||||||
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
|
* - **AbstractNodeProps<TData>** — Typed props (id, data, width?, height?, selected?) for your node.
|
||||||
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
* - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges,
|
||||||
* updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds.
|
* 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.
|
* - **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/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
|
* Example: define NodeData type, Props = AbstractNodeProps<NodeData>, use useAbstractNode in the
|
||||||
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
|
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Connection status: visual state of an edge (color/class).
|
* Connection status: visual state of an edge (color/class).
|
||||||
* Priority when multiple apply: error > paused > updating > default.
|
* Priority when multiple apply: error > paused > updating > default.
|
||||||
* Nodes report state via FlowContext (e.g. addConnectionPathError); edges derive status here.
|
*
|
||||||
|
* Status is derived from node lifecycle state: nodes report updating / paused / error
|
||||||
|
* via useSyncConnectionStatus() in nodeLifecycle.ts, which updates FlowContext sets.
|
||||||
|
* Edges read those sets here to pick the single status per edge.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ConnectionStatus = 'default' | 'updating' | 'paused' | 'error'
|
export type ConnectionStatus = 'default' | 'updating' | 'paused' | 'error'
|
||||||
|
|||||||
98
frontend/src/lib/nodeLifecycle.ts
Normal file
98
frontend/src/lib/nodeLifecycle.ts
Normal 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])
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Extensible node type registry. Register node types with registerNodeType();
|
* Extensible node type registry. Register node types with registerNodeType() or use NodeTypeBuilder.
|
||||||
* built-in types are registered in registerBuiltinNodes.ts.
|
* Built-in types are registered in registerBuiltinNodes.ts.
|
||||||
* Use getRegisteredNodeTypes() / getNodeType(id) for defaults, validation, and UI.
|
* Use getRegisteredNodeTypes() / getNodeType(id) for defaults, validation, and UI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import type { Node } from '@xyflow/react'
|
import { registerSourceRenderingLogic } from './sourceRenderingLogic'
|
||||||
|
import type { SourceRenderingLogic } from './sourceRenderingLogic'
|
||||||
|
|
||||||
/** Node classification for UI: Psyche, Pneuma, Physis, Archon (AI Agent). */
|
/** Node classification for UI: Psyche, Pneuma, Physis, Archon (AI Agent). */
|
||||||
export type NodeClassification = 'psyche' | 'pneuma' | 'physis' | 'archon'
|
export type NodeClassification = 'psyche' | 'pneuma' | 'physis' | 'archon'
|
||||||
@@ -47,6 +48,8 @@ export type NodeTypeDescriptor = {
|
|||||||
connectionLabel?: string
|
connectionLabel?: string
|
||||||
/** When true, double-clicking the node header opens a fullscreen dialog for this node. */
|
/** When true, double-clicking the node header opens a fullscreen dialog for this node. */
|
||||||
supportsFullscreen?: boolean
|
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>()
|
const registry = new Map<string, NodeTypeDescriptor>()
|
||||||
@@ -55,6 +58,9 @@ export function registerNodeType(descriptor: NodeTypeDescriptor): void {
|
|||||||
if (registry.has(descriptor.id)) {
|
if (registry.has(descriptor.id)) {
|
||||||
console.warn(`[nodeRegistry] Overwriting existing node type: ${descriptor.id}`)
|
console.warn(`[nodeRegistry] Overwriting existing node type: ${descriptor.id}`)
|
||||||
}
|
}
|
||||||
|
if (descriptor.sourceRenderingLogic) {
|
||||||
|
registerSourceRenderingLogic(descriptor.id, descriptor.sourceRenderingLogic)
|
||||||
|
}
|
||||||
registry.set(descriptor.id, descriptor)
|
registry.set(descriptor.id, descriptor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
165
frontend/src/lib/nodeTypeBuilder.ts
Normal file
165
frontend/src/lib/nodeTypeBuilder.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
import type { ConfigNodeData } from '@/components/nodes/ConfigNode'
|
import type { ConfigNodeData } from '@/components/nodes/config'
|
||||||
import type { RenderingNodeData } from '@/components/nodes/RenderingNode'
|
import type { RenderingNodeData } from '@/components/nodes/render'
|
||||||
import type { VariableNodeData } from '@/components/nodes/VariableNode'
|
import type { VariableNodeData } from '@/components/nodes/variable'
|
||||||
import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
|
import type { FunctionNodeData } from '@/components/nodes/function'
|
||||||
import type { DataNodeData } from '@/components/nodes/DataNode'
|
import type { DataNodeData } from '@/components/nodes/data'
|
||||||
import type { AgentNodeData } from '@/components/nodes/AgentNode'
|
import type { AgentNodeData } from '@/components/nodes/agent'
|
||||||
|
|
||||||
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData | DataNodeData | AgentNodeData
|
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData | DataNodeData | AgentNodeData
|
||||||
export type AppNode = Node<AppNodeData>
|
export type AppNode = Node<AppNodeData>
|
||||||
|
|||||||
@@ -1,137 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Registers built-in node types (config, render, variable, function).
|
* Registers built-in node types using the node type builder.
|
||||||
* Import this once at app startup (e.g. in main.tsx) so the registry is populated.
|
* 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 React from 'react'
|
|
||||||
import { ScrollText, Sparkles, Variable, Code2, Database, Bot } from 'lucide-react'
|
|
||||||
import { registerNodeType } from './nodeRegistry'
|
import { registerNodeType } from './nodeRegistry'
|
||||||
import { NODE_HELP } from './nodeHelp'
|
import { getConfigNodeDescriptor } from '@/components/nodes/config'
|
||||||
import ConfigNode from '../components/nodes/ConfigNode'
|
import { getAgentNodeDescriptor } from '@/components/nodes/agent'
|
||||||
import RenderingNode from '../components/nodes/RenderingNode'
|
import { getRenderNodeDescriptor } from '@/components/nodes/render'
|
||||||
import VariableNode from '../components/nodes/VariableNode'
|
import { getVariableNodeDescriptor } from '@/components/nodes/variable'
|
||||||
import FunctionNode from '../components/nodes/FunctionNode'
|
import { getFunctionNodeDescriptor } from '@/components/nodes/function'
|
||||||
import DataNode from '../components/nodes/DataNode'
|
import { getDataNodeDescriptor } from '@/components/nodes/data'
|
||||||
import AgentNode from '../components/nodes/AgentNode'
|
|
||||||
|
|
||||||
const ICON_CLASS = 'mr-2 h-4 w-4'
|
|
||||||
|
|
||||||
export function registerBuiltinNodes(): void {
|
export function registerBuiltinNodes(): void {
|
||||||
registerNodeType({
|
registerNodeType(getConfigNodeDescriptor())
|
||||||
id: 'config',
|
registerNodeType(getAgentNodeDescriptor())
|
||||||
component: ConfigNode,
|
registerNodeType(getRenderNodeDescriptor())
|
||||||
defaultStyle: { width: 320, height: 320 },
|
registerNodeType(getVariableNodeDescriptor())
|
||||||
defaultData: { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' },
|
registerNodeType(getFunctionNodeDescriptor())
|
||||||
idPrefix: 'cfg_',
|
registerNodeType(getDataNodeDescriptor())
|
||||||
hasInput: true,
|
|
||||||
hasOutput: true,
|
|
||||||
classification: 'psyche',
|
|
||||||
allowedSourceTypes: ['config', 'variable', 'function', 'data'],
|
|
||||||
allowedTargetTypes: ['config', 'render', 'agent'],
|
|
||||||
help: NODE_HELP.config,
|
|
||||||
menuLabel: 'Config',
|
|
||||||
menuIcon: <ScrollText className={ICON_CLASS} />,
|
|
||||||
getDefaultData: (newId) => ({
|
|
||||||
configType: 'plantuml',
|
|
||||||
content: '@startuml\n\n@enduml\n',
|
|
||||||
title: newId ?? '',
|
|
||||||
}),
|
|
||||||
getResetData: (nodeId) => ({
|
|
||||||
configType: 'plantuml',
|
|
||||||
content: '@startuml\n\n@enduml\n',
|
|
||||||
title: nodeId ?? '',
|
|
||||||
}),
|
|
||||||
connectionLabel: 'adding input',
|
|
||||||
supportsFullscreen: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
registerNodeType({
|
|
||||||
id: 'render',
|
|
||||||
component: RenderingNode,
|
|
||||||
defaultStyle: { width: 384, height: 320 },
|
|
||||||
defaultData: { viewportWidth: 1200, viewportHeight: 800 },
|
|
||||||
idPrefix: 'rnd_',
|
|
||||||
hasInput: true,
|
|
||||||
hasOutput: false,
|
|
||||||
classification: 'pneuma',
|
|
||||||
allowedSourceTypes: ['config', 'agent'],
|
|
||||||
help: NODE_HELP.render,
|
|
||||||
menuLabel: 'Renderer',
|
|
||||||
menuIcon: <Sparkles className={ICON_CLASS} />,
|
|
||||||
connectionLabel: 'rendering',
|
|
||||||
supportsFullscreen: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
registerNodeType({
|
|
||||||
id: 'agent',
|
|
||||||
component: AgentNode,
|
|
||||||
defaultStyle: { width: 360, height: 320 },
|
|
||||||
defaultData: { context: '' },
|
|
||||||
idPrefix: 'agt_',
|
|
||||||
hasInput: true,
|
|
||||||
hasOutput: true,
|
|
||||||
classification: 'archon',
|
|
||||||
allowedSourceTypes: ['config', 'variable', 'data'],
|
|
||||||
allowedTargetTypes: ['render'],
|
|
||||||
help: NODE_HELP.agent,
|
|
||||||
menuLabel: 'Agent',
|
|
||||||
menuIcon: <Bot className={ICON_CLASS} />,
|
|
||||||
connectionLabel: 'prompt/context',
|
|
||||||
supportsFullscreen: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
registerNodeType({
|
|
||||||
id: 'variable',
|
|
||||||
component: VariableNode,
|
|
||||||
defaultStyle: { width: 224, height: 240 },
|
|
||||||
defaultData: { value: '', valueType: 'string' },
|
|
||||||
idPrefix: 'var_',
|
|
||||||
hasInput: false,
|
|
||||||
hasOutput: true,
|
|
||||||
classification: 'psyche',
|
|
||||||
allowedTargetTypes: ['config', 'function', 'agent'],
|
|
||||||
help: NODE_HELP.variable,
|
|
||||||
menuLabel: 'Variable',
|
|
||||||
menuIcon: <Variable className={ICON_CLASS} />,
|
|
||||||
})
|
|
||||||
|
|
||||||
registerNodeType({
|
|
||||||
id: 'data',
|
|
||||||
component: DataNode,
|
|
||||||
defaultStyle: { width: 360, height: 280 },
|
|
||||||
defaultData: { rows: [], columns: [], fileName: '' },
|
|
||||||
idPrefix: 'data_',
|
|
||||||
hasInput: false,
|
|
||||||
hasOutput: true,
|
|
||||||
classification: 'physis',
|
|
||||||
allowedTargetTypes: ['config', 'agent'],
|
|
||||||
help: NODE_HELP.data,
|
|
||||||
menuLabel: 'Data',
|
|
||||||
menuIcon: <Database className={ICON_CLASS} />,
|
|
||||||
connectionLabel: 'data source',
|
|
||||||
supportsFullscreen: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
registerNodeType({
|
|
||||||
id: 'function',
|
|
||||||
component: FunctionNode,
|
|
||||||
defaultStyle: { width: 288, height: 260 },
|
|
||||||
defaultData: {
|
|
||||||
body: `function(num, kwargs) {
|
|
||||||
return num + (kwargs.bar || 0);
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
idPrefix: 'fn_',
|
|
||||||
hasInput: true,
|
|
||||||
hasOutput: true,
|
|
||||||
classification: 'psyche',
|
|
||||||
allowedSourceTypes: ['config', 'variable', 'function'],
|
|
||||||
allowedTargetTypes: ['config', 'function'],
|
|
||||||
help: NODE_HELP.function,
|
|
||||||
menuLabel: 'Function',
|
|
||||||
menuIcon: <Code2 className={ICON_CLASS} />,
|
|
||||||
getResetData: () => ({ body: '' }),
|
|
||||||
connectionLabel: 'adding input',
|
|
||||||
supportsFullscreen: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
47
frontend/src/lib/sourceRenderingLogic.ts
Normal file
47
frontend/src/lib/sourceRenderingLogic.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Source rendering logic: each node type that can feed the Rendering node
|
||||||
|
* registers how to get "resolved" content and default update behavior.
|
||||||
|
* The Rendering node uses this to run the right logic and respect auto vs manual updates.
|
||||||
|
*
|
||||||
|
* Register in registerBuiltinNodes (or at app init) via registerSourceRenderingLogic(nodeType, logic).
|
||||||
|
* Node types that are allowed sources for the Renderer should register here (see nodeRegistry NodeTypeDescriptor).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ConfigTypeId } from './configTypes'
|
||||||
|
|
||||||
|
export type SourceRenderingLogicContext = {
|
||||||
|
nodes: { id: string; type?: string; data?: unknown }[]
|
||||||
|
edges: { id: string; source: string; target: string }[]
|
||||||
|
sourceNodeId: string
|
||||||
|
renderNodeId: string
|
||||||
|
viewportWidth?: number
|
||||||
|
viewportHeight?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of getResolvedContent: resolved string plus the config type to use for final render.
|
||||||
|
*/
|
||||||
|
export type ResolvedContentResult = {
|
||||||
|
resolved: string
|
||||||
|
outputTypeId: ConfigTypeId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendering logic provided by a source node type (e.g. config, agent).
|
||||||
|
* - defaultUpdateMode: 'auto' = re-render on upstream changes; 'manual' = only on Run
|
||||||
|
* - getResolvedContent: async resolve step; returns resolved string and which renderer (ConfigTypeId) to use
|
||||||
|
*/
|
||||||
|
export type SourceRenderingLogic = {
|
||||||
|
defaultUpdateMode: 'auto' | 'manual'
|
||||||
|
getResolvedContent: (context: SourceRenderingLogicContext) => Promise<ResolvedContentResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = new Map<string, SourceRenderingLogic>()
|
||||||
|
|
||||||
|
export function registerSourceRenderingLogic(nodeType: string, logic: SourceRenderingLogic): void {
|
||||||
|
registry.set(nodeType, logic)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSourceRenderingLogic(nodeType: string): SourceRenderingLogic | null {
|
||||||
|
return registry.get(nodeType) ?? null
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user