feat: refactor rendering I

This commit is contained in:
2026-03-12 16:24:24 +01:00
parent 2a0507ca02
commit 79e4586ed1
12 changed files with 1211 additions and 769 deletions

View File

@@ -69,6 +69,7 @@ function parseReasoningAndOutput(fullMarkdown: string): { reasoning?: string; ou
export const agentRenderingLogic = { export const agentRenderingLogic = {
defaultUpdateMode: 'manual' as const, defaultUpdateMode: 'manual' as const,
getOutputMenuDescriptor: (): null => null,
getResolvedContent: async (context: SourceRenderingLogicContext): Promise<ResolvedContentResult> => { getResolvedContent: async (context: SourceRenderingLogicContext): Promise<ResolvedContentResult> => {
const { nodes, sourceNodeId, setNodes, aiConnection, onStreamingStart, onStreamingChunk } = context const { nodes, sourceNodeId, setNodes, aiConnection, onStreamingStart, onStreamingChunk } = context
const sourceNode = nodes.find((n) => n.id === sourceNodeId) const sourceNode = nodes.find((n) => n.id === sourceNodeId)

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { ScrollText } from 'lucide-react' import { ScrollText } from 'lucide-react'
import { createNodeTypeBuilder } from '@/lib/graph/nodeTypeBuilder' import { createNodeTypeBuilder } from '@/lib/graph/nodeTypeBuilder'
import { NODE_HELP } from '@/lib/graph/nodeHelp' import { NODE_HELP } from '@/lib/graph/nodeHelp'
import { getResolvedContentForConfig } from './renderingLogic' import { getResolvedContentForConfig, getOutputMenuDescriptorForConfig } from './renderingLogic'
import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry' import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry'
import ConfigNode from './ConfigNode' import ConfigNode from './ConfigNode'
@@ -37,6 +37,7 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor {
.sourceRenderingLogic({ .sourceRenderingLogic({
defaultUpdateMode: 'auto', defaultUpdateMode: 'auto',
getResolvedContent: getResolvedContentForConfig, getResolvedContent: getResolvedContentForConfig,
getOutputMenuDescriptor: getOutputMenuDescriptorForConfig,
}) })
.build() .build()
} }

View File

@@ -5,7 +5,8 @@
import nunjucks from 'nunjucks' import nunjucks from 'nunjucks'
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic' import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic'
import { getConfigContent, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/configTypes' import type { OutputMenuDescriptor } from '@/lib/graph/configTypes'
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/configTypes'
type Node = { id: string; type?: string; data?: unknown } type Node = { id: string; type?: string; data?: unknown }
type Edge = { id: string; source: string; target: string } type Edge = { id: string; source: string; target: string }
@@ -282,3 +283,9 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
}) })
}) })
} }
/** Output menu descriptor for the Rendering node when this config type is the source (e.g. Export for image types). */
export function getOutputMenuDescriptorForConfig(outputTypeId: ConfigTypeId): OutputMenuDescriptor | null {
const configType = getConfigType(outputTypeId)
return configType.outputMenuDescriptor ?? null
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,682 @@
/**
* 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,
}
}

View File

@@ -0,0 +1,83 @@
import React from 'react'
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
import type { RenderingNodeState } from '../useRenderingNodeState'
export type ImageOutputViewProps = {
state: RenderingNodeState
selected: boolean
viewportFocused: boolean
onViewportFocus: () => void
onViewportBlur: () => void
}
export function ImageOutputView({
state,
selected,
viewportFocused,
onViewportFocus,
onViewportBlur,
}: ImageOutputViewProps) {
return (
<div
className="rendering-viewport nodrag nopan relative min-h-0 flex-1 w-full min-w-0 overflow-hidden bg-white dark:bg-secondary rounded outline-none"
tabIndex={0}
onFocus={onViewportFocus}
onBlur={onViewportBlur}
>
<TransformWrapper
initialScale={1}
initialPositionX={0}
initialPositionY={0}
minScale={0.2}
maxScale={4}
centerOnInit={false}
panning={{ disabled: !selected && !viewportFocused }}
wheel={{ disabled: !selected && !viewportFocused }}
doubleClick={{ disabled: !selected && !viewportFocused }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
<button
type="button"
onClick={() => zoomIn()}
className="react-flow__controls-button"
title="Zoom in"
>
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
</button>
<button
type="button"
onClick={() => zoomOut()}
className="react-flow__controls-button"
title="Zoom out"
>
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
</button>
<button
type="button"
onClick={() => resetTransform()}
className="react-flow__controls-button"
title="Reset view (fit all)"
>
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
</button>
</div>
<div className="absolute inset-0 nodrag nopan overflow-hidden [&_.react-transform-component]:!w-full [&_.react-transform-component]:!h-full [&_.react-transform-wrapper]:!w-full [&_.react-transform-wrapper]:!h-full">
<TransformComponent
wrapperClass="!w-full !h-full"
contentClass="nodrag nopan !w-full !h-full !block !min-h-0"
>
<div
className="rendering-diagram absolute inset-0 w-full h-full min-w-0 min-h-0 nodrag nopan"
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
/>
</TransformComponent>
</div>
</>
)}
</TransformWrapper>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import React from 'react'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ChevronDown } from 'lucide-react'
import type { RenderingNodeState } from '../useRenderingNodeState'
const MARKDOWN_CLASS = 'rendering-markdown p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground'
const MARKDOWN_MAIN_CLASS = 'rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded'
export type MarkdownOutputViewProps = {
/** Final content: reasoning + think + main */
state: RenderingNodeState
/** When true, show streaming content (streamingThinkSplit, streamingPreviewHtml, streamingMarkdown) instead of final */
streaming: boolean
}
export function MarkdownOutputView({ state, streaming }: MarkdownOutputViewProps) {
if (streaming) {
return (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
{state.streamingThinkSplit.think ? (
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
Thinking
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
{state.streamingPreviewHtml ? (
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.think }} />
) : (
<pre className="rendering-markdown max-h-48 overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans text-muted-foreground">
{state.streamingThinkSplit.think}
</pre>
)}
</CollapsibleContent>
</Collapsible>
) : null}
{state.streamingPreviewHtml ? (
<div className={MARKDOWN_MAIN_CLASS} dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.main || state.streamingPreviewHtml }} />
) : (
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
{state.streamingThinkSplit.main || state.streamingMarkdown}
</pre>
)}
</div>
)
}
return (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
{state.reasoningHtml ? (
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
Reasoning
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.reasoningHtml }} />
</CollapsibleContent>
</Collapsible>
) : null}
{state.renderedThinkSplit.think ? (
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
Thinking
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.think }} />
</CollapsibleContent>
</Collapsible>
) : null}
<div className={MARKDOWN_MAIN_CLASS} dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.main }} />
</div>
)
}

