diff --git a/frontend/src/components/nodes/AgentNode.tsx b/frontend/src/components/nodes/agent/AgentNode.tsx similarity index 92% rename from frontend/src/components/nodes/AgentNode.tsx rename to frontend/src/components/nodes/agent/AgentNode.tsx index 2eb3664..5abb6f9 100644 --- a/frontend/src/components/nodes/AgentNode.tsx +++ b/frontend/src/components/nodes/agent/AgentNode.tsx @@ -16,6 +16,7 @@ import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndica import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle' import { InputHandle, OutputHandle } from '@/components/base/NodeHandles' import FlowContext from '@/lib/flowContext' +import { useSyncConnectionStatus } from '@/lib/nodeLifecycle' import { getNodeType } from '@/lib/nodeRegistry' import { Button } from '@/components/ui/button' 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) { const flowContext = useContext(FlowContext) 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 { nodes, sourceIds, updateData } = useAbstractNode(id, data ?? {}) 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 contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) })) - removeConnectionPathPausedNode?.(id) updateData({ error: undefined, loading: true }) - startConnectionPathUpdate?.(id) setRunning(true) try { 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}`, outputMarkdown: undefined, }) - endConnectionPathUpdate?.(id) return } const markdown = (json as { markdown?: string }).markdown ?? '' @@ -135,18 +129,16 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) { outputMarkdown: markdown, lastRunSourceSignature: sourceSignature, }) - endConnectionPathUpdate?.(id) } catch (err: unknown) { updateData({ loading: false, error: err instanceof Error ? err.message : 'Agent request failed', outputMarkdown: undefined, }) - endConnectionPathUpdate?.(id) } finally { setRunning(false) } - }, [sourceIds, nodes, contextText, sourceSignature, updateData, aiConnection, removeConnectionPathPausedNode, startConnectionPathUpdate, endConnectionPathUpdate]) + }, [sourceIds, nodes, contextText, sourceSignature, updateData, aiConnection]) const onContextChange = useCallback( (e: React.ChangeEvent) => updateData({ context: e.target.value }), @@ -161,10 +153,12 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) { triggerNodeIds.length > 0 && !loading && sourceSignature !== lastRunSourceSignature - useEffect(() => { - if (hasPendingInputs) addConnectionPathPausedNode?.(id) - else removeConnectionPathPausedNode?.(id) - }, [id, hasPendingInputs, addConnectionPathPausedNode, removeConnectionPathPausedNode]) + + useSyncConnectionStatus(id, { + updating: running || loading, + error: !!error, + paused: hasPendingInputs && !running && !loading, + }) return ( ) + .connectionLabel('prompt/context') + .withFullscreen() + .sourceRenderingLogic(agentRenderingLogic) + .build() +} diff --git a/frontend/src/components/nodes/agent/index.ts b/frontend/src/components/nodes/agent/index.ts new file mode 100644 index 0000000..915463c --- /dev/null +++ b/frontend/src/components/nodes/agent/index.ts @@ -0,0 +1,2 @@ +export { default as AgentNode, type AgentNodeData } from './AgentNode' +export { getAgentNodeDescriptor } from './descriptor' diff --git a/frontend/src/components/nodes/agent/renderingLogic.ts b/frontend/src/components/nodes/agent/renderingLogic.ts new file mode 100644 index 0000000..25e9486 --- /dev/null +++ b/frontend/src/components/nodes/agent/renderingLogic.ts @@ -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' } + }, +} diff --git a/frontend/src/components/nodes/ConfigNode.tsx b/frontend/src/components/nodes/config/ConfigNode.tsx similarity index 95% rename from frontend/src/components/nodes/ConfigNode.tsx rename to frontend/src/components/nodes/config/ConfigNode.tsx index 0cb441e..01493f3 100644 --- a/frontend/src/components/nodes/ConfigNode.tsx +++ b/frontend/src/components/nodes/config/ConfigNode.tsx @@ -8,11 +8,11 @@ import { createAbstractNodeComponent, useAbstractNode, type FlowNode, -} from '../../lib/abstractNode' -import { useResizeHeight } from '../../hooks/useResizeHeight' -import { nunjucksCompletionSource } from '../../lib/nunjucksAutocomplete' -import { plantumlLanguage } from '../../lib/plantumlLanguage' -import { useTheme } from '../../lib/themeContext' +} from '@/lib/abstractNode' +import { useResizeHeight } from '@/hooks/useResizeHeight' +import { nunjucksCompletionSource } from '@/lib/nunjucksAutocomplete' +import { plantumlLanguage } from '@/lib/plantumlLanguage' +import { useTheme } from '@/lib/themeContext' import { CONFIG_TYPES, getConfigContent, @@ -20,13 +20,13 @@ import { getConfigTypeId, isGroup, type ConfigTypeId, -} from '../../lib/configTypes' +} from '@/lib/configTypes' import { BaseNode, BaseNodeContent, BaseNodeFooter, BaseNodeHeaderRow, -} from '../base/BaseNode' +} from '@/components/base/BaseNode' import { Code2, Database, ScrollText, Variable } from 'lucide-react' import { MenubarItem, @@ -35,15 +35,15 @@ import { MenubarSub, MenubarSubContent, MenubarSubTrigger, -} from '../ui/menubar' -import { Kbd } from '../ui/kbd' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' -import { InputHandle, OutputHandle } from '../base/NodeHandles' -import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' -import { NodeHeaderTitle } from '../base/NodeHeaderTitle' -import { NodeMenubar } from '../base/NodeMenubar' -import FlowContext from '../../lib/flowContext' -import { getNodeType } from '../../lib/nodeRegistry' +} from '@/components/ui/menubar' +import { Kbd } from '@/components/ui/kbd' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { InputHandle, OutputHandle } from '@/components/base/NodeHandles' +import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators' +import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle' +import { NodeMenubar } from '@/components/base/NodeMenubar' +import FlowContext from '@/lib/flowContext' +import { getNodeType } from '@/lib/nodeRegistry' export type ConfigNodeData = { configType?: ConfigTypeId; content?: string; title?: string; /** @deprecated use content */ plantuml?: string } diff --git a/frontend/src/components/nodes/config/descriptor.tsx b/frontend/src/components/nodes/config/descriptor.tsx new file mode 100644 index 0000000..9be5a0a --- /dev/null +++ b/frontend/src/components/nodes/config/descriptor.tsx @@ -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', ) + .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() +} diff --git a/frontend/src/components/nodes/config/index.ts b/frontend/src/components/nodes/config/index.ts new file mode 100644 index 0000000..db35cb4 --- /dev/null +++ b/frontend/src/components/nodes/config/index.ts @@ -0,0 +1,2 @@ +export { default as ConfigNode, type ConfigNodeData } from './ConfigNode' +export { getConfigNodeDescriptor } from './descriptor' diff --git a/frontend/src/components/nodes/config/renderingLogic.ts b/frontend/src/components/nodes/config/renderingLogic.ts new file mode 100644 index 0000000..6342676 --- /dev/null +++ b/frontend/src/components/nodes/config/renderingLogic.ts @@ -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([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)?.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 { + 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 | undefined) + : 'plantuml' + + const configIdsUsed = new Set() + + const addConfigAndRefs = (templateName: string, visited = new Set()) => { + 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 | 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 | undefined), + path: name, + } + }, + } + + const nunjucksContext = Object.create(null) as Record + const setVarInContext = (src: Node) => { + const v = (src.data as Record)?.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)?.rows as Record[]) ?? [] + const hidden = ((src.data as Record)?.hiddenColumns as string[]) ?? [] + const visibleCols = ((src.data as Record)?.columns as string[] | undefined) ?? [] + const filtered = (visibleCols as string[]).filter((c) => !hidden.includes(c)) + const filteredRows = rows.map((row) => { + const out: Record = {} + for (const col of filtered) { + if (col in row) out[col] = row[col] + } + return out + }) + nunjucksContext[src.id] = filteredRows + } + } + + const functionIdsToRegister = new Set() + 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 => + typeof v === 'object' && v !== null && !Array.isArray(v) + + const functionConnectedVariableIds = Object.create(null) as Record + const functionConnectedFunctionIds = Object.create(null) as Record + 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)?.body as string) ?? 'return args[0];' + const parsed = parseFunctionSignature(body) + const connectedVarIds = new Set(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) : Object.create(null) + + const nestedCache = new Map() + 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((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).then === 'function') { + (result as Promise).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; 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 }) + }) + }) +} diff --git a/frontend/src/components/nodes/DataNode.tsx b/frontend/src/components/nodes/data/DataNode.tsx similarity index 95% rename from frontend/src/components/nodes/DataNode.tsx rename to frontend/src/components/nodes/data/DataNode.tsx index cdc7777..eb44d22 100644 --- a/frontend/src/components/nodes/DataNode.tsx +++ b/frontend/src/components/nodes/data/DataNode.tsx @@ -3,21 +3,21 @@ import { AbstractNodeProps, createAbstractNodeComponent, useAbstractNode, -} from '../../lib/abstractNode' +} from '@/lib/abstractNode' import { BaseNode, BaseNodeContent, BaseNodeFooter, BaseNodeHeaderRow, -} from '../base/BaseNode' -import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' -import { NodeHeaderTitle } from '../base/NodeHeaderTitle' -import { NodeMenubar } from '../base/NodeMenubar' -import FlowContext from '../../lib/flowContext' -import { getNodeType } from '../../lib/nodeRegistry' -import { OutputHandle } from '../base/NodeHandles' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table' -import { parseCsvToRows } from '../../lib/csvParse' +} from '@/components/base/BaseNode' +import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators' +import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle' +import { NodeMenubar } from '@/components/base/NodeMenubar' +import FlowContext from '@/lib/flowContext' +import { getNodeType } from '@/lib/nodeRegistry' +import { OutputHandle } from '@/components/base/NodeHandles' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { parseCsvToRows } from '@/lib/csvParse' import { ArrowDown, ArrowUp, ArrowUpDown, Database, FileUp } from 'lucide-react' import { flexRender, @@ -29,9 +29,9 @@ import { type ColumnDef, type SortingState, } from '@tanstack/react-table' -import { Button } from '../ui/button' +import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { ContextMenuCheckboxItem } from '../ui/context-menu' +import { ContextMenuCheckboxItem } from '@/components/ui/context-menu' import { MenubarCheckboxItem, MenubarItem, @@ -39,7 +39,7 @@ import { MenubarSub, MenubarSubContent, MenubarSubTrigger, -} from '../ui/menubar' +} from '@/components/ui/menubar' export type DataNodeData = { rows?: Record[] diff --git a/frontend/src/components/nodes/data/descriptor.tsx b/frontend/src/components/nodes/data/descriptor.tsx new file mode 100644 index 0000000..5a26c25 --- /dev/null +++ b/frontend/src/components/nodes/data/descriptor.tsx @@ -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', ) + .connectionLabel('data source') + .withFullscreen() + .build() +} diff --git a/frontend/src/components/nodes/data/index.ts b/frontend/src/components/nodes/data/index.ts new file mode 100644 index 0000000..516294f --- /dev/null +++ b/frontend/src/components/nodes/data/index.ts @@ -0,0 +1,2 @@ +export { default as DataNode, type DataNodeData } from './DataNode' +export { getDataNodeDescriptor } from './descriptor' diff --git a/frontend/src/components/nodes/FunctionNode.tsx b/frontend/src/components/nodes/function/FunctionNode.tsx similarity index 91% rename from frontend/src/components/nodes/FunctionNode.tsx rename to frontend/src/components/nodes/function/FunctionNode.tsx index 7633a97..5f0ec58 100644 --- a/frontend/src/components/nodes/FunctionNode.tsx +++ b/frontend/src/components/nodes/function/FunctionNode.tsx @@ -6,23 +6,23 @@ import { createAbstractNodeComponent, useAbstractNode, type FlowNode, -} from '../../lib/abstractNode' -import { useResizeHeight } from '../../hooks/useResizeHeight' -import { useTheme } from '../../lib/themeContext' +} from '@/lib/abstractNode' +import { useResizeHeight } from '@/hooks/useResizeHeight' +import { useTheme } from '@/lib/themeContext' import { BaseNode, BaseNodeContent, BaseNodeFooter, BaseNodeHeaderRow, -} from '../base/BaseNode' -import { InputHandle, OutputHandle } from '../base/NodeHandles' -import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' -import { NodeHeaderTitle } from '../base/NodeHeaderTitle' -import { NodeMenubar } from '../base/NodeMenubar' -import FlowContext from '../../lib/flowContext' -import { getNodeType } from '../../lib/nodeRegistry' -import { MenubarItem, MenubarShortcut } from '../ui/menubar' -import { Kbd } from '../ui/kbd' +} from '@/components/base/BaseNode' +import { InputHandle, OutputHandle } from '@/components/base/NodeHandles' +import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators' +import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle' +import { NodeMenubar } from '@/components/base/NodeMenubar' +import FlowContext from '@/lib/flowContext' +import { getNodeType } from '@/lib/nodeRegistry' +import { MenubarItem, MenubarShortcut } from '@/components/ui/menubar' +import { Kbd } from '@/components/ui/kbd' import { Code2, Variable } from 'lucide-react' export type FunctionNodeData = { body?: string } diff --git a/frontend/src/components/nodes/function/descriptor.tsx b/frontend/src/components/nodes/function/descriptor.tsx new file mode 100644 index 0000000..5315d49 --- /dev/null +++ b/frontend/src/components/nodes/function/descriptor.tsx @@ -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', ) + .getResetData(() => ({ body: '' })) + .connectionLabel('adding input') + .withFullscreen() + .build() +} diff --git a/frontend/src/components/nodes/function/index.ts b/frontend/src/components/nodes/function/index.ts new file mode 100644 index 0000000..b1cb4ed --- /dev/null +++ b/frontend/src/components/nodes/function/index.ts @@ -0,0 +1,2 @@ +export { default as FunctionNode, type FunctionNodeData } from './FunctionNode' +export { getFunctionNodeDescriptor } from './descriptor' diff --git a/frontend/src/components/nodes/RenderingNode.tsx b/frontend/src/components/nodes/render/RenderingNode.tsx similarity index 58% rename from frontend/src/components/nodes/RenderingNode.tsx rename to frontend/src/components/nodes/render/RenderingNode.tsx index 469f31f..14a6f95 100644 --- a/frontend/src/components/nodes/RenderingNode.tsx +++ b/frontend/src/components/nodes/render/RenderingNode.tsx @@ -7,37 +7,53 @@ import { AbstractNodeProps, createAbstractNodeComponent, useAbstractNode, -} from '../../lib/abstractNode' -import { getConfigContent, getConfigType, getConfigTypeId } from '../../lib/configTypes' -import { useResizeHeight } from '../../hooks/useResizeHeight' -import { plantumlLanguage } from '../../lib/plantumlLanguage' -import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '../ui/empty' +} from '@/lib/abstractNode' +import { getConfigContent, getConfigType, getConfigTypeId } from '@/lib/configTypes' +import { getSourceRenderingLogic } from '@/lib/sourceRenderingLogic' +import { useSyncConnectionStatus } from '@/lib/nodeLifecycle' +import { useResizeHeight } from '@/hooks/useResizeHeight' +import { plantumlLanguage } from '@/lib/plantumlLanguage' +import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '@/components/ui/empty' import { BaseNode, BaseNodeContent, BaseNodeFooter, BaseNodeHeaderRow, -} from '../base/BaseNode' -import { getDefaultDataForType, getNextNodeId } from '../../lib/flowUtils' -import FlowContext from '../../lib/flowContext' -import { getDefaultStyle, getNodeType } from '../../lib/nodeRegistry' -import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' -import { NodeHeaderTitle } from '../base/NodeHeaderTitle' -import { NodeMenubar } from '../base/NodeMenubar' -import { NodeStatusIndicator } from '../base/NodeStatusIndicator' -import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '../ui/menubar' -import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy } from 'lucide-react' -import { InputHandle } from '../base/NodeHandles' +} from '@/components/base/BaseNode' +import { getDefaultDataForType, getNextNodeId } from '@/lib/flowUtils' +import FlowContext from '@/lib/flowContext' +import { getDefaultStyle, getNodeType } from '@/lib/nodeRegistry' +import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators' +import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle' +import { NodeMenubar } from '@/components/base/NodeMenubar' +import { NodeStatusIndicator } from '@/components/base/NodeStatusIndicator' +import { MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '@/components/ui/menubar' +import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy, Play, ChevronDown } from 'lucide-react' +import { InputHandle } from '@/components/base/NodeHandles' import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch' -import { Input } from '../ui/input' -import { Button } from '../ui/button' -import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group' -import { useTheme } from '../../lib/themeContext' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +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' export type RenderingNodeData = { viewportWidth?: 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 @@ -50,10 +66,6 @@ type ViewMode = 'preview' | 'raw' function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const flowContext = useContext(FlowContext) 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 [renderedContent, setRenderedContent] = useState(null) const [resolvedContent, setResolvedContent] = useState(null) @@ -65,6 +77,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | null>(null) + const lastManualRunTriggerRef = useRef(0) const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode(id, data ?? {}) const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT @@ -72,6 +85,9 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const incomingIds = sourceIds const srcId = incomingIds.length > 0 ? incomingIds[0] : null 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 agentOutputMarkdown = isAgentSource ? ((srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? '') : '' const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record | 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 | undefined) : isAgentSource ? agentOutputMarkdown : '' const srcData = srcNode?.data ?? {} - useEffect(() => { - if (!addConnectionPathError || !removeConnectionPathError) return - if (error != null) { - addConnectionPathError(id) - return () => removeConnectionPathError(id) - } - removeConnectionPathError(id) - }, [id, error, addConnectionPathError, removeConnectionPathError]) + const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? [] /** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */ const connectedNodeIds = useMemo(() => { @@ -197,385 +206,153 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { [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 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) { setRenderedContent(null) setResolvedContent(null) setError(null) setLoading(false) - endConnectionPathUpdate?.(id) 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 const thisRunId = runIdRef.current + const signatureForThisRun = sourceSignature + const isManualMode = effectiveUpdateMode === 'manual' let cancelled = false const run = async () => { loadingStartedAtRef.current = Date.now() setLoading(true) - startConnectionPathUpdate?.(id) setError(null) try { - if (srcNode?.type === 'agent') { - const md = (srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? '' - setResolvedContent(md) - const markdownType = getConfigType('markdown') - const html = await markdownType.render(md) - if (cancelled || thisRunId !== runIdRef.current) return - setRenderedContent(html) - setError(null) - setLoading(false) - endConnectionPathUpdate?.(id) - return + const context = { + nodes, + edges, + sourceNodeId: srcId, + renderNodeId: id, + viewportWidth, + viewportHeight, } - const configIdsUsed = new Set() - - const isReachable = (startId: string, targetId: string) => { - const q: string[] = [startId] - const seen = new Set([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 + const { resolved, outputTypeId } = await logic.getResolvedContent(context) + if (cancelled || thisRunId !== runIdRef.current) return + setResolvedContent(resolved) + const typeRenderer = getConfigType(outputTypeId) + const renderOptions = outputTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined + const htmlOrSvg = await typeRenderer.render(resolved, renderOptions) + if (cancelled || thisRunId !== runIdRef.current) return + setRenderedContent(htmlOrSvg) + setError(null) + if (isManualMode) { + updateData({ lastRunSourceSignature: signatureForThisRun }) } - - 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()) => { - 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 | 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 | 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 - 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[] | 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 = {} - 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() - 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 => - 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 - const functionConnectedFunctionIds = Object.create(null) as Record - 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(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) : 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() - 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((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).then === 'function') { - (result as Promise).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; 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) { if (!cancelled && thisRunId === runIdRef.current) { setRenderedContent(null) 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 () => { cancelled = true - clearTimeout(debounceTimer) if (minLoadingTimeoutRef.current != null) { clearTimeout(minLoadingTimeoutRef.current) 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, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, isAgentSource]) + }, [id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceContent, sourceSignature, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, nodes, edges, updateData]) + const dimensions = 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} right={ incomingIds.length > 0 ? ( - { - 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" - > - { - e.stopPropagation() - setViewMode('preview') +
+ + + + + + + e.stopPropagation()}> + + When to re-render + + checked && updateData({ updateMode: 'auto' })} + className="flex flex-col items-start gap-0.5 py-2" + > + Auto + + Re-renders when upstream content changes + + + checked && updateData({ updateMode: 'manual' })} + className="flex flex-col items-start gap-0.5 py-2" + > + Manual + + Re-renders only when you click Run + + + + + + { + 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 - - { - e.stopPropagation() - setViewMode('raw') - }} - > - Raw - - + { + e.stopPropagation() + setViewMode('preview') + }} + > + Preview + + { + e.stopPropagation() + setViewMode('raw') + }} + > + Raw + + +
) : undefined } /> diff --git a/frontend/src/components/nodes/render/descriptor.tsx b/frontend/src/components/nodes/render/descriptor.tsx new file mode 100644 index 0000000..1fe19de --- /dev/null +++ b/frontend/src/components/nodes/render/descriptor.tsx @@ -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', ) + .connectionLabel('rendering') + .withFullscreen() + .build() +} diff --git a/frontend/src/components/nodes/render/index.ts b/frontend/src/components/nodes/render/index.ts new file mode 100644 index 0000000..c1cbc1f --- /dev/null +++ b/frontend/src/components/nodes/render/index.ts @@ -0,0 +1,2 @@ +export { default as RenderingNode, type RenderingNodeData } from './RenderingNode' +export { getRenderNodeDescriptor } from './descriptor' diff --git a/frontend/src/components/nodes/VariableNode.tsx b/frontend/src/components/nodes/variable/VariableNode.tsx similarity index 91% rename from frontend/src/components/nodes/VariableNode.tsx rename to frontend/src/components/nodes/variable/VariableNode.tsx index ff6d9d0..6677718 100644 --- a/frontend/src/components/nodes/VariableNode.tsx +++ b/frontend/src/components/nodes/variable/VariableNode.tsx @@ -3,20 +3,20 @@ import { AbstractNodeProps, createAbstractNodeComponent, useAbstractNode, -} from '../../lib/abstractNode' +} from '@/lib/abstractNode' import { BaseNode, BaseNodeContent, BaseNodeFooter, BaseNodeHeaderRow, -} from '../base/BaseNode' -import { NodeMenubar } from '../base/NodeMenubar' -import { Input } from '../ui/input' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' -import { Switch } from '../ui/switch' -import { NodeFooterEdgeIndicators } from '../base/NodeFooterEdgeIndicators' -import { NodeHeaderTitle } from '../base/NodeHeaderTitle' -import { OutputHandle } from '../base/NodeHandles' +} from '@/components/base/BaseNode' +import { NodeMenubar } from '@/components/base/NodeMenubar' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators' +import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle' +import { OutputHandle } from '@/components/base/NodeHandles' import { Variable } from 'lucide-react' export type ValueType = 'string' | 'number' | 'boolean' diff --git a/frontend/src/components/nodes/variable/descriptor.tsx b/frontend/src/components/nodes/variable/descriptor.tsx new file mode 100644 index 0000000..3a02824 --- /dev/null +++ b/frontend/src/components/nodes/variable/descriptor.tsx @@ -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', ) + .build() +} diff --git a/frontend/src/components/nodes/variable/index.ts b/frontend/src/components/nodes/variable/index.ts new file mode 100644 index 0000000..7f25104 --- /dev/null +++ b/frontend/src/components/nodes/variable/index.ts @@ -0,0 +1,2 @@ +export { default as VariableNode, type VariableNodeData, type ValueType } from './VariableNode' +export { getVariableNodeDescriptor } from './descriptor' diff --git a/frontend/src/lib/abstractNode.ts b/frontend/src/lib/abstractNode.ts index 894ea69..3c7eca7 100644 --- a/frontend/src/lib/abstractNode.ts +++ b/frontend/src/lib/abstractNode.ts @@ -3,9 +3,14 @@ * * - **AbstractNodeProps** — Typed props (id, data, width?, height?, selected?) for your node. * - **useAbstractNode(id, data)** — Flow context plus helpers: nodes, edges, setNodes, setEdges, - * updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. + * updateData(partial), incomingEdges, outgoingEdges, sourceIds, targetIds. Calling updateData() + * also reports this node as a trigger for connection path (lifecycle "trigger"). * - **createAbstractNodeComponent(displayName, Component)** — Wraps with memo + nodePropsAreEqual. * + * **Node lifecycle / connection status:** Nodes that can be updating, paused, or in error should + * call useSyncConnectionStatus(id, { updating, error, paused }) from @/lib/nodeLifecycle so edge + * colors and path animation stay correct. See nodeLifecycle.ts for the full contract. + * * Example: define NodeData type, Props = AbstractNodeProps, use useAbstractNode in the * component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent). */ diff --git a/frontend/src/lib/connectionStatus.ts b/frontend/src/lib/connectionStatus.ts index e983ede..1517fc9 100644 --- a/frontend/src/lib/connectionStatus.ts +++ b/frontend/src/lib/connectionStatus.ts @@ -1,7 +1,10 @@ /** * Connection status: visual state of an edge (color/class). * 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' diff --git a/frontend/src/lib/nodeLifecycle.ts b/frontend/src/lib/nodeLifecycle.ts new file mode 100644 index 0000000..f44eb56 --- /dev/null +++ b/frontend/src/lib/nodeLifecycle.ts @@ -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]) +} diff --git a/frontend/src/lib/nodeRegistry.ts b/frontend/src/lib/nodeRegistry.ts index ce1569c..432ab76 100644 --- a/frontend/src/lib/nodeRegistry.ts +++ b/frontend/src/lib/nodeRegistry.ts @@ -1,11 +1,12 @@ /** - * Extensible node type registry. Register node types with registerNodeType(); - * built-in types are registered in registerBuiltinNodes.ts. + * Extensible node type registry. Register node types with registerNodeType() or use NodeTypeBuilder. + * Built-in types are registered in registerBuiltinNodes.ts. * Use getRegisteredNodeTypes() / getNodeType(id) for defaults, validation, and UI. */ import type React from 'react' -import type { Node } from '@xyflow/react' +import { registerSourceRenderingLogic } from './sourceRenderingLogic' +import type { SourceRenderingLogic } from './sourceRenderingLogic' /** Node classification for UI: Psyche, Pneuma, Physis, Archon (AI Agent). */ export type NodeClassification = 'psyche' | 'pneuma' | 'physis' | 'archon' @@ -47,6 +48,8 @@ export type NodeTypeDescriptor = { connectionLabel?: string /** When true, double-clicking the node header opens a fullscreen dialog for this node. */ supportsFullscreen?: boolean + /** When set, this type can feed the Renderer; registration will also register source rendering logic. */ + sourceRenderingLogic?: SourceRenderingLogic } const registry = new Map() @@ -55,6 +58,9 @@ export function registerNodeType(descriptor: NodeTypeDescriptor): void { if (registry.has(descriptor.id)) { console.warn(`[nodeRegistry] Overwriting existing node type: ${descriptor.id}`) } + if (descriptor.sourceRenderingLogic) { + registerSourceRenderingLogic(descriptor.id, descriptor.sourceRenderingLogic) + } registry.set(descriptor.id, descriptor) } diff --git a/frontend/src/lib/nodeTypeBuilder.ts b/frontend/src/lib/nodeTypeBuilder.ts new file mode 100644 index 0000000..a259eef --- /dev/null +++ b/frontend/src/lib/nodeTypeBuilder.ts @@ -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', ) + * .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 + private readonly defaultStyle: { width: number; height: number } + private readonly defaultData: Record + private partial: OptionalDescriptor & { + idPrefix?: string + hasInput?: boolean + hasOutput?: boolean + help?: NodeHelpEntry + menuLabel?: string + menuIcon?: React.ReactNode + } = {} + + constructor( + id: string, + component: React.ComponentType, + defaultStyle: { width: number; height: number }, + defaultData: Record + ) { + 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): this { + this.partial.getDefaultData = fn + return this + } + + getResetData(fn: (nodeId?: string) => Record): 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, + defaultStyle: { width: number; height: number }, + defaultData: Record +): NodeTypeBuilder { + return new NodeTypeBuilder(id, component, defaultStyle, defaultData) +} diff --git a/frontend/src/lib/nodeTypes.ts b/frontend/src/lib/nodeTypes.ts index 1223e90..b49a2d2 100644 --- a/frontend/src/lib/nodeTypes.ts +++ b/frontend/src/lib/nodeTypes.ts @@ -3,12 +3,12 @@ */ import type { Node, Edge } from '@xyflow/react' -import type { ConfigNodeData } from '@/components/nodes/ConfigNode' -import type { RenderingNodeData } from '@/components/nodes/RenderingNode' -import type { VariableNodeData } from '@/components/nodes/VariableNode' -import type { FunctionNodeData } from '@/components/nodes/FunctionNode' -import type { DataNodeData } from '@/components/nodes/DataNode' -import type { AgentNodeData } from '@/components/nodes/AgentNode' +import type { ConfigNodeData } from '@/components/nodes/config' +import type { RenderingNodeData } from '@/components/nodes/render' +import type { VariableNodeData } from '@/components/nodes/variable' +import type { FunctionNodeData } from '@/components/nodes/function' +import type { DataNodeData } from '@/components/nodes/data' +import type { AgentNodeData } from '@/components/nodes/agent' export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData | DataNodeData | AgentNodeData export type AppNode = Node diff --git a/frontend/src/lib/registerBuiltinNodes.tsx b/frontend/src/lib/registerBuiltinNodes.tsx index 48a8f8d..c682b34 100644 --- a/frontend/src/lib/registerBuiltinNodes.tsx +++ b/frontend/src/lib/registerBuiltinNodes.tsx @@ -1,137 +1,22 @@ /** - * Registers built-in node types (config, render, variable, function). - * Import this once at app startup (e.g. in main.tsx) so the registry is populated. + * Registers built-in node types using the node type builder. + * Each node type is defined in its own folder under components/nodes// 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 { NODE_HELP } from './nodeHelp' -import ConfigNode from '../components/nodes/ConfigNode' -import RenderingNode from '../components/nodes/RenderingNode' -import VariableNode from '../components/nodes/VariableNode' -import FunctionNode from '../components/nodes/FunctionNode' -import DataNode from '../components/nodes/DataNode' -import AgentNode from '../components/nodes/AgentNode' - -const ICON_CLASS = 'mr-2 h-4 w-4' +import { getConfigNodeDescriptor } from '@/components/nodes/config' +import { getAgentNodeDescriptor } from '@/components/nodes/agent' +import { getRenderNodeDescriptor } from '@/components/nodes/render' +import { getVariableNodeDescriptor } from '@/components/nodes/variable' +import { getFunctionNodeDescriptor } from '@/components/nodes/function' +import { getDataNodeDescriptor } from '@/components/nodes/data' export function registerBuiltinNodes(): void { - registerNodeType({ - id: 'config', - component: ConfigNode, - defaultStyle: { width: 320, height: 320 }, - defaultData: { configType: 'plantuml', content: '@startuml\n\n@enduml\n', title: '' }, - idPrefix: 'cfg_', - hasInput: true, - hasOutput: true, - classification: 'psyche', - allowedSourceTypes: ['config', 'variable', 'function', 'data'], - allowedTargetTypes: ['config', 'render', 'agent'], - help: NODE_HELP.config, - menuLabel: 'Config', - menuIcon: , - 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: , - 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: , - 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: , - }) - - 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: , - 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: , - getResetData: () => ({ body: '' }), - connectionLabel: 'adding input', - supportsFullscreen: true, - }) + registerNodeType(getConfigNodeDescriptor()) + registerNodeType(getAgentNodeDescriptor()) + registerNodeType(getRenderNodeDescriptor()) + registerNodeType(getVariableNodeDescriptor()) + registerNodeType(getFunctionNodeDescriptor()) + registerNodeType(getDataNodeDescriptor()) } diff --git a/frontend/src/lib/sourceRenderingLogic.ts b/frontend/src/lib/sourceRenderingLogic.ts new file mode 100644 index 0000000..c45af4e --- /dev/null +++ b/frontend/src/lib/sourceRenderingLogic.ts @@ -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 +} + +const registry = new Map() + +export function registerSourceRenderingLogic(nodeType: string, logic: SourceRenderingLogic): void { + registry.set(nodeType, logic) +} + +export function getSourceRenderingLogic(nodeType: string): SourceRenderingLogic | null { + return registry.get(nodeType) ?? null +}