feat: refactoring rendering
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@ export function getAgentNodeDescriptor(): NodeTypeDescriptor {
|
||||
.connectionLabel('prompt/context')
|
||||
.withFullscreen()
|
||||
.sourceRenderingLogic(agentRenderingLogic)
|
||||
.outputMenuContent(() => null)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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'
|
||||
|
||||
73
frontend/src/components/nodes/render/outputMenuRegistry.tsx
Normal file
73
frontend/src/components/nodes/render/outputMenuRegistry.tsx
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string>
|
||||
/** Optional. Output menu items for the Rendering node when this type is shown (e.g. Export submenu for image types). */
|
||||
outputMenuDescriptor?: OutputMenuDescriptor
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, NodeTypeDescriptor>()
|
||||
|
||||
@@ -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', <ScrollText />)
|
||||
* .connectionLabel('adding input')
|
||||
* .withFullscreen()
|
||||
* createNodeTypeBuilder('config', ConfigNode, ...)
|
||||
* .sourceRenderingLogic({ defaultUpdateMode: 'auto', getResolvedContent: ... })
|
||||
* .outputMenuContent((outputTypeId, ctx) => <ExportSubmenu ... />)
|
||||
* .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
|
||||
}
|
||||
}
|
||||
|
||||
105
frontend/src/lib/graph/rendering.ts
Normal file
105
frontend/src/lib/graph/rendering.ts
Normal file
@@ -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<IResolveResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>
|
||||
/** 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'
|
||||
@@ -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<ResolvedContentResult>
|
||||
/** Optional. When the renderer displays this source's output, which extra output menu items to show (e.g. Export SVG/PNG). */
|
||||
getOutputMenuDescriptor?: (outputTypeId: ConfigTypeId) => OutputMenuDescriptor | null
|
||||
}
|
||||
|
||||
const registry = new Map<string, SourceRenderingLogic>()
|
||||
|
||||
Reference in New Issue
Block a user