Files
zui/frontend/src/components/nodes/render/useRenderingNodeState.ts
2026-03-12 16:24:24 +01:00

683 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 hooks 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<string, unknown>
}
export function useRenderingNodeState(
id: string,
data: RenderingNodeData | undefined
): RenderingNodeState {
const {
nodes,
edges,
setNodes,
setEdges,
sourceIds: incomingIds,
updateData,
} = useAbstractNode<RenderingNodeData>(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<string, unknown> | undefined)
: isAgentSource
? 'markdown'
: 'plantuml'
const configType = getConfigType(configTypeId)
const outputType = configType.outputType
const sourceContent =
srcNode?.type === 'config'
? getConfigContent((srcNode?.data ?? undefined) as Record<string, unknown> | undefined)
: isAgentSource
? agentOutputMarkdown
: ''
const connectedNodeIds = useMemo(() => {
const out = new Set<string>()
const isReachable = (startId: string, targetId: string) => {
const q: string[] = [startId]
const seen = new Set<string>([startId])
while (q.length) {
const cur = q.shift()!
if (cur === targetId) return true
for (const e of edges as { source: string; target: string }[]) {
if (e.source === cur && !seen.has(e.target)) {
seen.add(e.target)
q.push(e.target)
}
}
}
return false
}
const resolveRef = (name: string) => {
const refName = name.replace(/\.(puml|html)$/, '').trim()
return (nodes as { id: string; data?: { title?: string } }[]).find(
(n) => n.id === refName || n.data?.title === refName
)?.id ?? refName
}
const getTemplateRefs = (content: string): string[] => {
const refs: string[] = []
const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/)
if (extendMatch) refs.push(extendMatch[1].trim())
const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g
let m
while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim())
const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g
while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim())
return refs
}
const addConfigRefs = (nodeId: string, visited: Set<string>) => {
if (visited.has(nodeId)) return
const node = (nodes as { id: string; type?: string; data?: unknown }[]).find(
(n) => n.id === nodeId && n.type === 'config'
)
if (!node) return
visited.add(nodeId)
out.add(nodeId)
const content = getConfigContent((node.data ?? undefined) as Record<string, unknown> | undefined)
for (const ref of getTemplateRefs(content)) {
const refId = resolveRef(ref)
if (
refId &&
(nodes as { id: string; type?: string }[]).some((n) => n.id === refId && n.type === 'config') &&
isReachable(refId, id)
) {
addConfigRefs(refId, visited)
}
}
}
const configVisited = new Set<string>()
for (const nid of incomingIds) {
const node = (nodes as { id: string; type?: string }[]).find((n) => n.id === nid)
if (node?.type === 'config') addConfigRefs(nid, configVisited)
else out.add(nid)
}
for (const e of edges as { source: string; target: string }[]) {
if (out.has(e.target)) out.add(e.source)
}
return out
}, [nodes, edges, id, incomingIds])
const configSignature = useMemo(
() =>
(nodes as { id: string; type?: string; data?: unknown }[])
.filter((n) => n.type === 'config' && connectedNodeIds.has(n.id))
.map((n) => `${n.id}:${(n.data as { title?: string })?.title ?? ''}:${getConfigContent(n.data as Record<string, unknown>)}`)
.sort()
.join('|'),
[nodes, connectedNodeIds]
)
const edgesSignature = useMemo(
() =>
(edges as { source: string; target: string }[])
.filter(
(e) =>
connectedNodeIds.has(e.source) &&
(connectedNodeIds.has(e.target) || e.target === id)
)
.map((e) => `${e.source}->${e.target}`)
.sort()
.join('|'),
[edges, connectedNodeIds, id]
)
const variablesSignature = useMemo(
() =>
(nodes as { id: string; type?: string; data?: { value?: unknown } }[])
.filter((n) => n.type === 'variable' && connectedNodeIds.has(n.id))
.map((n) => `${n.id}:${n.data?.value}`)
.sort()
.join('|'),
[nodes, connectedNodeIds]
)
const functionsSignature = useMemo(
() =>
(nodes as { id: string; type?: string; data?: { body?: string } }[])
.filter((n) => n.type === 'function' && connectedNodeIds.has(n.id))
.map((n) => `${n.id}:${n.data?.body ?? ''}`)
.sort()
.join('|'),
[nodes, connectedNodeIds]
)
const dataSignature = useMemo(
() =>
(nodes as { id: string; type?: string; data?: { rows?: unknown[]; hiddenColumns?: unknown[] } }[])
.filter((n) => n.type === 'data' && connectedNodeIds.has(n.id))
.map((n) => `${n.id}:${JSON.stringify(n.data?.rows ?? [])}:${JSON.stringify(n.data?.hiddenColumns ?? [])}`)
.sort()
.join('|'),
[nodes, connectedNodeIds]
)
const sourceSignature = useMemo(
() =>
JSON.stringify({
configSignature,
edgesSignature,
variablesSignature,
functionsSignature,
dataSignature,
}),
[configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature]
)
const lastRunSourceSignature = data?.lastRunSourceSignature
const [renderedContent, setRenderedContent] = useState<string | null>(
() => (data?.cachedRenderedContent as string | undefined) ?? null
)
const [resolvedContent, setResolvedContent] = useState<string | null>(
() => (data?.cachedResolvedContent as string | undefined) ?? null
)
const [error, setError] = useState<null | { kind: string; message: string }>(null)
const [loading, setLoading] = useState(false)
const [streamingMarkdown, setStreamingMarkdown] = useState<string | null>(null)
const [streamingPreviewHtml, setStreamingPreviewHtml] = useState<string>('')
const [reasoningContent, setReasoningContent] = useState<string>(
() => (data?.cachedReasoningContent as string | undefined) ?? ''
)
const [reasoningHtml, setReasoningHtml] = useState<string>('')
const [retryCount, setRetryCountState] = useState(0)
const runIdRef = useRef(0)
const loadingStartedAtRef = useRef<number | null>(null)
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<string>)(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<string>)(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() && /<svg[\s>]/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<string, unknown>) ?? {}, [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,
}
}