Files
zui/frontend/src/components/nodes/render/useRenderingNodeState.ts
2026-03-14 07:52:34 +01:00

534 lines
20 KiB
TypeScript

/**
* runs the resolve → render pipeline, manages streaming/cache,
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
* pipeline interface.
*/
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { useAbstractNode } from '@/lib/graph/abstractNode'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
import {
getConfigContent,
getConfigType,
getConfigTypeId,
getSourceRenderingLogic,
parseThinkSections,
processSvgDisplay,
stripTemplateSyntax,
} from '@/lib/graph/rendering'
import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph/renderingSignatures'
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
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
/** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/error) stays in sync. */
export type RenderingNodeLifecycle = {
updating: boolean
error: boolean
}
export type RenderingNodeState = {
/** For NodeStatusIndicator and empty/error UI. Derived from loading, error, renderedContent. */
displayStatus: NodeDisplayStatus
/** Pass to useSyncConnectionStatus so edges show correct status (blue/yellow/red). */
lifecycle: RenderingNodeLifecycle
// 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
// 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>
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
sourceNodeType: string | null
}
export function useRenderingNodeState(
id: string,
data: RenderingNodeData | undefined
): RenderingNodeState {
const {
nodes: contextNodes,
edges: contextEdges,
setNodes,
setEdges,
sourceIds: incomingIds,
updateData,
} = useAbstractNode<RenderingNodeData>(id, data ?? {})
// Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data).
// Context only exposes a ref, so we wouldn't re-render when another node updates otherwise.
const storeNodes = useCanvasStore((s) => s.graph.nodes)
const storeEdges = useCanvasStore((s) => s.graph.edges)
const nodes = storeNodes.length > 0 ? storeNodes : contextNodes
const edges = storeEdges.length > 0 ? storeEdges : contextEdges
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 deferredNodes = useDeferredValue(nodes)
const deferredEdges = useDeferredValue(edges)
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 signatures = useMemo(
() => buildSourceSignatures(deferredNodes as NodeLike[], deferredEdges as EdgeLike[], id, incomingIds),
[deferredNodes, deferredEdges, id, incomingIds]
)
const {
connectedNodeIds,
configSignature,
edgesSignature,
variablesSignature,
functionsSignature,
dataSignature,
sourceSignature,
} = signatures
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 updateDataRef = useRef(updateData)
updateDataRef.current = updateData
const setNodesRef = useRef(setNodes)
setNodesRef.current = setNodes
const aiConnectionRef = useRef(aiConnection)
aiConnectionRef.current = aiConnection
const hasPendingInputs =
effectiveUpdateMode === 'manual' &&
!loading &&
incomingIds.length > 0 &&
sourceSignature !== lastRunSourceSignature
const lifecycle = useMemo<RenderingNodeLifecycle>(
() => ({ updating: loading, error: error != null }),
[loading, error]
)
useSyncConnectionStatus(id, lifecycle)
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 () => {
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
loadingStartedAtRef.current = Date.now()
setLoading(true)
setError(null)
setRenderedContent(null)
setResolvedContent(null)
setStreamingMarkdown(null)
setReasoningContent('')
updateDataRef.current({
cachedRenderedContent: undefined,
cachedResolvedContent: undefined,
cachedReasoningContent: undefined,
})
try {
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
const { nodes: ctxNodes, edges: ctxEdges } = nodesEdgesRef.current
const context = {
nodes: ctxNodes,
edges: ctxEdges,
sourceNodeId: srcId,
renderNodeId: id,
viewportWidth,
viewportHeight,
setNodes: setNodesRef.current ?? undefined,
aiConnection: aiConnectionRef.current,
...(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)
updateDataRef.current({
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
}
}
// Content updates only when connected-node data changes (sourceSignature) or explicit run/viewport.
// React Flow updates (position, selection, context ref churn) do not trigger re-runs.
}, [
id,
srcId,
srcNode?.type,
effectiveUpdateMode,
runTrigger,
sourceSignature,
viewportWidth,
viewportHeight,
retryCount,
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 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 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])
const sourceNodeType = (srcNode?.type as string) ?? null
const displayStatus = useMemo<NodeDisplayStatus>(
() => getNodeDisplayStatus({ loading, error, hasContent: !!renderedContent }),
[loading, error, renderedContent]
)
return {
displayStatus,
lifecycle,
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,
setUpdateMode,
emptyStateAction,
sourceData,
sourceNodeType,
}
}