feat: refactoring rendering

This commit is contained in:
2026-03-12 16:56:53 +01:00
parent 79e4586ed1
commit 084863909a
15 changed files with 327 additions and 196 deletions

View File

@@ -20,5 +20,6 @@ export function getAgentNodeDescriptor(): NodeTypeDescriptor {
.connectionLabel('prompt/context')
.withFullscreen()
.sourceRenderingLogic(agentRenderingLogic)
.outputMenuContent(() => null)
.build()
}

View File

@@ -1,10 +1,11 @@
/**
* Agent node rendering logic: when the Rendering node runs, it calls the agent API
* and updates the agent node's output. Update mode is manual (user clicks Run on the renderer).
* Agent node rendering logic: implements the "Resolve" step for agent sources.
* Calls the agent API and updates the agent node's output. Update mode is manual.
* See lib/graph/rendering.ts (IRenderingSource).
*/
import { getConfigContent } from '@/lib/graph/configTypes'
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic'
import { getConfigContent } from '@/lib/graph/rendering'
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/rendering'
import type { AiConnection } from '@/app/kosmos/KosmosContext'
function serializeNodeForContext(
@@ -69,7 +70,6 @@ function parseReasoningAndOutput(fullMarkdown: string): { reasoning?: string; ou
export const agentRenderingLogic = {
defaultUpdateMode: 'manual' as const,
getOutputMenuDescriptor: (): null => null,
getResolvedContent: async (context: SourceRenderingLogicContext): Promise<ResolvedContentResult> => {
const { nodes, sourceNodeId, setNodes, aiConnection, onStreamingStart, onStreamingChunk } = context
const sourceNode = nodes.find((n) => n.id === sourceNodeId)

View File

@@ -2,12 +2,62 @@ import React from 'react'
import { ScrollText } from 'lucide-react'
import { createNodeTypeBuilder } from '@/lib/graph/nodeTypeBuilder'
import { NODE_HELP } from '@/lib/graph/nodeHelp'
import { getResolvedContentForConfig, getOutputMenuDescriptorForConfig } from './renderingLogic'
import { getConfigType } from '@/lib/graph/rendering'
import {
MenubarItem,
MenubarSeparator,
MenubarSub,
MenubarSubContent,
MenubarSubTrigger,
} from '@/components/ui/menubar'
import { getResolvedContentForConfig } from './renderingLogic'
import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry'
import ConfigNode from './ConfigNode'
import { createImageExportHandlers, type OutputMenuContext } from '@/components/nodes/render/outputMenuRegistry'
const ICON_CLASS = 'mr-2 h-4 w-4'
function configOutputMenuContent(outputTypeId: string, ctx: unknown): React.ReactNode {
const c = ctx as OutputMenuContext
const configType = getConfigType(outputTypeId as 'plantuml' | 'markdown' | 'wireframe')
const descriptor = configType?.outputMenuDescriptor
if (!descriptor?.items?.length) return null
const disabled = !c.state.isSvgOutput
const handlers = createImageExportHandlers(c)
return (
<>
<MenubarSeparator />
<MenubarSub>
<MenubarSubTrigger className="text-xs" disabled={disabled}>
{descriptor.submenuLabel ?? 'Export'}
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]" aria-label={descriptor.submenuLabel ?? 'Export'}>
{descriptor.items.map((item) => {
const handler =
item.action === 'downloadSvg'
? handlers.downloadSvg
: item.action === 'downloadPng'
? handlers.downloadPng
: item.action === 'copySvg'
? handlers.copySvg
: handlers.copyPng
return (
<MenubarItem
key={item.id}
className="text-xs"
onClick={handler}
disabled={disabled}
>
{item.label}
</MenubarItem>
)
})}
</MenubarSubContent>
</MenubarSub>
</>
)
}
export function getConfigNodeDescriptor(): NodeTypeDescriptor {
return createNodeTypeBuilder(
'config',
@@ -37,7 +87,7 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor {
.sourceRenderingLogic({
defaultUpdateMode: 'auto',
getResolvedContent: getResolvedContentForConfig,
getOutputMenuDescriptor: getOutputMenuDescriptorForConfig,
})
.outputMenuContent(configOutputMenuContent)
.build()
}

View File

@@ -1,12 +1,12 @@
/**
* Config node rendering logic: resolves Nunjucks (extends/include/import, variables, data, functions)
* and returns the resolved string and output type for the Rendering node.
* Config node rendering logic: implements the "Resolve" step for config sources.
* Resolves Nunjucks (extends/include/import, variables, data, functions) and returns
* resolved string + output type. See lib/graph/rendering.ts (IRenderingSource).
*/
import nunjucks from 'nunjucks'
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic'
import type { OutputMenuDescriptor } from '@/lib/graph/configTypes'
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/configTypes'
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/rendering'
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
type Node = { id: string; type?: string; data?: unknown }
type Edge = { id: string; source: string; target: string }
@@ -283,9 +283,3 @@ 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
}

View File

@@ -35,7 +35,6 @@ import {
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { useTheme } from '@/lib/themeContext'
import { outputMenuActionRequiresSvg } from '@/lib/graph/configTypes'
import {
useRenderingNodeState,
type RenderingNodeData,
@@ -266,44 +265,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
>
Raw
</MenubarCheckboxItem>
{state.outputMenuDescriptor && state.outputMenuDescriptor.items.length > 0 && (
<>
<MenubarSeparator />
<MenubarSub>
<MenubarSubTrigger
className="text-xs"
disabled={state.outputMenuDescriptor.items.some(
(it) => outputMenuActionRequiresSvg(it.action) && !state.isSvgOutput
)}
>
{state.outputMenuDescriptor.submenuLabel ?? 'Output'}
</MenubarSubTrigger>
<MenubarSubContent className="min-w-[10rem]" aria-label={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 (
<MenubarItem
key={item.id}
className="text-xs"
onClick={onClick}
disabled={disabled}
>
{item.label}
</MenubarItem>
)
})}
</MenubarSubContent>
</MenubarSub>
</>
)}
{getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })}
</>
}
/>

View File

@@ -1,2 +1,4 @@
export { default as RenderingNode, type RenderingNodeData } from './RenderingNode'
export { getRenderNodeDescriptor } from './descriptor'
export { createImageExportHandlers } from './outputMenuRegistry'
export type { OutputMenuContext } from './outputMenuRegistry'

View File

@@ -0,0 +1,73 @@
/**
* Shared output menu helpers for the Rendering node. Source node types (e.g. config)
* define their Output menu content in their descriptor via getOutputMenuContent; they
* use createImageExportHandlers and this context type for image export (SVG/PNG).
*/
import type { RenderingNodeState } from './useRenderingNodeState'
export type OutputMenuContext = {
state: RenderingNodeState
nodeId: string
}
/** Create handlers for SVG export (download SVG/PNG, copy). Used by source descriptors (e.g. config). */
export function createImageExportHandlers(ctx: OutputMenuContext) {
const { state, nodeId } = ctx
const { renderedContent, isSvgOutput } = state
return {
downloadSvg: () => {
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 = `${nodeId}.svg`
a.click()
URL.revokeObjectURL(url)
},
downloadPng: () => {
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 c = canvas.getContext('2d')
if (!c) return
c.drawImage(img, 0, 0)
const a = document.createElement('a')
a.href = canvas.toDataURL('image/png')
a.download = `${nodeId}.png`
a.click()
}
img.onerror = () => {}
img.src = dataUrl
},
copySvg: () => {
if (!renderedContent || !isSvgOutput) return
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {})
},
copyPng: () => {
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 c = canvas.getContext('2d')
if (!c) return
c.drawImage(img, 0, 0)
canvas.toBlob(
(blob) => blob && navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {}),
'image/png'
)
}
img.onerror = () => {}
img.src = dataUrl
},
}
}