View File

@@ -0,0 +1,65 @@
import React, { useMemo } from 'react'
import CodeMirror from '@uiw/react-codemirror'
import { javascript } from '@codemirror/lang-javascript'
import { markdown } from '@codemirror/lang-markdown'
import { Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { plantumlLanguage } from '@/lib/plantumlLanguage'
import type { RenderingNodeState } from '../useRenderingNodeState'
import { toast } from 'sonner'
export type RawOutputViewProps = {
state: RenderingNodeState
height: number
containerRef: React.RefObject<HTMLDivElement | null>
theme: 'light' | 'dark'
}
export function RawOutputView({ state, height, containerRef, theme }: RawOutputViewProps) {
const extensions = useMemo(() => {
const lang =
state.rawLanguage === 'wireframe'
? javascript()
: state.rawLanguage === 'plantuml'
? plantumlLanguage.extension
: markdown()
return [lang]
}, [state.rawLanguage])
return (
<div
ref={containerRef}
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input"
>
<Button
type="button"
variant="secondary"
size="icon"
className="absolute top-2 right-2 z-10 h-7 w-7 shrink-0 rounded-md shadow-sm"
onClick={() => {
const text = state.rawDisplayContent
if (text) {
navigator.clipboard
?.writeText(text)
.then(() => toast.success('Copied to clipboard'))
.catch(() => {})
}
}}
disabled={!state.rawDisplayContent}
title="Copy raw output"
>
<Copy className="size-3.5" />
</Button>
<CodeMirror
value={state.rawDisplayContent}
height={`${height}px`}
theme={theme}
extensions={extensions}
readOnly
editable={false}
basicSetup={{ lineNumbers: true, foldGutter: false }}
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
/>
</div>
)
}

View File

@@ -0,0 +1,13 @@
/**
* Output view components used by the Rendering node.
* Which view is shown is determined by the source's outputType (image vs html);
* the actual HTML structure lives here so it is not coupled to the Rendering node.
*/
export { ImageOutputView } from './ImageOutputView'
export { MarkdownOutputView } from './MarkdownOutputView'
export { RawOutputView } from './RawOutputView'
export type { ImageOutputViewProps } from './ImageOutputView'
export type { MarkdownOutputViewProps } from './MarkdownOutputView'
export type { RawOutputViewProps } from './RawOutputView'

View File

@@ -24,6 +24,20 @@ export type RenderOptions = { width?: number; height?: number }
/** How the renderer should display this type: HTML in a div, or image (SVG/PNG) in a viewport. */ /** How the renderer should display this type: HTML in a div, or image (SVG/PNG) in a viewport. */
export type ConfigOutputType = 'html' | 'image' export type ConfigOutputType = 'html' | 'image'
/** Data-only descriptor for output menu items on the Rendering node (e.g. Export SVG/PNG). Provided by config/source types. */
export type OutputMenuAction = 'downloadSvg' | 'downloadPng' | 'copySvg' | 'copyPng'
export type OutputMenuItemDescriptor = { id: string; label: string; action: OutputMenuAction }
export type OutputMenuDescriptor = {
/** Submenu label (e.g. "Export"). Omit for inline items. */
submenuLabel?: string
items: OutputMenuItemDescriptor[]
}
/** Whether this output menu action requires SVG content (disabled when no SVG). */
export function outputMenuActionRequiresSvg(action: OutputMenuAction): boolean {
return action === 'downloadSvg' || action === 'downloadPng' || action === 'copySvg' || action === 'copyPng'
}
export type ConfigType = { export type ConfigType = {
id: ConfigTypeId id: ConfigTypeId
label: string label: string
@@ -35,6 +49,8 @@ export type ConfigType = {
insertBlocks: InsertBlockOrGroup[] insertBlocks: InsertBlockOrGroup[]
/** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */ /** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */
render: (content: string, options?: RenderOptions) => Promise<string> render: (content: string, options?: RenderOptions) => Promise<string>
/** Optional. Output menu items for the Rendering node when this type is shown (e.g. Export submenu for image types). */
outputMenuDescriptor?: OutputMenuDescriptor
} }
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg' const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
@@ -169,6 +185,15 @@ export const CONFIG_TYPES: ConfigType[] = [
throw err throw err
} }
}, },
outputMenuDescriptor: {
submenuLabel: 'Export',
items: [
{ id: 'downloadSvg', label: 'Download SVG', action: 'downloadSvg' },
{ id: 'downloadPng', label: 'Download PNG', action: 'downloadPng' },
{ id: 'copySvg', label: 'Copy SVG', action: 'copySvg' },
{ id: 'copyPng', label: 'Copy image', action: 'copyPng' },
],
},
}, },
{ {
id: 'markdown', id: 'markdown',
@@ -185,6 +210,15 @@ export const CONFIG_TYPES: ConfigType[] = [
language: 'markdown', language: 'markdown',
insertBlocks: WIREFRAME_INSERT_BLOCKS, insertBlocks: WIREFRAME_INSERT_BLOCKS,
render: renderWireframe, render: renderWireframe,
outputMenuDescriptor: {
submenuLabel: 'Export',
items: [
{ id: 'downloadSvg', label: 'Download SVG', action: 'downloadSvg' },
{ id: 'downloadPng', label: 'Download PNG', action: 'downloadPng' },
{ id: 'copySvg', label: 'Copy SVG', action: 'copySvg' },
{ id: 'copyPng', label: 'Copy image', action: 'copyPng' },
],
},
}, },
] ]

