/** * All logic for the Rendering node: source resolution, signatures, run effect, * streaming, and derived display state. The RenderingNode UI is kept dumb and * only consumes this hook’s return value. */ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' import { useAbstractNode } from '@/lib/graph/abstractNode' import { getConfigContent, getConfigType, getConfigTypeId } from '@/lib/graph/configTypes' import type { ConfigTypeId, OutputMenuDescriptor } from '@/lib/graph/configTypes' import FlowContext from '@/lib/graph/flowContext' import { getSourceRenderingLogic, type SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic' import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle' import { usePlatform } from '@/app/kosmos/KosmosContext' import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils' import { getDefaultStyle } from '@/lib/graph/nodeRegistry' import { parseThinkSections, processSvgDisplay, stripTemplateSyntax } from '@/lib/graph/renderingUtils' export type RenderingNodeData = { viewportWidth?: number viewportHeight?: number updateMode?: 'auto' | 'manual' runTrigger?: number lastRunSourceSignature?: string cachedRenderedContent?: string cachedResolvedContent?: string cachedReasoningContent?: string } const DEFAULT_VIEWPORT_WIDTH = 1200 const DEFAULT_VIEWPORT_HEIGHT = 800 const RENDER_DEBOUNCE_MS = 250 export type RenderingNodeState = { // Connection & run control incomingIds: string[] effectiveUpdateMode: 'auto' | 'manual' runTrigger: number hasPendingInputs: boolean loading: boolean error: null | { kind: string; message: string } incrementRunTrigger: () => void setUpdateMode: (mode: 'auto' | 'manual') => void setRetryCount: (fn: (c: number) => number) => void // Content (resolved = before render, rendered = after render) renderedContent: string | null resolvedContent: string | null streamingMarkdown: string | null streamingPreviewHtml: string reasoningContent: string reasoningHtml: string // Display type (no config/agent types exposed) outputType: 'html' | 'image' outputLabel: string rawLanguage: ConfigTypeId isSvgOutput: boolean // Derived for UI renderedThinkSplit: { main: string; think: string } streamingThinkSplit: { main: string; think: string } rawDisplayContent: string displayContent: string | null // Viewport viewportWidth: number viewportHeight: number // Callbacks downloadSvg: () => void downloadPng: () => void copySvg: () => void copyPng: () => void /** Output menu descriptor from the source (e.g. Export submenu for image config types). Null when no source or source provides none. */ outputMenuDescriptor: OutputMenuDescriptor | null // Empty state (source-type-specific action stays in hook) emptyStateAction: null | { label: string; onClick: () => void } // Optional custom error UI from source node data sourceData: Record } export function useRenderingNodeState( id: string, data: RenderingNodeData | undefined ): RenderingNodeState { const { nodes, edges, setNodes, setEdges, sourceIds: incomingIds, updateData, } = useAbstractNode(id, data ?? {}) const nodesEdgesRef = useRef({ nodes, edges }) nodesEdgesRef.current = { nodes, edges } const { aiConnection } = usePlatform() const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT const srcId = incomingIds.length > 0 ? incomingIds[0] : null const srcNode = useMemo( () => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null), [nodes, srcId] ) const sourceLogic = useMemo( () => (srcNode?.type ? getSourceRenderingLogic(srcNode.type as string) : null), [srcNode?.type] ) const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual' const runTrigger = data?.runTrigger ?? 0 const isAgentSource = srcNode?.type === 'agent' const agentOutputMarkdown = isAgentSource ? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '') : '' const configTypeId: ConfigTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode?.data ?? undefined) as Record | undefined) : isAgentSource ? 'markdown' : 'plantuml' const configType = getConfigType(configTypeId) const outputType = configType.outputType const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode?.data ?? undefined) as Record | undefined) : isAgentSource ? agentOutputMarkdown : '' const connectedNodeIds = useMemo(() => { const out = 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 as { source: string; target: string }[]) { if (e.source === cur && !seen.has(e.target)) { seen.add(e.target) q.push(e.target) } } } return false } const resolveRef = (name: string) => { const refName = name.replace(/\.(puml|html)$/, '').trim() return (nodes as { id: string; data?: { title?: string } }[]).find( (n) => n.id === refName || n.data?.title === refName )?.id ?? refName } const getTemplateRefs = (content: string): string[] => { const refs: string[] = [] const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/) if (extendMatch) refs.push(extendMatch[1].trim()) const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g let m while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim()) const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim()) return refs } const addConfigRefs = (nodeId: string, visited: Set) => { if (visited.has(nodeId)) return const node = (nodes as { id: string; type?: string; data?: unknown }[]).find( (n) => n.id === nodeId && n.type === 'config' ) if (!node) return visited.add(nodeId) out.add(nodeId) const content = getConfigContent((node.data ?? undefined) as Record | undefined) for (const ref of getTemplateRefs(content)) { const refId = resolveRef(ref) if ( refId && (nodes as { id: string; type?: string }[]).some((n) => n.id === refId && n.type === 'config') && isReachable(refId, id) ) { addConfigRefs(refId, visited) } } } const configVisited = new Set() for (const nid of incomingIds) { const node = (nodes as { id: string; type?: string }[]).find((n) => n.id === nid) if (node?.type === 'config') addConfigRefs(nid, configVisited) else out.add(nid) } for (const e of edges as { source: string; target: string }[]) { if (out.has(e.target)) out.add(e.source) } return out }, [nodes, edges, id, incomingIds]) const configSignature = useMemo( () => (nodes as { id: string; type?: string; data?: unknown }[]) .filter((n) => n.type === 'config' && connectedNodeIds.has(n.id)) .map((n) => `${n.id}:${(n.data as { title?: string })?.title ?? ''}:${getConfigContent(n.data as Record)}`) .sort() .join('|'), [nodes, connectedNodeIds] ) const edgesSignature = useMemo( () => (edges as { source: string; target: string }[]) .filter( (e) => connectedNodeIds.has(e.source) && (connectedNodeIds.has(e.target) || e.target === id) ) .map((e) => `${e.source}->${e.target}`) .sort() .join('|'), [edges, connectedNodeIds, id] ) const variablesSignature = useMemo( () => (nodes as { id: string; type?: string; data?: { value?: unknown } }[]) .filter((n) => n.type === 'variable' && connectedNodeIds.has(n.id)) .map((n) => `${n.id}:${n.data?.value}`) .sort() .join('|'), [nodes, connectedNodeIds] ) const functionsSignature = useMemo( () => (nodes as { id: string; type?: string; data?: { body?: string } }[]) .filter((n) => n.type === 'function' && connectedNodeIds.has(n.id)) .map((n) => `${n.id}:${n.data?.body ?? ''}`) .sort() .join('|'), [nodes, connectedNodeIds] ) const dataSignature = useMemo( () => (nodes as { id: string; type?: string; data?: { rows?: unknown[]; hiddenColumns?: unknown[] } }[]) .filter((n) => n.type === 'data' && connectedNodeIds.has(n.id)) .map((n) => `${n.id}:${JSON.stringify(n.data?.rows ?? [])}:${JSON.stringify(n.data?.hiddenColumns ?? [])}`) .sort() .join('|'), [nodes, connectedNodeIds] ) const sourceSignature = useMemo( () => JSON.stringify({ configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, }), [configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature] ) const lastRunSourceSignature = data?.lastRunSourceSignature const [renderedContent, setRenderedContent] = useState( () => (data?.cachedRenderedContent as string | undefined) ?? null ) const [resolvedContent, setResolvedContent] = useState( () => (data?.cachedResolvedContent as string | undefined) ?? null ) const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [streamingMarkdown, setStreamingMarkdown] = useState(null) const [streamingPreviewHtml, setStreamingPreviewHtml] = useState('') const [reasoningContent, setReasoningContent] = useState( () => (data?.cachedReasoningContent as string | undefined) ?? '' ) const [reasoningHtml, setReasoningHtml] = useState('') const [retryCount, setRetryCountState] = useState(0) const runIdRef = useRef(0) const loadingStartedAtRef = useRef(null) const minLoadingTimeoutRef = useRef | null>(null) const lastManualRunTriggerRef = useRef(0) const manualRunTriggerSyncedRef = useRef(false) const flowContext = useContext(FlowContext) const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? [] const hasPendingInputs = effectiveUpdateMode === 'manual' && !loading && triggerNodeIds.length > 0 && incomingIds.length > 0 && sourceSignature !== lastRunSourceSignature useSyncConnectionStatus(id, { updating: loading, error: error != null, paused: hasPendingInputs }) useEffect(() => { if (incomingIds.length === 0) { setRenderedContent(null) setResolvedContent(null) setError(null) setLoading(false) return } if (!srcId || !srcNode) { setRenderedContent(null) setResolvedContent(null) setError(null) setLoading(false) return } const logic = getSourceRenderingLogic(srcNode.type as string) if (!logic) { setRenderedContent(null) setResolvedContent(null) setError({ kind: 'render', message: `Unsupported source type: ${srcNode.type}` }) setLoading(false) return } if (effectiveUpdateMode === 'manual') { if (!manualRunTriggerSyncedRef.current) { lastManualRunTriggerRef.current = runTrigger manualRunTriggerSyncedRef.current = true return } if (runTrigger === 0) { const hasCachedOutput = Boolean( (data?.cachedRenderedContent ?? data?.cachedResolvedContent) as string | undefined ) if (!hasCachedOutput) { setRenderedContent(null) setResolvedContent(null) setError({ kind: 'no-content', message: 'Click Run to render.' }) setLoading(false) } return } if (runTrigger === lastManualRunTriggerRef.current) return 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) setError(null) setRenderedContent(null) setResolvedContent(null) setStreamingMarkdown(null) setReasoningContent('') updateData({ cachedRenderedContent: undefined, cachedResolvedContent: undefined, cachedReasoningContent: undefined, }) try { const { nodes: ctxNodes, edges: ctxEdges } = nodesEdgesRef.current const context = { nodes: ctxNodes, edges: ctxEdges, sourceNodeId: srcId, renderNodeId: id, viewportWidth, viewportHeight, setNodes: setNodes ?? undefined, aiConnection, ...(isAgentSource && { onStreamingStart: () => setStreamingMarkdown(''), onStreamingChunk: (chunk: string) => setStreamingMarkdown((prev) => (prev ?? '') + chunk), }), } as SourceRenderingLogicContext const { resolved, outputTypeId, reasoning } = await logic.getResolvedContent(context) if (thisRunId !== runIdRef.current) return setResolvedContent(resolved) setReasoningContent(reasoning ?? '') const typeRenderer = getConfigType(outputTypeId) const renderOptions = outputTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined const htmlOrSvg = await typeRenderer.render(resolved, renderOptions) if (thisRunId !== runIdRef.current) return setRenderedContent(htmlOrSvg) setError(null) updateData({ cachedRenderedContent: htmlOrSvg, cachedResolvedContent: resolved, cachedReasoningContent: reasoning ?? '', ...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}), }) } catch (err: unknown) { if (!cancelled && thisRunId === runIdRef.current) { setRenderedContent(null) setReasoningContent('') setError({ kind: 'render', message: (err as { message?: string })?.message ?? 'Render error', }) } } finally { setStreamingMarkdown(null) if (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 (thisRunId === runIdRef.current) setLoading(false) }, remaining) } else { setLoading(false) } } } } 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 setStreamingMarkdown(null) if (minLoadingTimeoutRef.current != null) { clearTimeout(minLoadingTimeoutRef.current) minLoadingTimeoutRef.current = null } } }, [ id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceContent, sourceSignature, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, updateData, setNodes, aiConnection, isAgentSource, incomingIds.length, ]) useEffect(() => { if (streamingMarkdown === null) { setStreamingPreviewHtml('') return } let cancelled = false import('marked') .then(async ({ marked }) => { if (cancelled) return const parsed = typeof marked.parse === 'function' ? await (marked.parse as (s: string) => Promise)(streamingMarkdown) : (marked as (s: string) => string)(streamingMarkdown) const str = typeof parsed === 'string' ? parsed : String(parsed) if (!cancelled) setStreamingPreviewHtml(str) }) .catch(() => { if (!cancelled) setStreamingPreviewHtml(streamingMarkdown) }) return () => { cancelled = true } }, [streamingMarkdown]) useEffect(() => { if (!reasoningContent) { setReasoningHtml('') return } let cancelled = false import('marked') .then(async ({ marked }) => { if (cancelled) return const parsed = typeof marked.parse === 'function' ? await (marked.parse as (s: string) => Promise)(reasoningContent) : (marked as (s: string) => string)(reasoningContent) const str = typeof parsed === 'string' ? parsed : String(parsed) if (!cancelled) setReasoningHtml(str) }) .catch(() => { if (!cancelled) setReasoningHtml(reasoningContent) }) return () => { cancelled = true } }, [reasoningContent]) const isSvgOutput = Boolean( renderedContent?.trim() && /]/i.test(renderedContent.trim()) ) const outputMenuDescriptor = useMemo( () => sourceLogic?.getOutputMenuDescriptor?.(configTypeId) ?? null, [sourceLogic, configTypeId] ) const renderedThinkSplit = useMemo(() => { if (outputType === 'image' || !renderedContent) return { main: '', think: '' } return parseThinkSections(renderedContent) }, [renderedContent, outputType]) const streamingThinkSplit = useMemo(() => { if (streamingMarkdown === null) return { main: '', think: '' } const src = streamingPreviewHtml || streamingMarkdown return parseThinkSections(src) }, [streamingMarkdown, streamingPreviewHtml]) const rawDisplayContent = useMemo(() => { const src = resolvedContent != null ? resolvedContent : streamingMarkdown ?? '' return stripTemplateSyntax(src) }, [resolvedContent, streamingMarkdown]) const displayContent = useMemo(() => { if (!renderedContent || !isSvgOutput) return renderedContent return processSvgDisplay(renderedContent) }, [renderedContent, isSvgOutput]) const downloadSvg = useCallback(() => { if (!renderedContent || !isSvgOutput) return const blob = new Blob([renderedContent], { type: 'image/svg+xml' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${id}.svg` a.click() URL.revokeObjectURL(url) }, [id, renderedContent, isSvgOutput]) const downloadPng = useCallback(() => { if (!renderedContent || !isSvgOutput) return const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent) const img = new Image() img.onload = () => { const canvas = document.createElement('canvas') canvas.width = img.naturalWidth canvas.height = img.naturalHeight const ctx = canvas.getContext('2d') if (!ctx) return ctx.drawImage(img, 0, 0) const pngUrl = canvas.toDataURL('image/png') const a = document.createElement('a') a.href = pngUrl a.download = `${id}.png` a.click() } img.onerror = () => {} img.src = dataUrl }, [id, renderedContent, isSvgOutput]) const copyPng = useCallback(() => { if (!renderedContent || !isSvgOutput) return const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent) const img = new Image() img.onload = () => { const canvas = document.createElement('canvas') canvas.width = img.naturalWidth canvas.height = img.naturalHeight const ctx = canvas.getContext('2d') if (!ctx) return ctx.drawImage(img, 0, 0) canvas.toBlob((blob) => { if (blob) navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {}) }, 'image/png') } img.onerror = () => {} img.src = dataUrl }, [renderedContent, isSvgOutput]) const copySvg = useCallback(() => { if (!renderedContent || !isSvgOutput) return const blob = new Blob([renderedContent], { type: 'image/svg+xml' }) navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {}) }, [renderedContent, isSvgOutput]) const incrementRunTrigger = useCallback(() => { updateData({ runTrigger: (data?.runTrigger ?? 0) + 1 }) }, [data?.runTrigger, updateData]) const setUpdateMode = useCallback( (mode: 'auto' | 'manual') => { updateData({ updateMode: mode }) }, [updateData] ) const setRetryCount = useCallback((fn: (c: number) => number) => { setRetryCountState(fn) }, []) const emptyStateAction = useMemo(() => { if (incomingIds.length > 0) return null if (!setNodes || !setEdges) return null return { label: 'Create Config', onClick: () => { const nid = getNextNodeId( 'config', (nodes as { id: string }[]).map((n) => n.id) ) const thisNode = (nodes as { id: string; position?: { x: number; y: number } }[]).find( (n) => n.id === id ) const pos = thisNode?.position ?? { x: 0, y: 0 } const newPos = { x: pos.x - 220, y: pos.y } const newNode = { id: nid, type: 'config', position: newPos, data: getDefaultDataForType('config', nid), style: getDefaultStyle('config'), } setNodes((nds: unknown[]) => nds.concat(newNode) as unknown[]) setEdges((eds: unknown[]) => eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id }) as unknown[] ) }, } }, [id, incomingIds.length, nodes, setNodes, setEdges]) const sourceData = useMemo(() => (srcNode?.data as Record) ?? {}, [srcNode?.data]) return { incomingIds, effectiveUpdateMode, runTrigger, hasPendingInputs, loading, error, incrementRunTrigger, setRetryCount, renderedContent, resolvedContent, streamingMarkdown, streamingPreviewHtml, reasoningContent, reasoningHtml, outputType, outputLabel: configType.label, rawLanguage: configTypeId, isSvgOutput, renderedThinkSplit, streamingThinkSplit, rawDisplayContent, displayContent, viewportWidth, viewportHeight, downloadSvg, downloadPng, copySvg, copyPng, setUpdateMode, outputMenuDescriptor, emptyStateAction, sourceData, } }