View File

@@ -1,20 +1,26 @@
/**
* 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.
* runs the resolve → render pipeline, manages streaming/cache,
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
* pipeline interface.
*/
import { useCallback, 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'
import {
getConfigContent,
getConfigType,
getConfigTypeId,
getSourceRenderingLogic,
parseThinkSections,
processSvgDisplay,
stripTemplateSyntax,
} from '@/lib/graph/rendering'
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
export type RenderingNodeData = {
viewportWidth?: number
@@ -67,20 +73,14 @@ export type RenderingNodeState = {
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>
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
sourceNodeType: string | null
}
export function useRenderingNodeState(
@@ -363,6 +363,7 @@ export function useRenderingNodeState(
cachedReasoningContent: undefined,
})
try {
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
const { nodes: ctxNodes, edges: ctxEdges } = nodesEdgesRef.current
const context = {
nodes: ctxNodes,
@@ -518,10 +519,6 @@ export function useRenderingNodeState(
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)
@@ -540,64 +537,6 @@ export function useRenderingNodeState(
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])
@@ -644,6 +583,7 @@ export function useRenderingNodeState(
}, [id, incomingIds.length, nodes, setNodes, setEdges])
const sourceData = useMemo(() => (srcNode?.data as Record<string, unknown>) ?? {}, [srcNode?.data])
const sourceNodeType = (srcNode?.type as string) ?? null
return {
incomingIds,
@@ -670,13 +610,9 @@ export function useRenderingNodeState(
displayContent,
viewportWidth,
viewportHeight,
downloadSvg,
downloadPng,
copySvg,
copyPng,
setUpdateMode,
outputMenuDescriptor,
emptyStateAction,
sourceData,
sourceNodeType,
}
}

View File

@@ -1,7 +1,6 @@
/**
* 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.
* Output view components: "Display" step of the pipeline (see lib/graph/rendering.ts).
* Which view is used comes from the source's outputType ('image' | 'html').
*/
export { ImageOutputView } from './ImageOutputView'