View File

@@ -0,0 +1,39 @@
/**
* Shared rendering utilities used by the Rendering node and output views.
* Pure functions only; no React or node-specific logic.
*/
/** Extract <think>...</think> blocks from HTML/markdown; return main (with blocks removed) and think for collapsible. */
export function parseThinkSections(html: string): { main: string; think: string } {
const thinkRegex = /<think>([\s\S]*?)<\/think>/gi
const thinkParts: string[] = []
let match
while ((match = thinkRegex.exec(html)) !== null) thinkParts.push(match[1].trim())
const think = thinkParts.join('\n\n')
const main = html.replace(thinkRegex, '').replace(/\n{3,}/g, '\n\n').trim()
return { main, think }
}
/** Process SVG HTML for viewport display (aspect ratio, fill container). */
export function processSvgDisplay(html: string): string {
let out = html
out = out.replace(/\bpreserveAspectRatio\s*=\s*["']none["']/gi, 'preserveAspectRatio="xMidYMid meet"')
out = out.replace(/\bwidth\s*=\s*["'][^"']*["']/i, 'width="100%"')
out = out.replace(/\bheight\s*=\s*["'][^"']*["']/i, 'height="100%"')
out = out.replace(/\bstyle\s*=\s*["']([^"']*)["']/i, (_, style) => {
const overridden = style.replace(/\b(width|height):[^;]+/gi, '$1:100%')
return `style="${overridden}"`
})
return out
}
/** Strip Nunjucks/template syntax for raw view. */
export function stripTemplateSyntax(text: string): string {
return text
.replace(/\{%[\s\S]*?%\}/g, '')
.replace(/\{\{[\s\S]*?\}\}/g, '')
.replace(/\{#[\s\S]*?#\}/g, '')
.replace(/(\r?\n)\s*(\r?\n)/g, '$1$2')
.replace(/^\s*\n|\n\s*$/g, (m) => (m === '\n' ? '\n' : ''))
.trim()
}

View File

@@ -7,7 +7,9 @@
* Node types that are allowed sources for the Renderer should register here (see nodeRegistry NodeTypeDescriptor). * Node types that are allowed sources for the Renderer should register here (see nodeRegistry NodeTypeDescriptor).
*/ */
import type { ConfigTypeId } from './configTypes' import type { ConfigTypeId, OutputMenuDescriptor } from './configTypes'
export type { OutputMenuDescriptor, OutputMenuItemDescriptor, OutputMenuAction } from './configTypes'
export type SourceRenderingLogicContext = { export type SourceRenderingLogicContext = {
nodes: { id: string; type?: string; data?: unknown }[] nodes: { id: string; type?: string; data?: unknown }[]
@@ -41,10 +43,13 @@ export type ResolvedContentResult = {
* Rendering logic provided by a source node type (e.g. config, agent). * Rendering logic provided by a source node type (e.g. config, agent).
* - defaultUpdateMode: 'auto' = re-render on upstream changes; 'manual' = only on Run * - defaultUpdateMode: 'auto' = re-render on upstream changes; 'manual' = only on Run
* - getResolvedContent: async resolve step; returns resolved string and which renderer (ConfigTypeId) to use * - getResolvedContent: async resolve step; returns resolved string and which renderer (ConfigTypeId) to use
* - getOutputMenuDescriptor: optional; returns output menu items for the Rendering node (e.g. Export submenu for image types)
*/ */
export type SourceRenderingLogic = { export type SourceRenderingLogic = {
defaultUpdateMode: 'auto' | 'manual' defaultUpdateMode: 'auto' | 'manual'
getResolvedContent: (context: SourceRenderingLogicContext) => Promise<ResolvedContentResult> getResolvedContent: (context: SourceRenderingLogicContext) => Promise<ResolvedContentResult>
/** Optional. When the renderer displays this source's output, which extra output menu items to show (e.g. Export SVG/PNG). */
getOutputMenuDescriptor?: (outputTypeId: ConfigTypeId) => OutputMenuDescriptor | null
} }
const registry = new Map<string, SourceRenderingLogic>() const registry = new Map<string, SourceRenderingLogic>()