feat: renderin imrpovements

This commit is contained in:
2026-03-12 14:48:19 +01:00
parent f43e2df019
commit 909359ea53
5 changed files with 379 additions and 84 deletions

View File

@@ -28,6 +28,7 @@ import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
import { NodeMenubar } from '@/components/graph/NodeMenubar'
import { NodeStatusIndicator } from '@/components/graph/NodeStatusIndicator'
import { MenubarCheckboxItem, MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '@/components/ui/menubar'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy, Play, ChevronDown, Loader2 } from 'lucide-react'
import { InputHandle } from '@/components/graph/NodeHandles'
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
@@ -55,6 +56,10 @@ export type RenderingNodeData = {
runTrigger?: number
/** Signature of inputs used in the last successful render. Used in manual mode to show paused (yellow) when upstream changed. */
lastRunSourceSignature?: string
/** Cached render output so fullscreen can show content (set when render completes, cleared when new run starts). */
cachedRenderedContent?: string
cachedResolvedContent?: string
cachedReasoningContent?: string
}
const DEFAULT_VIEWPORT_WIDTH = 1200
@@ -64,14 +69,31 @@ type Props = AbstractNodeProps<RenderingNodeData>
type ViewMode = 'preview' | 'raw'
/** Extract <think>...</think> blocks from HTML/markdown; return main content (with blocks removed) and think content for a collapsible. */
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 }
}
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
const [renderedContent, setRenderedContent] = useState<string | null>(null)
const [resolvedContent, setResolvedContent] = useState<string | null>(null)
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, setRetryCount] = useState(0)
const [viewMode, setViewMode] = useState<ViewMode>('preview')
const [viewportFocused, setViewportFocused] = useState(false)
@@ -79,7 +101,10 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const loadingStartedAtRef = useRef<number | null>(null)
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastManualRunTriggerRef = useRef<number>(0)
const manualRunTriggerSyncedRef = useRef<boolean>(false)
const nodesEdgesRef = useRef<{ nodes: unknown[]; edges: unknown[] }>({ nodes: [], edges: [] })
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
nodesEdgesRef.current = { nodes, edges }
const { aiConnection } = usePlatform()
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
@@ -262,20 +287,30 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setLoading(false)
return
}
if (effectiveUpdateMode === 'manual' && runTrigger === 0) {
setRenderedContent(null)
setResolvedContent(null)
setError({
kind: 'no-content',
message: 'Click Run to render.',
})
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
}
if (effectiveUpdateMode === 'manual' && runTrigger === lastManualRunTriggerRef.current) {
return
}
if (effectiveUpdateMode === 'manual') lastManualRunTriggerRef.current = runTrigger
runIdRef.current += 1
const thisRunId = runIdRef.current
@@ -287,43 +322,59 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
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,
edges,
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 } = await logic.getResolvedContent(context)
if (cancelled || thisRunId !== runIdRef.current) return
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 (cancelled || thisRunId !== runIdRef.current) return
if (thisRunId !== runIdRef.current) return
setRenderedContent(htmlOrSvg)
setError(null)
if (isManualMode) {
updateData({ lastRunSourceSignature: signatureForThisRun })
}
updateData({
cachedRenderedContent: htmlOrSvg,
cachedResolvedContent: resolved,
cachedReasoningContent: reasoning ?? '',
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
})
} catch (err: any) {
if (!cancelled && thisRunId === runIdRef.current) {
setRenderedContent(null)
setReasoningContent('')
setError({ kind: 'render', message: err?.message ?? 'Render error' })
}
} finally {
if (!cancelled && thisRunId === runIdRef.current) {
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 (!cancelled && thisRunId === runIdRef.current) {
if (thisRunId === runIdRef.current) {
setLoading(false)
}
}, remaining)
@@ -349,13 +400,14 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
run()
return () => {
cancelled = true
setStreamingMarkdown(null)
if (minLoadingTimeoutRef.current != null) {
clearTimeout(minLoadingTimeoutRef.current)
minLoadingTimeoutRef.current = null
}
setLoading(false)
// Do not setLoading(false) here: the in-flight run() will set it in finally. Clearing it here would hide the Run button spinner when the effect re-runs (e.g. agent updates nodes).
}
}, [id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceContent, sourceSignature, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, nodes, edges, updateData, setNodes, aiConnection])
}, [id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceContent, sourceSignature, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, updateData, setNodes, aiConnection, isAgentSource])
const dimensions =
@@ -363,20 +415,70 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
? { width, height }
: undefined
// Render streaming agent markdown to HTML for preview
useEffect(() => {
if (streamingMarkdown === null) {
setStreamingPreviewHtml('')
return
}
let cancelled = false
import('marked').then(async ({ marked }) => {
if (cancelled) return
const html = typeof marked.parse === 'function' ? await marked.parse(streamingMarkdown) : (marked as (s: string) => string)(streamingMarkdown)
const str = typeof html === 'string' ? html : String(html)
if (!cancelled) setStreamingPreviewHtml(str)
}).catch(() => {
if (!cancelled) setStreamingPreviewHtml(streamingMarkdown)
})
return () => { cancelled = true }
}, [streamingMarkdown])
// Render reasoning markdown to HTML for collapsible section
useEffect(() => {
if (!reasoningContent) {
setReasoningHtml('')
return
}
let cancelled = false
import('marked').then(async ({ marked }) => {
if (cancelled) return
const html = typeof marked.parse === 'function' ? await marked.parse(reasoningContent) : (marked as (s: string) => string)(reasoningContent)
const str = typeof html === 'string' ? html : String(html)
if (!cancelled) setReasoningHtml(str)
}).catch(() => {
if (!cancelled) setReasoningHtml(reasoningContent)
})
return () => { cancelled = true }
}, [reasoningContent])
// Kroki and other SVG sources may prepend <?xml ... ?> so we detect by presence of <svg> tag
const isSvgOutput = Boolean(renderedContent?.trim() && /<svg[\s>]/i.test(renderedContent.trim()))
/** Strip remaining Nunjucks tags from resolved content for display in raw view (so tags don't show as literal lines). */
/** Split final markdown HTML into <think> (collapsible) and main. Only for non-image output. */
const renderedThinkSplit = useMemo(() => {
if (outputType === 'image' || !renderedContent) return { main: '', think: '' }
return parseThinkSections(renderedContent)
}, [renderedContent, outputType])
/** Split streaming content into <think> (collapsible) and main. Uses HTML when ready, else raw markdown. */
const streamingThinkSplit = useMemo(() => {
if (streamingMarkdown === null) return { main: '', think: '' }
const src = streamingPreviewHtml || streamingMarkdown
return parseThinkSections(src)
}, [streamingMarkdown, streamingPreviewHtml])
/** Raw markdown/text for Raw view: resolved content when set, otherwise streaming accumulation (so raw updates while streaming). */
const rawDisplayContent = useMemo(() => {
if (resolvedContent == null) return ''
return resolvedContent
const src = resolvedContent != null ? resolvedContent : (streamingMarkdown ?? '')
if (!src) return ''
return src
.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()
}, [resolvedContent])
}, [resolvedContent, streamingMarkdown])
/** Process SVG HTML so it keeps aspect ratio and fills the viewport (used only for display, not download). */
const displayContent = useMemo(() => {
@@ -653,7 +755,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
/>
</div>
<div className="min-h-0 flex-1 flex flex-col">
{incomingIds.length === 0 ? (
{incomingIds.length === 0 && !renderedContent && !streamingMarkdown && !loading ? (
<Empty className="min-h-0 flex-1">
<EmptyHeader>
<EmptyMedia variant="icon">
@@ -700,8 +802,6 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
</Button>
</div>
)
) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : viewMode === 'raw' ? (
<div ref={rawEditorContainerRef} className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
<Button
@@ -732,6 +832,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
/>
</div>
) : renderedContent ? (
/* Show final content as soon as it's ready (even if still loading), so streamed preview isn't replaced by "Rendering…" */
outputType === 'image' ? (
<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"
@@ -815,11 +916,76 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
</TransformWrapper>
</div>
) : (
<div
className="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"
dangerouslySetInnerHTML={{ __html: renderedContent }}
/>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
{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="rendering-markdown max-h-48 overflow-auto 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"
dangerouslySetInnerHTML={{ __html: reasoningHtml }}
/>
</CollapsibleContent>
</Collapsible>
) : null}
{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="rendering-markdown max-h-48 overflow-auto 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"
dangerouslySetInnerHTML={{ __html: renderedThinkSplit.think }}
/>
</CollapsibleContent>
</Collapsible>
) : null}
<div
className="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"
dangerouslySetInnerHTML={{ __html: renderedThinkSplit.main }}
/>
</div>
)
) : loading && viewMode === 'preview' && streamingMarkdown !== null ? (
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
{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>
{streamingPreviewHtml ? (
<div
className="rendering-markdown max-h-48 overflow-auto 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"
dangerouslySetInnerHTML={{ __html: 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">
{streamingThinkSplit.think}
</pre>
)}
</CollapsibleContent>
</Collapsible>
) : null}
{streamingPreviewHtml ? (
<div
className="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"
dangerouslySetInnerHTML={{ __html: streamingThinkSplit.main || streamingPreviewHtml }}
/>
) : (
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
{streamingThinkSplit.main || streamingMarkdown}
</pre>
)}
</div>
) : loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering</div>
) : null}
</div>
</BaseNodeContent>