From 79e4586ed1753c0d6310a4d559056f95f5d78752 Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 12 Mar 2026 16:24:24 +0100 Subject: [PATCH] feat: refactor rendering I --- .../components/nodes/agent/renderingLogic.ts | 1 + .../components/nodes/config/descriptor.tsx | 3 +- .../components/nodes/config/renderingLogic.ts | 9 +- .../components/nodes/render/RenderingNode.tsx | 969 ++++-------------- .../nodes/render/useRenderingNodeState.ts | 682 ++++++++++++ .../nodes/render/views/ImageOutputView.tsx | 83 ++ .../nodes/render/views/MarkdownOutputView.tsx | 75 ++ .../nodes/render/views/RawOutputView.tsx | 65 ++ .../components/nodes/render/views/index.ts | 13 + frontend/src/lib/graph/configTypes.ts | 34 + frontend/src/lib/graph/renderingUtils.ts | 39 + .../src/lib/graph/sourceRenderingLogic.ts | 7 +- 12 files changed, 1211 insertions(+), 769 deletions(-) create mode 100644 frontend/src/components/nodes/render/useRenderingNodeState.ts create mode 100644 frontend/src/components/nodes/render/views/ImageOutputView.tsx create mode 100644 frontend/src/components/nodes/render/views/MarkdownOutputView.tsx create mode 100644 frontend/src/components/nodes/render/views/RawOutputView.tsx create mode 100644 frontend/src/components/nodes/render/views/index.ts create mode 100644 frontend/src/lib/graph/renderingUtils.ts diff --git a/frontend/src/components/nodes/agent/renderingLogic.ts b/frontend/src/components/nodes/agent/renderingLogic.ts index 3e1bb6b..081c65a 100644 --- a/frontend/src/components/nodes/agent/renderingLogic.ts +++ b/frontend/src/components/nodes/agent/renderingLogic.ts @@ -69,6 +69,7 @@ function parseReasoningAndOutput(fullMarkdown: string): { reasoning?: string; ou export const agentRenderingLogic = { defaultUpdateMode: 'manual' as const, + getOutputMenuDescriptor: (): null => null, getResolvedContent: async (context: SourceRenderingLogicContext): Promise => { const { nodes, sourceNodeId, setNodes, aiConnection, onStreamingStart, onStreamingChunk } = context const sourceNode = nodes.find((n) => n.id === sourceNodeId) diff --git a/frontend/src/components/nodes/config/descriptor.tsx b/frontend/src/components/nodes/config/descriptor.tsx index efaf043..8e408a1 100644 --- a/frontend/src/components/nodes/config/descriptor.tsx +++ b/frontend/src/components/nodes/config/descriptor.tsx @@ -2,7 +2,7 @@ import React from 'react' import { ScrollText } from 'lucide-react' import { createNodeTypeBuilder } from '@/lib/graph/nodeTypeBuilder' import { NODE_HELP } from '@/lib/graph/nodeHelp' -import { getResolvedContentForConfig } from './renderingLogic' +import { getResolvedContentForConfig, getOutputMenuDescriptorForConfig } from './renderingLogic' import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry' import ConfigNode from './ConfigNode' @@ -37,6 +37,7 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor { .sourceRenderingLogic({ defaultUpdateMode: 'auto', getResolvedContent: getResolvedContentForConfig, + getOutputMenuDescriptor: getOutputMenuDescriptorForConfig, }) .build() } diff --git a/frontend/src/components/nodes/config/renderingLogic.ts b/frontend/src/components/nodes/config/renderingLogic.ts index 3a06174..27ad9cc 100644 --- a/frontend/src/components/nodes/config/renderingLogic.ts +++ b/frontend/src/components/nodes/config/renderingLogic.ts @@ -5,7 +5,8 @@ import nunjucks from 'nunjucks' 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 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 +} diff --git a/frontend/src/components/nodes/render/RenderingNode.tsx b/frontend/src/components/nodes/render/RenderingNode.tsx index 1133b7f..197840d 100644 --- a/frontend/src/components/nodes/render/RenderingNode.tsx +++ b/frontend/src/components/nodes/render/RenderingNode.tsx @@ -1,18 +1,6 @@ -import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -import nunjucks from 'nunjucks' -import CodeMirror from '@uiw/react-codemirror' -import { javascript } from '@codemirror/lang-javascript' -import { markdown } from '@codemirror/lang-markdown' -import { - AbstractNodeProps, - createAbstractNodeComponent, - useAbstractNode, -} from '@/lib/graph/abstractNode' -import { getConfigContent, getConfigType, getConfigTypeId } from '@/lib/graph/configTypes' -import { getSourceRenderingLogic, type SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic' -import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle' +import React, { useContext, useState } from 'react' +import { AbstractNodeProps, createAbstractNodeComponent } from '@/lib/graph/abstractNode' import { useResizeHeight } from '@/hooks/useResizeHeight' -import { plantumlLanguage } from '@/lib/plantumlLanguage' import { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia } from '@/components/ui/empty' import { BaseNode, @@ -20,18 +8,22 @@ import { BaseNodeFooter, BaseNodeHeaderRow, } from '@/components/graph/BaseNode' -import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils' import FlowContext from '@/lib/graph/flowContext' -import { getDefaultStyle, getNodeType } from '@/lib/graph/nodeRegistry' +import { getNodeType } from '@/lib/graph/nodeRegistry' import { NodeFooterEdgeIndicators } from '@/components/graph/NodeFooterEdgeIndicators' 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 { + MenubarCheckboxItem, + MenubarItem, + MenubarSeparator, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, +} from '@/components/ui/menubar' +import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react' import { InputHandle } from '@/components/graph/NodeHandles' -import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch' import { Button } from '@/components/ui/button' import { ButtonGroup } from '@/components/ui/button-group' import { @@ -41,559 +33,155 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { usePlatform } from '@/app/kosmos/KosmosContext' import { cn } from '@/lib/utils' import { useTheme } from '@/lib/themeContext' -import { toast } from 'sonner' +import { outputMenuActionRequiresSvg } from '@/lib/graph/configTypes' +import { + useRenderingNodeState, + type RenderingNodeData, +} from './useRenderingNodeState' +import { ImageOutputView, MarkdownOutputView, RawOutputView } from './views' -export type RenderingNodeData = { - viewportWidth?: number - viewportHeight?: number - /** When set, overrides the source node's default. 'auto' = re-render on upstream changes; 'manual' = only when user clicks Run. */ - updateMode?: 'auto' | 'manual' - /** Incremented when user clicks Run (manual mode). Effect runs when this changes. */ - 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 -const DEFAULT_VIEWPORT_HEIGHT = 800 +export type { RenderingNodeData } type Props = AbstractNodeProps type ViewMode = 'preview' | 'raw' -/** Extract ... 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 = /([\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(() => (data?.cachedRenderedContent as string | undefined) ?? null) - const [resolvedContent, setResolvedContent] = useState(() => (data?.cachedResolvedContent as string | undefined) ?? null) - const [error, setError] = useState(null) - const [loading, setLoading] = useState(false) - const [streamingMarkdown, setStreamingMarkdown] = useState(null) - const [streamingPreviewHtml, setStreamingPreviewHtml] = useState('') - const [reasoningContent, setReasoningContent] = useState(() => (data?.cachedReasoningContent as string | undefined) ?? '') - const [reasoningHtml, setReasoningHtml] = useState('') - const [retryCount, setRetryCount] = useState(0) + + const state = useRenderingNodeState(id, data) + const [viewMode, setViewMode] = useState('preview') const [viewportFocused, setViewportFocused] = useState(false) - const runIdRef = useRef(0) - const loadingStartedAtRef = useRef(null) - const minLoadingTimeoutRef = useRef | null>(null) - const lastManualRunTriggerRef = useRef(0) - const manualRunTriggerSyncedRef = useRef(false) - const nodesEdgesRef = useRef<{ nodes: unknown[]; edges: unknown[] }>({ nodes: [], edges: [] }) - const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode(id, data ?? {}) - nodesEdgesRef.current = { nodes, edges } - const { aiConnection } = usePlatform() - const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH - const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT - - const incomingIds = sourceIds - const srcId = incomingIds.length > 0 ? incomingIds[0] : null - const srcNode = nodes.find((n: any) => n.id === srcId) - const sourceLogic = useMemo(() => (srcNode?.type ? getSourceRenderingLogic(srcNode.type) : null), [srcNode?.type]) - const effectiveUpdateMode = data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto' - const runTrigger = data?.runTrigger ?? 0 - const isAgentSource = srcNode?.type === 'agent' - const agentOutputMarkdown = isAgentSource ? ((srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? '') : '' - const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record | undefined) : isAgentSource ? 'markdown' : 'plantuml' - const configType = getConfigType(configTypeId) - const outputType = configType.outputType - const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record | undefined) : isAgentSource ? agentOutputMarkdown : '' - const srcData = srcNode?.data ?? {} - - const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? [] - - /** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */ - const connectedNodeIds = useMemo(() => { - const out = new Set() - const isReachable = (startId: string, targetId: string) => { - const q: string[] = [startId] - const seen = new Set([startId]) - while (q.length) { - const cur = q.shift()! - if (cur === targetId) return true - for (const e of edges) { - 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.find((n: any) => n.id === refName || n.data?.title === refName)?.id ?? refName - } - const getTemplateRefs = (content: string): string[] => { - const refs: string[] = [] - const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/) - if (extendMatch) refs.push(extendMatch[1].trim()) - const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g - let m - while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim()) - const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g - while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim()) - return refs - } - const addConfigRefs = (nodeId: string, visited: Set) => { - if (visited.has(nodeId)) return - const node = nodes.find((n: any) => n.id === nodeId && n.type === 'config') - if (!node) return - visited.add(nodeId) - out.add(nodeId) - const content = getConfigContent((node.data ?? undefined) as Record | undefined) - for (const ref of getTemplateRefs(content)) { - const refId = resolveRef(ref) - if (refId && nodes.some((n: any) => n.id === refId && n.type === 'config') && isReachable(refId, id)) - addConfigRefs(refId, visited) - } - } - const configVisited = new Set() - for (const nid of incomingIds) { - const node = nodes.find((n: any) => n.id === nid) - if (node?.type === 'config') addConfigRefs(nid, configVisited) - else out.add(nid) - } - for (const e of edges) { - if (out.has(e.target)) out.add(e.source) - } - return out - }, [nodes, edges, id, incomingIds]) - - const configSignature = useMemo( - () => - nodes - .filter((n: any) => n.type === 'config' && connectedNodeIds.has(n.id)) - .map((n: any) => `${n.id}:${n.data?.title ?? ''}:${getConfigContent(n.data)}`) - .sort() - .join('|'), - [nodes, connectedNodeIds] - ) - - const edgesSignature = useMemo( - () => - edges - .filter((e: any) => connectedNodeIds.has(e.source) && (connectedNodeIds.has(e.target) || e.target === id)) - .map((e: any) => `${e.source}->${e.target}`) - .sort() - .join('|'), - [edges, connectedNodeIds, id] - ) - - const variablesSignature = useMemo( - () => - nodes - .filter((n: any) => n.type === 'variable' && connectedNodeIds.has(n.id)) - .map((n: any) => `${n.id}:${n.data?.value}`) - .sort() - .join('|'), - [nodes, connectedNodeIds] - ) - - const functionsSignature = useMemo( - () => - nodes - .filter((n: any) => n.type === 'function' && connectedNodeIds.has(n.id)) - .map((n: any) => `${n.id}:${n.data?.body ?? ''}`) - .sort() - .join('|'), - [nodes, connectedNodeIds] - ) - - const dataSignature = useMemo( - () => - nodes - .filter((n: any) => n.type === 'data' && connectedNodeIds.has(n.id)) - .map((n: any) => `${n.id}:${JSON.stringify(n.data?.rows ?? [])}:${JSON.stringify(n.data?.hiddenColumns ?? [])}`) - .sort() - .join('|'), - [nodes, connectedNodeIds] - ) - - /** Single signature of all inputs that affect this render. Stored on successful render for manual-mode paused state. */ - const sourceSignature = useMemo( - () => - JSON.stringify({ - configSignature, - edgesSignature, - variablesSignature, - functionsSignature, - dataSignature, - }), - [configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature] - ) - - const lastRunSourceSignature = data?.lastRunSourceSignature - /** - * In manual mode, yellow = "dirty": inputs changed since the last manual run (or never run). - * We report paused when dirty so we get added to connectionPathPausedNodeIds; the path is - * then computed as downstream(trigger) ∩ upstream(paused), so we must not require - * pathNodeIds.has(id) here (that would be a chicken-and-egg). - */ - const hasPendingInputs = - effectiveUpdateMode === 'manual' && - !loading && - triggerNodeIds.length > 0 && - incomingIds.length > 0 && - sourceSignature !== lastRunSourceSignature - - useSyncConnectionStatus(id, { updating: loading, error: error != null, paused: hasPendingInputs }) - - const RENDER_DEBOUNCE_MS = 250 - - 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 ?? '') - 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: any) { - if (!cancelled && thisRunId === runIdRef.current) { - setRenderedContent(null) - setReasoningContent('') - setError({ kind: 'render', message: err?.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 - } - // 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, updateData, setNodes, aiConnection, isAgentSource]) - const dimensions = width != null && height != null && width > 0 && height > 0 ? { 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 so we detect by presence of tag - const isSvgOutput = Boolean(renderedContent?.trim() && /]/i.test(renderedContent.trim())) - - /** Split final markdown HTML into (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 (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(() => { - 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, streamingMarkdown]) - - /** Process SVG HTML so it keeps aspect ratio and fills the viewport (used only for display, not download). */ - const displayContent = useMemo(() => { - if (!renderedContent || !isSvgOutput) return renderedContent - let html = renderedContent - // Force preserve aspect ratio so the diagram is not stretched (Kroki often returns preserveAspectRatio="none") - html = html.replace(/\bpreserveAspectRatio\s*=\s*["']none["']/gi, 'preserveAspectRatio="xMidYMid meet"') - // Make root SVG fill container so it scales uniformly with meet - html = html.replace(/\bwidth\s*=\s*["'][^"']*["']/i, 'width="100%"') - html = html.replace(/\bheight\s*=\s*["'][^"']*["']/i, 'height="100%"') - // Override inline style width/height so they don't override the attributes - html = html.replace(/\bstyle\s*=\s*["']([^"']*)["']/i, (_, style) => { - const overridden = style.replace(/\b(width|height):[^;]+/gi, '$1:100%') - return `style="${overridden}"` - }) - return html - }, [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 status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial' + const status = state.loading + ? 'loading' + : state.error + ? 'error' + : state.renderedContent + ? 'success' + : 'initial' const { theme } = useTheme() const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode]) - const rawExtensions = useMemo(() => { - const lang = - configTypeId === 'wireframe' - ? javascript() - : configType.language === 'plantuml' - ? plantumlLanguage.extension - : markdown() - return [lang] - }, [configTypeId, configType.language]) + + const showEmpty = + state.incomingIds.length === 0 && + !state.renderedContent && + state.streamingMarkdown === null && + !state.loading + + const errorUi = state.error && (() => { + const srcData = state.sourceData as { + renderError?: (err: { kind: string; message: string }) => React.ReactNode + errorHtml?: string + } + if (srcData?.renderError) return srcData.renderError(state.error) + if (srcData?.errorHtml) + return ( +
+ ) + return ( +
+

{state.error.message}

+ +
+ ) + })() + + const PreviewContent = () => { + if (state.renderedContent) { + if (state.outputType === 'image') { + return ( + setViewportFocused(true)} + onViewportBlur={() => setViewportFocused(false)} + /> + ) + } + return + } + if (state.loading && state.streamingMarkdown !== null) { + return + } + if (state.loading) { + return ( +
+ Rendering… +
+ ) + } + return null + } return ( - }> + } + > } title={} - onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined} + onHeaderDoubleClick={ + supportsFullscreen && setFullscreenNodeId + ? () => setFullscreenNodeId(id) + : undefined + } right={ - incomingIds.length > 0 ? ( + state.incomingIds.length > 0 ? (
@@ -624,8 +214,10 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { When to re-render checked && updateData({ updateMode: 'auto' })} + checked={state.effectiveUpdateMode === 'auto'} + onCheckedChange={(checked) => + checked && state.setUpdateMode('auto') + } className="flex flex-col items-start gap-0.5 py-2" > Auto @@ -634,8 +226,10 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { checked && updateData({ updateMode: 'manual' })} + checked={state.effectiveUpdateMode === 'manual'} + onCheckedChange={(checked) => + checked && state.setUpdateMode('manual') + } className="flex flex-col items-start gap-0.5 py-2" > Manual @@ -672,254 +266,97 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { > Raw - - - - Export - - - - Download SVG - - - Download PNG - - - Copy SVG - - - Copy image - - - + {state.outputMenuDescriptor && state.outputMenuDescriptor.items.length > 0 && ( + <> + + + outputMenuActionRequiresSvg(it.action) && !state.isSvgOutput + )} + > + {state.outputMenuDescriptor.submenuLabel ?? 'Output'} + + + {state.outputMenuDescriptor.items.map((item) => { + const disabled = outputMenuActionRequiresSvg(item.action) && !state.isSvgOutput + const onClick = + item.action === 'downloadSvg' + ? state.downloadSvg + : item.action === 'downloadPng' + ? state.downloadPng + : item.action === 'copySvg' + ? state.copySvg + : state.copyPng + return ( + + {item.label} + + ) + })} + + + + )} } />
- {incomingIds.length === 0 && !renderedContent && !streamingMarkdown && !loading ? ( + {showEmpty ? ( - No configuration connected - Connect a Configuration node or create one. The renderer will display the diagram or document. + No source connected + + Connect a Configuration or Agent node, or create one. The renderer will display the output. + - - - - - ) : error ? ( - (srcData as { renderError?: (err: { kind: string; message: string }) => React.ReactNode; errorHtml?: string })?.renderError ? ( - (srcData as { renderError: (err: { kind: string; message: string }) => React.ReactNode }).renderError(error) - ) : (srcData as { errorHtml?: string })?.errorHtml ? ( -
- ) : ( -
-

{error.message}

- -
- ) - ) : viewMode === 'raw' ? ( -
- - -
- ) : renderedContent ? ( - /* Show final content as soon as it's ready (even if still loading), so streamed preview isn't replaced by "Rendering…" */ - outputType === 'image' ? ( -
setViewportFocused(true)} - onBlur={() => setViewportFocused(false)} - > - - {({ zoomIn, zoomOut, resetTransform }) => ( - <> -
- - - -
-
- -
- -
- - )} - -
- ) : ( -
- {reasoningHtml ? ( - - - Reasoning - - - -
- - - ) : null} - {renderedThinkSplit.think ? ( - - - Thinking - - - -
- - - ) : null} -
-
- ) - ) : loading && viewMode === 'preview' && streamingMarkdown !== null ? ( -
- {streamingThinkSplit.think ? ( - - - Thinking - - - - {streamingPreviewHtml ? ( -
- ) : ( -
-                                                    {streamingThinkSplit.think}
-                                                
- )} - - - ) : null} - {streamingPreviewHtml ? ( -
- ) : ( -
-                                        {streamingThinkSplit.main || streamingMarkdown}
-                                    
+ {state.emptyStateAction && ( + + + )} -
- ) : loading ? ( -
Rendering…
- ) : null} + + ) : state.error ? ( + errorUi + ) : viewMode === 'raw' ? ( + + ) : ( + + )}
{viewMode === 'raw' - ? (rawDisplayContent ? `Raw · ${rawDisplayContent.length} chars` : '—') - : renderedContent - ? `${configType.label} · ${renderedContent.length} chars` - : error - ? 'Error' - : '—'} + ? state.rawDisplayContent + ? `Raw · ${state.rawDisplayContent.length} chars` + : '—' + : state.renderedContent + ? `${state.outputLabel} · ${state.renderedContent.length} chars` + : state.error + ? 'Error' + : '—'} diff --git a/frontend/src/components/nodes/render/useRenderingNodeState.ts b/frontend/src/components/nodes/render/useRenderingNodeState.ts new file mode 100644 index 0000000..4815515 --- /dev/null +++ b/frontend/src/components/nodes/render/useRenderingNodeState.ts @@ -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 hook’s return value. + */ + +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useAbstractNode } from '@/lib/graph/abstractNode' +import { getConfigContent, getConfigType, getConfigTypeId } from '@/lib/graph/configTypes' +import type { ConfigTypeId, OutputMenuDescriptor } from '@/lib/graph/configTypes' +import FlowContext from '@/lib/graph/flowContext' +import { getSourceRenderingLogic, type SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic' +import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle' +import { usePlatform } from '@/app/kosmos/KosmosContext' +import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils' +import { getDefaultStyle } from '@/lib/graph/nodeRegistry' +import { parseThinkSections, processSvgDisplay, stripTemplateSyntax } from '@/lib/graph/renderingUtils' + +export type RenderingNodeData = { + viewportWidth?: number + viewportHeight?: number + updateMode?: 'auto' | 'manual' + runTrigger?: number + lastRunSourceSignature?: string + cachedRenderedContent?: string + cachedResolvedContent?: string + cachedReasoningContent?: string +} + +const DEFAULT_VIEWPORT_WIDTH = 1200 +const DEFAULT_VIEWPORT_HEIGHT = 800 +const RENDER_DEBOUNCE_MS = 250 + +export type RenderingNodeState = { + // Connection & run control + incomingIds: string[] + effectiveUpdateMode: 'auto' | 'manual' + runTrigger: number + hasPendingInputs: boolean + loading: boolean + error: null | { kind: string; message: string } + incrementRunTrigger: () => void + setUpdateMode: (mode: 'auto' | 'manual') => void + setRetryCount: (fn: (c: number) => number) => void + + // Content (resolved = before render, rendered = after render) + renderedContent: string | null + resolvedContent: string | null + streamingMarkdown: string | null + streamingPreviewHtml: string + reasoningContent: string + reasoningHtml: string + + // Display type (no config/agent types exposed) + outputType: 'html' | 'image' + outputLabel: string + rawLanguage: ConfigTypeId + isSvgOutput: boolean + + // Derived for UI + renderedThinkSplit: { main: string; think: string } + streamingThinkSplit: { main: string; think: string } + rawDisplayContent: string + displayContent: string | null + + // Viewport + viewportWidth: number + viewportHeight: number + + // Callbacks + downloadSvg: () => void + downloadPng: () => void + copySvg: () => void + copyPng: () => void + + /** Output menu descriptor from the source (e.g. Export submenu for image config types). Null when no source or source provides none. */ + outputMenuDescriptor: OutputMenuDescriptor | null + + // Empty state (source-type-specific action stays in hook) + emptyStateAction: null | { label: string; onClick: () => void } + + // Optional custom error UI from source node data + sourceData: Record +} + +export function useRenderingNodeState( + id: string, + data: RenderingNodeData | undefined +): RenderingNodeState { + const { + nodes, + edges, + setNodes, + setEdges, + sourceIds: incomingIds, + updateData, + } = useAbstractNode(id, data ?? {}) + + const nodesEdgesRef = useRef({ nodes, edges }) + nodesEdgesRef.current = { nodes, edges } + + const { aiConnection } = usePlatform() + + const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH + const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT + + const srcId = incomingIds.length > 0 ? incomingIds[0] : null + const srcNode = useMemo( + () => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null), + [nodes, srcId] + ) + + const sourceLogic = useMemo( + () => (srcNode?.type ? getSourceRenderingLogic(srcNode.type as string) : null), + [srcNode?.type] + ) + const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual' + const runTrigger = data?.runTrigger ?? 0 + const isAgentSource = srcNode?.type === 'agent' + const agentOutputMarkdown = isAgentSource + ? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '') + : '' + const configTypeId: ConfigTypeId = + srcNode?.type === 'config' + ? getConfigTypeId((srcNode?.data ?? undefined) as Record | undefined) + : isAgentSource + ? 'markdown' + : 'plantuml' + const configType = getConfigType(configTypeId) + const outputType = configType.outputType + const sourceContent = + srcNode?.type === 'config' + ? getConfigContent((srcNode?.data ?? undefined) as Record | undefined) + : isAgentSource + ? agentOutputMarkdown + : '' + + const connectedNodeIds = useMemo(() => { + const out = new Set() + const isReachable = (startId: string, targetId: string) => { + const q: string[] = [startId] + const seen = new Set([startId]) + while (q.length) { + const cur = q.shift()! + if (cur === targetId) return true + for (const e of edges as { source: string; target: string }[]) { + if (e.source === cur && !seen.has(e.target)) { + seen.add(e.target) + q.push(e.target) + } + } + } + return false + } + const resolveRef = (name: string) => { + const refName = name.replace(/\.(puml|html)$/, '').trim() + return (nodes as { id: string; data?: { title?: string } }[]).find( + (n) => n.id === refName || n.data?.title === refName + )?.id ?? refName + } + const getTemplateRefs = (content: string): string[] => { + const refs: string[] = [] + const extendMatch = content.match(/\{\%\s*extends\s+["']([^"']+)["']\s*\%\}/) + if (extendMatch) refs.push(extendMatch[1].trim()) + const includeRegex = /\{\%\s*include\s+["']([^"']+)["']\s*\%\}/g + let m + while ((m = includeRegex.exec(content)) !== null) refs.push(m[1].trim()) + const importRegex = /\{\%\s*import\s+["']([^"']+)["']\s+as\s+\w+\s*\%\}/g + while ((m = importRegex.exec(content)) !== null) refs.push(m[1].trim()) + return refs + } + const addConfigRefs = (nodeId: string, visited: Set) => { + if (visited.has(nodeId)) return + const node = (nodes as { id: string; type?: string; data?: unknown }[]).find( + (n) => n.id === nodeId && n.type === 'config' + ) + if (!node) return + visited.add(nodeId) + out.add(nodeId) + const content = getConfigContent((node.data ?? undefined) as Record | undefined) + for (const ref of getTemplateRefs(content)) { + const refId = resolveRef(ref) + if ( + refId && + (nodes as { id: string; type?: string }[]).some((n) => n.id === refId && n.type === 'config') && + isReachable(refId, id) + ) { + addConfigRefs(refId, visited) + } + } + } + const configVisited = new Set() + for (const nid of incomingIds) { + const node = (nodes as { id: string; type?: string }[]).find((n) => n.id === nid) + if (node?.type === 'config') addConfigRefs(nid, configVisited) + else out.add(nid) + } + for (const e of edges as { source: string; target: string }[]) { + if (out.has(e.target)) out.add(e.source) + } + return out + }, [nodes, edges, id, incomingIds]) + + const configSignature = useMemo( + () => + (nodes as { id: string; type?: string; data?: unknown }[]) + .filter((n) => n.type === 'config' && connectedNodeIds.has(n.id)) + .map((n) => `${n.id}:${(n.data as { title?: string })?.title ?? ''}:${getConfigContent(n.data as Record)}`) + .sort() + .join('|'), + [nodes, connectedNodeIds] + ) + const edgesSignature = useMemo( + () => + (edges as { source: string; target: string }[]) + .filter( + (e) => + connectedNodeIds.has(e.source) && + (connectedNodeIds.has(e.target) || e.target === id) + ) + .map((e) => `${e.source}->${e.target}`) + .sort() + .join('|'), + [edges, connectedNodeIds, id] + ) + const variablesSignature = useMemo( + () => + (nodes as { id: string; type?: string; data?: { value?: unknown } }[]) + .filter((n) => n.type === 'variable' && connectedNodeIds.has(n.id)) + .map((n) => `${n.id}:${n.data?.value}`) + .sort() + .join('|'), + [nodes, connectedNodeIds] + ) + const functionsSignature = useMemo( + () => + (nodes as { id: string; type?: string; data?: { body?: string } }[]) + .filter((n) => n.type === 'function' && connectedNodeIds.has(n.id)) + .map((n) => `${n.id}:${n.data?.body ?? ''}`) + .sort() + .join('|'), + [nodes, connectedNodeIds] + ) + const dataSignature = useMemo( + () => + (nodes as { id: string; type?: string; data?: { rows?: unknown[]; hiddenColumns?: unknown[] } }[]) + .filter((n) => n.type === 'data' && connectedNodeIds.has(n.id)) + .map((n) => `${n.id}:${JSON.stringify(n.data?.rows ?? [])}:${JSON.stringify(n.data?.hiddenColumns ?? [])}`) + .sort() + .join('|'), + [nodes, connectedNodeIds] + ) + + const sourceSignature = useMemo( + () => + JSON.stringify({ + configSignature, + edgesSignature, + variablesSignature, + functionsSignature, + dataSignature, + }), + [configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature] + ) + + const lastRunSourceSignature = data?.lastRunSourceSignature + + const [renderedContent, setRenderedContent] = useState( + () => (data?.cachedRenderedContent as string | undefined) ?? null + ) + const [resolvedContent, setResolvedContent] = useState( + () => (data?.cachedResolvedContent as string | undefined) ?? null + ) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [streamingMarkdown, setStreamingMarkdown] = useState(null) + const [streamingPreviewHtml, setStreamingPreviewHtml] = useState('') + const [reasoningContent, setReasoningContent] = useState( + () => (data?.cachedReasoningContent as string | undefined) ?? '' + ) + const [reasoningHtml, setReasoningHtml] = useState('') + const [retryCount, setRetryCountState] = useState(0) + + const runIdRef = useRef(0) + const loadingStartedAtRef = useRef(null) + const minLoadingTimeoutRef = useRef | null>(null) + const lastManualRunTriggerRef = useRef(0) + const manualRunTriggerSyncedRef = useRef(false) + + const flowContext = useContext(FlowContext) + const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? [] + const hasPendingInputs = + effectiveUpdateMode === 'manual' && + !loading && + triggerNodeIds.length > 0 && + incomingIds.length > 0 && + sourceSignature !== lastRunSourceSignature + + useSyncConnectionStatus(id, { updating: loading, error: error != null, paused: hasPendingInputs }) + + useEffect(() => { + if (incomingIds.length === 0) { + setRenderedContent(null) + setResolvedContent(null) + setError(null) + setLoading(false) + return + } + if (!srcId || !srcNode) { + setRenderedContent(null) + setResolvedContent(null) + setError(null) + setLoading(false) + return + } + const logic = getSourceRenderingLogic(srcNode.type as string) + if (!logic) { + setRenderedContent(null) + setResolvedContent(null) + setError({ kind: 'render', message: `Unsupported source type: ${srcNode.type}` }) + setLoading(false) + return + } + if (effectiveUpdateMode === 'manual') { + if (!manualRunTriggerSyncedRef.current) { + lastManualRunTriggerRef.current = runTrigger + manualRunTriggerSyncedRef.current = true + return + } + if (runTrigger === 0) { + const hasCachedOutput = Boolean( + (data?.cachedRenderedContent ?? data?.cachedResolvedContent) as string | undefined + ) + if (!hasCachedOutput) { + setRenderedContent(null) + setResolvedContent(null) + setError({ kind: 'no-content', message: 'Click Run to render.' }) + setLoading(false) + } + return + } + if (runTrigger === lastManualRunTriggerRef.current) return + lastManualRunTriggerRef.current = runTrigger + } + + runIdRef.current += 1 + const thisRunId = runIdRef.current + const signatureForThisRun = sourceSignature + const isManualMode = effectiveUpdateMode === 'manual' + let cancelled = false + + const run = async () => { + loadingStartedAtRef.current = Date.now() + setLoading(true) + setError(null) + setRenderedContent(null) + setResolvedContent(null) + setStreamingMarkdown(null) + setReasoningContent('') + updateData({ + cachedRenderedContent: undefined, + cachedResolvedContent: undefined, + cachedReasoningContent: undefined, + }) + try { + const { nodes: ctxNodes, edges: ctxEdges } = nodesEdgesRef.current + const context = { + nodes: ctxNodes, + edges: ctxEdges, + sourceNodeId: srcId, + renderNodeId: id, + viewportWidth, + viewportHeight, + setNodes: setNodes ?? undefined, + aiConnection, + ...(isAgentSource && { + onStreamingStart: () => setStreamingMarkdown(''), + onStreamingChunk: (chunk: string) => + setStreamingMarkdown((prev) => (prev ?? '') + chunk), + }), + } as SourceRenderingLogicContext + const { resolved, outputTypeId, reasoning } = await logic.getResolvedContent(context) + if (thisRunId !== runIdRef.current) return + setResolvedContent(resolved) + setReasoningContent(reasoning ?? '') + const typeRenderer = getConfigType(outputTypeId) + const renderOptions = + outputTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined + const htmlOrSvg = await typeRenderer.render(resolved, renderOptions) + if (thisRunId !== runIdRef.current) return + setRenderedContent(htmlOrSvg) + setError(null) + updateData({ + cachedRenderedContent: htmlOrSvg, + cachedResolvedContent: resolved, + cachedReasoningContent: reasoning ?? '', + ...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}), + }) + } catch (err: unknown) { + if (!cancelled && thisRunId === runIdRef.current) { + setRenderedContent(null) + setReasoningContent('') + setError({ + kind: 'render', + message: (err as { message?: string })?.message ?? 'Render error', + }) + } + } finally { + setStreamingMarkdown(null) + if (thisRunId === runIdRef.current) { + const startedAt = loadingStartedAtRef.current ?? 0 + const elapsed = Date.now() - startedAt + const remaining = Math.max(0, 1000 - elapsed) + if (remaining > 0) { + minLoadingTimeoutRef.current = setTimeout(() => { + minLoadingTimeoutRef.current = null + if (thisRunId === runIdRef.current) setLoading(false) + }, remaining) + } else { + setLoading(false) + } + } + } + } + + if (effectiveUpdateMode === 'auto') { + const debounceTimer = setTimeout(run, RENDER_DEBOUNCE_MS) + return () => { + cancelled = true + clearTimeout(debounceTimer) + if (minLoadingTimeoutRef.current != null) { + clearTimeout(minLoadingTimeoutRef.current) + minLoadingTimeoutRef.current = null + } + setLoading(false) + } + } + run() + return () => { + cancelled = true + setStreamingMarkdown(null) + if (minLoadingTimeoutRef.current != null) { + clearTimeout(minLoadingTimeoutRef.current) + minLoadingTimeoutRef.current = null + } + } + }, [ + id, + srcId, + srcNode?.type, + effectiveUpdateMode, + runTrigger, + sourceContent, + sourceSignature, + configSignature, + edgesSignature, + variablesSignature, + functionsSignature, + dataSignature, + viewportWidth, + viewportHeight, + retryCount, + updateData, + setNodes, + aiConnection, + isAgentSource, + incomingIds.length, + ]) + + useEffect(() => { + if (streamingMarkdown === null) { + setStreamingPreviewHtml('') + return + } + let cancelled = false + import('marked') + .then(async ({ marked }) => { + if (cancelled) return + const parsed = + typeof marked.parse === 'function' + ? await (marked.parse as (s: string) => Promise)(streamingMarkdown) + : (marked as (s: string) => string)(streamingMarkdown) + const str = typeof parsed === 'string' ? parsed : String(parsed) + if (!cancelled) setStreamingPreviewHtml(str) + }) + .catch(() => { + if (!cancelled) setStreamingPreviewHtml(streamingMarkdown) + }) + return () => { + cancelled = true + } + }, [streamingMarkdown]) + + useEffect(() => { + if (!reasoningContent) { + setReasoningHtml('') + return + } + let cancelled = false + import('marked') + .then(async ({ marked }) => { + if (cancelled) return + const parsed = + typeof marked.parse === 'function' + ? await (marked.parse as (s: string) => Promise)(reasoningContent) + : (marked as (s: string) => string)(reasoningContent) + const str = typeof parsed === 'string' ? parsed : String(parsed) + if (!cancelled) setReasoningHtml(str) + }) + .catch(() => { + if (!cancelled) setReasoningHtml(reasoningContent) + }) + return () => { + cancelled = true + } + }, [reasoningContent]) + + const isSvgOutput = Boolean( + renderedContent?.trim() && /]/i.test(renderedContent.trim()) + ) + const outputMenuDescriptor = useMemo( + () => sourceLogic?.getOutputMenuDescriptor?.(configTypeId) ?? null, + [sourceLogic, configTypeId] + ) + const renderedThinkSplit = useMemo(() => { + if (outputType === 'image' || !renderedContent) return { main: '', think: '' } + return parseThinkSections(renderedContent) + }, [renderedContent, outputType]) + const streamingThinkSplit = useMemo(() => { + if (streamingMarkdown === null) return { main: '', think: '' } + const src = streamingPreviewHtml || streamingMarkdown + return parseThinkSections(src) + }, [streamingMarkdown, streamingPreviewHtml]) + const rawDisplayContent = useMemo(() => { + const src = resolvedContent != null ? resolvedContent : streamingMarkdown ?? '' + return stripTemplateSyntax(src) + }, [resolvedContent, streamingMarkdown]) + const displayContent = useMemo(() => { + if (!renderedContent || !isSvgOutput) return renderedContent + return processSvgDisplay(renderedContent) + }, [renderedContent, isSvgOutput]) + + const downloadSvg = useCallback(() => { + if (!renderedContent || !isSvgOutput) return + const blob = new Blob([renderedContent], { type: 'image/svg+xml' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${id}.svg` + a.click() + URL.revokeObjectURL(url) + }, [id, renderedContent, isSvgOutput]) + + const downloadPng = useCallback(() => { + if (!renderedContent || !isSvgOutput) return + const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent) + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.drawImage(img, 0, 0) + const pngUrl = canvas.toDataURL('image/png') + const a = document.createElement('a') + a.href = pngUrl + a.download = `${id}.png` + a.click() + } + img.onerror = () => {} + img.src = dataUrl + }, [id, renderedContent, isSvgOutput]) + + const copyPng = useCallback(() => { + if (!renderedContent || !isSvgOutput) return + const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(renderedContent) + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.drawImage(img, 0, 0) + canvas.toBlob((blob) => { + if (blob) + navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {}) + }, 'image/png') + } + img.onerror = () => {} + img.src = dataUrl + }, [renderedContent, isSvgOutput]) + + const copySvg = useCallback(() => { + if (!renderedContent || !isSvgOutput) return + const blob = new Blob([renderedContent], { type: 'image/svg+xml' }) + navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {}) + }, [renderedContent, isSvgOutput]) + + const incrementRunTrigger = useCallback(() => { + updateData({ runTrigger: (data?.runTrigger ?? 0) + 1 }) + }, [data?.runTrigger, updateData]) + + const setUpdateMode = useCallback( + (mode: 'auto' | 'manual') => { + updateData({ updateMode: mode }) + }, + [updateData] + ) + + const setRetryCount = useCallback((fn: (c: number) => number) => { + setRetryCountState(fn) + }, []) + + const emptyStateAction = useMemo(() => { + if (incomingIds.length > 0) return null + if (!setNodes || !setEdges) return null + return { + label: 'Create Config', + onClick: () => { + const nid = getNextNodeId( + 'config', + (nodes as { id: string }[]).map((n) => n.id) + ) + const thisNode = (nodes as { id: string; position?: { x: number; y: number } }[]).find( + (n) => n.id === id + ) + const pos = thisNode?.position ?? { x: 0, y: 0 } + const newPos = { x: pos.x - 220, y: pos.y } + const newNode = { + id: nid, + type: 'config', + position: newPos, + data: getDefaultDataForType('config', nid), + style: getDefaultStyle('config'), + } + setNodes((nds: unknown[]) => nds.concat(newNode) as unknown[]) + setEdges((eds: unknown[]) => + eds.concat({ id: `e-${nid}-${id}`, source: nid, target: id }) as unknown[] + ) + }, + } + }, [id, incomingIds.length, nodes, setNodes, setEdges]) + + const sourceData = useMemo(() => (srcNode?.data as Record) ?? {}, [srcNode?.data]) + + return { + incomingIds, + effectiveUpdateMode, + runTrigger, + hasPendingInputs, + loading, + error, + incrementRunTrigger, + setRetryCount, + renderedContent, + resolvedContent, + streamingMarkdown, + streamingPreviewHtml, + reasoningContent, + reasoningHtml, + outputType, + outputLabel: configType.label, + rawLanguage: configTypeId, + isSvgOutput, + renderedThinkSplit, + streamingThinkSplit, + rawDisplayContent, + displayContent, + viewportWidth, + viewportHeight, + downloadSvg, + downloadPng, + copySvg, + copyPng, + setUpdateMode, + outputMenuDescriptor, + emptyStateAction, + sourceData, + } +} diff --git a/frontend/src/components/nodes/render/views/ImageOutputView.tsx b/frontend/src/components/nodes/render/views/ImageOutputView.tsx new file mode 100644 index 0000000..95681f6 --- /dev/null +++ b/frontend/src/components/nodes/render/views/ImageOutputView.tsx @@ -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 ( +
+ + {({ zoomIn, zoomOut, resetTransform }) => ( + <> +
+ + + +
+
+ +
+ +
+ + )} + +
+ ) +} diff --git a/frontend/src/components/nodes/render/views/MarkdownOutputView.tsx b/frontend/src/components/nodes/render/views/MarkdownOutputView.tsx new file mode 100644 index 0000000..ac1b0bb --- /dev/null +++ b/frontend/src/components/nodes/render/views/MarkdownOutputView.tsx @@ -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 ( +
+ {state.streamingThinkSplit.think ? ( + + + Thinking + + + + {state.streamingPreviewHtml ? ( +
+ ) : ( +
+                  {state.streamingThinkSplit.think}
+                
+ )} + + + ) : null} + {state.streamingPreviewHtml ? ( +
+ ) : ( +
+            {state.streamingThinkSplit.main || state.streamingMarkdown}
+          
+ )} +
+ ) + } + + return ( +
+ {state.reasoningHtml ? ( + + + Reasoning + + + +
+ + + ) : null} + {state.renderedThinkSplit.think ? ( + + + Thinking + + + +
+ + + ) : null} +
+
+ ) +} diff --git a/frontend/src/components/nodes/render/views/RawOutputView.tsx b/frontend/src/components/nodes/render/views/RawOutputView.tsx new file mode 100644 index 0000000..5a87e89 --- /dev/null +++ b/frontend/src/components/nodes/render/views/RawOutputView.tsx @@ -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 + 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 ( +
+ + +
+ ) +} diff --git a/frontend/src/components/nodes/render/views/index.ts b/frontend/src/components/nodes/render/views/index.ts new file mode 100644 index 0000000..6326ded --- /dev/null +++ b/frontend/src/components/nodes/render/views/index.ts @@ -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' diff --git a/frontend/src/lib/graph/configTypes.ts b/frontend/src/lib/graph/configTypes.ts index e06dbcd..b429141 100644 --- a/frontend/src/lib/graph/configTypes.ts +++ b/frontend/src/lib/graph/configTypes.ts @@ -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. */ 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 = { id: ConfigTypeId label: string @@ -35,6 +49,8 @@ export type ConfigType = { insertBlocks: InsertBlockOrGroup[] /** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */ render: (content: string, options?: RenderOptions) => Promise + /** 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' @@ -169,6 +185,15 @@ export const CONFIG_TYPES: ConfigType[] = [ 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', @@ -185,6 +210,15 @@ export const CONFIG_TYPES: ConfigType[] = [ language: 'markdown', insertBlocks: WIREFRAME_INSERT_BLOCKS, 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' }, + ], + }, }, ] diff --git a/frontend/src/lib/graph/renderingUtils.ts b/frontend/src/lib/graph/renderingUtils.ts new file mode 100644 index 0000000..84de97d --- /dev/null +++ b/frontend/src/lib/graph/renderingUtils.ts @@ -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 ... blocks from HTML/markdown; return main (with blocks removed) and think for collapsible. */ +export function parseThinkSections(html: string): { main: string; think: string } { + const thinkRegex = /([\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() +} diff --git a/frontend/src/lib/graph/sourceRenderingLogic.ts b/frontend/src/lib/graph/sourceRenderingLogic.ts index 1566af4..6d46c7e 100644 --- a/frontend/src/lib/graph/sourceRenderingLogic.ts +++ b/frontend/src/lib/graph/sourceRenderingLogic.ts @@ -7,7 +7,9 @@ * 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 = { 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). * - defaultUpdateMode: 'auto' = re-render on upstream changes; 'manual' = only on Run * - 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 = { defaultUpdateMode: 'auto' | 'manual' getResolvedContent: (context: SourceRenderingLogicContext) => Promise + /** 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()