From 084863909a2278cbc0b534dbe86dc9bc2a792cf9 Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 12 Mar 2026 16:56:53 +0100 Subject: [PATCH] feat: refactoring rendering --- frontend/src/components/graph/NodeMenubar.tsx | 9 +- .../src/components/nodes/agent/descriptor.tsx | 1 + .../components/nodes/agent/renderingLogic.ts | 10 +- .../components/nodes/config/descriptor.tsx | 54 ++++++++- .../components/nodes/config/renderingLogic.ts | 16 +-- .../components/nodes/render/RenderingNode.tsx | 40 +------ frontend/src/components/nodes/render/index.ts | 2 + .../nodes/render/outputMenuRegistry.tsx | 73 ++++++++++++ .../nodes/render/useRenderingNodeState.ts | 102 ++++------------- .../components/nodes/render/views/index.ts | 5 +- frontend/src/lib/graph/configTypes.ts | 14 +-- frontend/src/lib/graph/nodeRegistry.ts | 20 +++- frontend/src/lib/graph/nodeTypeBuilder.ts | 40 ++++--- frontend/src/lib/graph/rendering.ts | 105 ++++++++++++++++++ .../src/lib/graph/sourceRenderingLogic.ts | 32 ++---- 15 files changed, 327 insertions(+), 196 deletions(-) create mode 100644 frontend/src/components/nodes/render/outputMenuRegistry.tsx create mode 100644 frontend/src/lib/graph/rendering.ts diff --git a/frontend/src/components/graph/NodeMenubar.tsx b/frontend/src/components/graph/NodeMenubar.tsx index 20d4ba5..e695f5c 100644 --- a/frontend/src/components/graph/NodeMenubar.tsx +++ b/frontend/src/components/graph/NodeMenubar.tsx @@ -1,5 +1,6 @@ -import React, { useCallback, useContext } from 'react' +import React, { useCallback, useContext, useMemo } from 'react' import FlowContext from '@/lib/graph/flowContext' +import { getNodeType } from '@/lib/graph/nodeRegistry' import { Menubar, MenubarContent, @@ -36,7 +37,7 @@ type Props = { dataMenuContent?: React.ReactNode } -export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent, outputMenuContent, dataMenuContent }: Props) { +export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) { const ctx = useContext(FlowContext) const nodes = ctx?.nodes ?? [] const setNodes = ctx?.setNodes @@ -44,6 +45,10 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon const edges = ctx?.edges ?? [] const node = nodes.find((n: any) => n.id === nodeId) + const nodeMenuExtraContent = useMemo( + () => nodeMenuExtraContentProp ?? getNodeType(nodeType)?.getNodeMenuExtraContent?.(nodeId, node?.data ?? {}), + [nodeMenuExtraContentProp, nodeType, nodeId, node?.data] + ) const hasEdit = nodeType === 'config' || nodeType === 'function' const hasConnectedNodes = edges.some((e: any) => e.target === nodeId) diff --git a/frontend/src/components/nodes/agent/descriptor.tsx b/frontend/src/components/nodes/agent/descriptor.tsx index 4aa5bd7..26f9c5a 100644 --- a/frontend/src/components/nodes/agent/descriptor.tsx +++ b/frontend/src/components/nodes/agent/descriptor.tsx @@ -20,5 +20,6 @@ export function getAgentNodeDescriptor(): NodeTypeDescriptor { .connectionLabel('prompt/context') .withFullscreen() .sourceRenderingLogic(agentRenderingLogic) + .outputMenuContent(() => null) .build() } diff --git a/frontend/src/components/nodes/agent/renderingLogic.ts b/frontend/src/components/nodes/agent/renderingLogic.ts index 081c65a..6dfc039 100644 --- a/frontend/src/components/nodes/agent/renderingLogic.ts +++ b/frontend/src/components/nodes/agent/renderingLogic.ts @@ -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 => { 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 8e408a1..7319243 100644 --- a/frontend/src/components/nodes/config/descriptor.tsx +++ b/frontend/src/components/nodes/config/descriptor.tsx @@ -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 ( + <> + + + + {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 ( + + {item.label} + + ) + })} + + + + ) +} + 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() } diff --git a/frontend/src/components/nodes/config/renderingLogic.ts b/frontend/src/components/nodes/config/renderingLogic.ts index 27ad9cc..f492bcd 100644 --- a/frontend/src/components/nodes/config/renderingLogic.ts +++ b/frontend/src/components/nodes/config/renderingLogic.ts @@ -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 -} diff --git a/frontend/src/components/nodes/render/RenderingNode.tsx b/frontend/src/components/nodes/render/RenderingNode.tsx index 197840d..fded9ab 100644 --- a/frontend/src/components/nodes/render/RenderingNode.tsx +++ b/frontend/src/components/nodes/render/RenderingNode.tsx @@ -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 - {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} - - ) - })} - - - - )} + {getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })} } /> diff --git a/frontend/src/components/nodes/render/index.ts b/frontend/src/components/nodes/render/index.ts index c1cbc1f..64053ee 100644 --- a/frontend/src/components/nodes/render/index.ts +++ b/frontend/src/components/nodes/render/index.ts @@ -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' diff --git a/frontend/src/components/nodes/render/outputMenuRegistry.tsx b/frontend/src/components/nodes/render/outputMenuRegistry.tsx new file mode 100644 index 0000000..3549f29 --- /dev/null +++ b/frontend/src/components/nodes/render/outputMenuRegistry.tsx @@ -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 + }, + } +} diff --git a/frontend/src/components/nodes/render/useRenderingNodeState.ts b/frontend/src/components/nodes/render/useRenderingNodeState.ts index 4815515..d500c39 100644 --- a/frontend/src/components/nodes/render/useRenderingNodeState.ts +++ b/frontend/src/components/nodes/render/useRenderingNodeState.ts @@ -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 hook’s 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 + + /** 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() && /]/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) ?? {}, [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, } } diff --git a/frontend/src/components/nodes/render/views/index.ts b/frontend/src/components/nodes/render/views/index.ts index 6326ded..aa037fd 100644 --- a/frontend/src/components/nodes/render/views/index.ts +++ b/frontend/src/components/nodes/render/views/index.ts @@ -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' diff --git a/frontend/src/lib/graph/configTypes.ts b/frontend/src/lib/graph/configTypes.ts index b429141..16b864a 100644 --- a/frontend/src/lib/graph/configTypes.ts +++ b/frontend/src/lib/graph/configTypes.ts @@ -1,7 +1,9 @@ /** - * Registry of config node types. Each type defines syntax highlighting, - * insert-menu blocks, and how to render the content (after Nunjucks) for the renderer. - * Add a new entry here and wire its language in ConfigNode to add a new config type. + * Config (output) types: render-step contract and registry. + * + * Each type implements the "Render" step of the pipeline (see lib/graph/rendering.ts): + * it turns resolved content into HTML/SVG and declares how to display it (image vs html). + * Also used by ConfigNode for syntax, insert blocks, and language. */ export type ConfigTypeId = 'plantuml' | 'markdown' | 'wireframe' @@ -38,18 +40,14 @@ export function outputMenuActionRequiresSvg(action: OutputMenuAction): boolean { return action === 'downloadSvg' || action === 'downloadPng' || action === 'copySvg' || action === 'copyPng' } +/** Config type: implements {@link IOutputTypeRenderer} and adds editor/insert options for ConfigNode. */ export type ConfigType = { id: ConfigTypeId label: string - /** How the RenderingNode should display output: 'html' (div) or 'image' (viewport). */ outputType: ConfigOutputType - /** CodeMirror language key; used to pick the extension in ConfigNode. */ language: ConfigTypeId - /** Options under Insert → [type-specific]. Can be flat blocks or groups. */ 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 } diff --git a/frontend/src/lib/graph/nodeRegistry.ts b/frontend/src/lib/graph/nodeRegistry.ts index 432ab76..b292de2 100644 --- a/frontend/src/lib/graph/nodeRegistry.ts +++ b/frontend/src/lib/graph/nodeRegistry.ts @@ -1,7 +1,7 @@ /** - * Extensible node type registry. Register node types with registerNodeType() or use NodeTypeBuilder. - * Built-in types are registered in registerBuiltinNodes.ts. - * Use getRegisteredNodeTypes() / getNodeType(id) for defaults, validation, and UI. + * Extensible node type registry. The descriptor is the central place for what a node type uses: + * rendering logic (when it feeds the Renderer), Output menu content (when it is the Renderer's source), + * and any extra Node menu content. Use the builder in each node's descriptor so all behavior is defined in one place. */ import type React from 'react' @@ -48,8 +48,20 @@ export type NodeTypeDescriptor = { connectionLabel?: string /** When true, double-clicking the node header opens a fullscreen dialog for this node. */ supportsFullscreen?: boolean - /** When set, this type can feed the Renderer; registration will also register source rendering logic. */ + /** + * When set, this type can feed the Renderer; registration will also register source rendering logic. + * Define resolve step and default update mode here. + */ sourceRenderingLogic?: SourceRenderingLogic + /** + * When this type is the Renderer's source, provide Output menu content (e.g. Export submenu). + * Called with outputTypeId (e.g. 'plantuml') and context (state + render nodeId). Return null for no extra menu. + */ + getOutputMenuContent?: (outputTypeId: string, ctx: unknown) => React.ReactNode + /** + * Optional extra content in the Node menu (before Delete). Use for type-specific actions. + */ + getNodeMenuExtraContent?: (nodeId: string, data: unknown) => React.ReactNode } const registry = new Map() diff --git a/frontend/src/lib/graph/nodeTypeBuilder.ts b/frontend/src/lib/graph/nodeTypeBuilder.ts index a259eef..0f6a447 100644 --- a/frontend/src/lib/graph/nodeTypeBuilder.ts +++ b/frontend/src/lib/graph/nodeTypeBuilder.ts @@ -1,19 +1,15 @@ /** - * Fluent builder for NodeTypeDescriptor. Use to define node types with common behavior - * and optional source rendering logic (for types that feed the Renderer). + * Fluent builder for NodeTypeDescriptor. Use to define node types with common behavior, + * rendering logic, and menus in one place. Looking at the builder chain shows everything the node uses. + * + * - sourceRenderingLogic: when this type feeds the Renderer (resolve step, update mode). + * - outputMenuContent: when this type is the Renderer's source, provide Output menu (e.g. Export). + * - nodeMenuExtraContent: extra items in the Node menu (before Delete). * * Example: - * createNodeTypeBuilder('config', ConfigNode, { width: 320, height: 320 }, { configType: 'plantuml', content: '', title: '' }) - * .idPrefix('cfg_') - * .withInputOutput(true, true) - * .classification('psyche') - * .allowedSourceTypes(['config', 'variable', 'function', 'data']) - * .allowedTargetTypes(['config', 'render', 'agent']) - * .help(NODE_HELP.config) - * .menu('Config', ) - * .connectionLabel('adding input') - * .withFullscreen() + * createNodeTypeBuilder('config', ConfigNode, ...) * .sourceRenderingLogic({ defaultUpdateMode: 'auto', getResolvedContent: ... }) + * .outputMenuContent((outputTypeId, ctx) => ) * .build() */ @@ -116,6 +112,18 @@ export class NodeTypeBuilder { return this } + /** When this type is the Renderer's source, provide Output menu content (e.g. Export submenu). */ + outputMenuContent(fn: (outputTypeId: string, ctx: unknown) => React.ReactNode): this { + this.partial.getOutputMenuContent = fn + return this + } + + /** Extra content in the Node menu (before Delete). Use for type-specific actions. */ + nodeMenuExtraContent(fn: (nodeId: string, data: unknown) => React.ReactNode): this { + this.partial.getNodeMenuExtraContent = fn + return this + } + build(): NodeTypeDescriptor { const { idPrefix, @@ -125,6 +133,8 @@ export class NodeTypeBuilder { menuLabel, menuIcon, sourceRenderingLogic, + getOutputMenuContent, + getNodeMenuExtraContent, ...rest } = this.partial if (idPrefix == null || hasInput == null || hasOutput == null || !help || !menuLabel || menuIcon == null) { @@ -145,9 +155,9 @@ export class NodeTypeBuilder { menuIcon, ...rest, } - if (sourceRenderingLogic != null) { - descriptor.sourceRenderingLogic = sourceRenderingLogic - } + if (sourceRenderingLogic != null) descriptor.sourceRenderingLogic = sourceRenderingLogic + if (getOutputMenuContent != null) descriptor.getOutputMenuContent = getOutputMenuContent + if (getNodeMenuExtraContent != null) descriptor.getNodeMenuExtraContent = getNodeMenuExtraContent return descriptor } } diff --git a/frontend/src/lib/graph/rendering.ts b/frontend/src/lib/graph/rendering.ts new file mode 100644 index 0000000..55bbc1e --- /dev/null +++ b/frontend/src/lib/graph/rendering.ts @@ -0,0 +1,105 @@ +/** + * Rendering pipeline interface and public API. + * + * ## Pipeline (3 steps) + * + * 1. **Resolve** — A source node (config, agent) implements `IRenderingSource`. Given a + * `IRenderingContext` (graph, source id, callbacks), it produces a `IResolveResult`: + * resolved string, which output type to use, and optional reasoning text. + * + * 2. **Render** — The output type (e.g. plantuml, markdown, wireframe) implements + * `IOutputTypeRenderer`. It turns the resolved string into HTML or SVG via `render()`. + * Config types in configTypes.ts are the built-in implementations. + * + * 3. **Display** — The Rendering node picks a view (image viewport vs markdown content) + * from `outputType` on the renderer and shows the result. Output menu items (e.g. Export) + * come from the source/renderer via `outputMenuDescriptor`. + * + * ## Implementing a new source + * - Implement `IRenderingSource` (resolve + optional output menu). + * - Register with `registerSourceRenderingLogic(nodeType, logic)`. + * + * ## Implementing a new output type + * - Add a config type (or equivalent) fulfilling `IOutputTypeRenderer`. + * - Register in CONFIG_TYPES and use `getConfigType(id)` in the resolve step. + */ + +import type { + ConfigTypeId, + ConfigOutputType, + OutputMenuDescriptor, + OutputMenuItemDescriptor, + OutputMenuAction, + RenderOptions, +} from './configTypes' +import type { + SourceRenderingLogicContext, + ResolvedContentResult, + SourceRenderingLogic, +} from './sourceRenderingLogic' + +// --------------------------------------------------------------------------- +// Contracts (interfaces) +// --------------------------------------------------------------------------- + +/** Input to the resolve step: graph state and callbacks for the source to use. */ +export type IRenderingContext = SourceRenderingLogicContext + +/** + * Result of the resolve step. The source produces a resolved string and declares + * which output type (renderer) to use for the render step. + */ +export interface IResolveResult { + /** Resolved content (e.g. after Nunjucks, or agent output). */ + resolved: string + /** Which output type will render this (e.g. 'plantuml', 'markdown'). */ + outputTypeId: ConfigTypeId + /** Optional reasoning block for the UI (e.g. agent reasoning). */ + reasoning?: string +} + +/** + * Contract for a node type that can feed the Rendering node (config, agent, etc.). + * Register via registerSourceRenderingLogic(nodeType, logic). + * Output menu content is registered per output type in the frontend (outputMenuRegistry). + */ +export interface IRenderingSource { + /** When to re-run: 'auto' on upstream changes, 'manual' only on Run. */ + defaultUpdateMode: 'auto' | 'manual' + /** Resolve step: produce content and choose output type. */ + getResolvedContent(context: IRenderingContext): Promise +} + +/** + * Contract for an output type that turns resolved content into HTML/SVG. + * Config types (plantuml, markdown, wireframe) implement this. + */ +export interface IOutputTypeRenderer { + id: ConfigTypeId + /** How to display: 'image' (viewport) or 'html' (scrollable div). */ + outputType: ConfigOutputType + /** Render resolved content to HTML or SVG string. */ + render(content: string, options?: RenderOptions): Promise + /** Optional: output menu items when this type is shown (e.g. Export). */ + outputMenuDescriptor?: OutputMenuDescriptor +} + +// --------------------------------------------------------------------------- +// Re-exports: source logic (resolve step) +// --------------------------------------------------------------------------- + +export type { SourceRenderingLogic, ResolvedContentResult, SourceRenderingLogicContext } +export { registerSourceRenderingLogic, getSourceRenderingLogic } from './sourceRenderingLogic' + +// --------------------------------------------------------------------------- +// Re-exports: output types (render step) and menu +// --------------------------------------------------------------------------- + +export type { ConfigTypeId, ConfigOutputType, OutputMenuDescriptor, OutputMenuItemDescriptor, OutputMenuAction, RenderOptions } +export { getConfigType, getConfigTypeId, getConfigContent, outputMenuActionRequiresSvg } from './configTypes' + +// --------------------------------------------------------------------------- +// Re-exports: shared utils (pure helpers used by hook and views) +// --------------------------------------------------------------------------- + +export { parseThinkSections, processSvgDisplay, stripTemplateSyntax } from './renderingUtils' diff --git a/frontend/src/lib/graph/sourceRenderingLogic.ts b/frontend/src/lib/graph/sourceRenderingLogic.ts index 6d46c7e..d974f45 100644 --- a/frontend/src/lib/graph/sourceRenderingLogic.ts +++ b/frontend/src/lib/graph/sourceRenderingLogic.ts @@ -1,16 +1,15 @@ /** - * Source rendering logic: each node type that can feed the Rendering node - * registers how to get "resolved" content and default update behavior. - * The Rendering node uses this to run the right logic and respect auto vs manual updates. + * Source rendering logic: resolve-step contract and registry. * - * Register in registerBuiltinNodes (or at app init) via registerSourceRenderingLogic(nodeType, logic). - * Node types that are allowed sources for the Renderer should register here (see nodeRegistry NodeTypeDescriptor). + * Implements the "Resolve" step of the rendering pipeline (see lib/graph/rendering.ts). + * Each node type that can feed the Rendering node registers an implementation of + * {@link IRenderingSource} via registerSourceRenderingLogic(nodeType, logic). */ -import type { ConfigTypeId, OutputMenuDescriptor } from './configTypes' - +import type { ConfigTypeId } from './configTypes' export type { OutputMenuDescriptor, OutputMenuItemDescriptor, OutputMenuAction } from './configTypes' +/** Context passed into the resolve step (graph, source id, callbacks). See {@link IRenderingContext}. */ export type SourceRenderingLogicContext = { nodes: { id: string; type?: string; data?: unknown }[] edges: { id: string; source: string; target: string }[] @@ -18,38 +17,23 @@ export type SourceRenderingLogicContext = { renderNodeId: string viewportWidth?: number viewportHeight?: number - /** Optional: allows source logic to update other nodes (e.g. agent run updates agent node). */ setNodes?: (updater: (nodes: { id: string; type?: string; data?: unknown }[]) => { id: string; type?: string; data?: unknown }[]) => void - /** Optional: AI connection for agent source (used when Rendering node runs the agent). */ aiConnection?: unknown - /** Optional: called when agent streaming starts (Rendering node can show streaming preview). */ onStreamingStart?: () => void - /** Optional: called with each text chunk during agent stream (Rendering node can update preview). */ onStreamingChunk?: (chunk: string) => void } -/** - * Result of getResolvedContent: resolved string plus the config type to use for final render. - * When the source is an agent with reasoning enabled, reasoning may be set for a collapsible section. - */ +/** Result of the resolve step. See {@link IResolveResult}. */ export type ResolvedContentResult = { resolved: string outputTypeId: ConfigTypeId - /** Optional reasoning section (e.g. agent with reasoning enabled); renderer shows it in a collapsible. */ reasoning?: string } -/** - * 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) - */ +/** Source logic implementation. Implements {@link IRenderingSource}. Output menu content is provided per output type via the frontend registry (see outputMenuRegistry). */ 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()