Compare commits
2 Commits
2a0507ca02
...
084863909a
| Author | SHA1 | Date | |
|---|---|---|---|
| 084863909a | |||
| 79e4586ed1 |
@@ -1,5 +1,6 @@
|
|||||||
import React, { useCallback, useContext } from 'react'
|
import React, { useCallback, useContext, useMemo } from 'react'
|
||||||
import FlowContext from '@/lib/graph/flowContext'
|
import FlowContext from '@/lib/graph/flowContext'
|
||||||
|
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||||
import {
|
import {
|
||||||
Menubar,
|
Menubar,
|
||||||
MenubarContent,
|
MenubarContent,
|
||||||
@@ -36,7 +37,7 @@ type Props = {
|
|||||||
dataMenuContent?: React.ReactNode
|
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 ctx = useContext(FlowContext)
|
||||||
const nodes = ctx?.nodes ?? []
|
const nodes = ctx?.nodes ?? []
|
||||||
const setNodes = ctx?.setNodes
|
const setNodes = ctx?.setNodes
|
||||||
@@ -44,6 +45,10 @@ export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuCon
|
|||||||
|
|
||||||
const edges = ctx?.edges ?? []
|
const edges = ctx?.edges ?? []
|
||||||
const node = nodes.find((n: any) => n.id === nodeId)
|
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 hasEdit = nodeType === 'config' || nodeType === 'function'
|
||||||
const hasConnectedNodes = edges.some((e: any) => e.target === nodeId)
|
const hasConnectedNodes = edges.some((e: any) => e.target === nodeId)
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ export function getAgentNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
.connectionLabel('prompt/context')
|
.connectionLabel('prompt/context')
|
||||||
.withFullscreen()
|
.withFullscreen()
|
||||||
.sourceRenderingLogic(agentRenderingLogic)
|
.sourceRenderingLogic(agentRenderingLogic)
|
||||||
|
.outputMenuContent(() => null)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Agent node rendering logic: when the Rendering node runs, it calls the agent API
|
* Agent node rendering logic: implements the "Resolve" step for agent sources.
|
||||||
* and updates the agent node's output. Update mode is manual (user clicks Run on the renderer).
|
* 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 { getConfigContent } from '@/lib/graph/rendering'
|
||||||
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic'
|
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||||
import type { AiConnection } from '@/app/kosmos/KosmosContext'
|
import type { AiConnection } from '@/app/kosmos/KosmosContext'
|
||||||
|
|
||||||
function serializeNodeForContext(
|
function serializeNodeForContext(
|
||||||
|
|||||||
@@ -2,12 +2,62 @@ import React from 'react'
|
|||||||
import { ScrollText } from 'lucide-react'
|
import { ScrollText } from 'lucide-react'
|
||||||
import { createNodeTypeBuilder } from '@/lib/graph/nodeTypeBuilder'
|
import { createNodeTypeBuilder } from '@/lib/graph/nodeTypeBuilder'
|
||||||
import { NODE_HELP } from '@/lib/graph/nodeHelp'
|
import { NODE_HELP } from '@/lib/graph/nodeHelp'
|
||||||
|
import { getConfigType } from '@/lib/graph/rendering'
|
||||||
|
import {
|
||||||
|
MenubarItem,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubContent,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
} from '@/components/ui/menubar'
|
||||||
import { getResolvedContentForConfig } from './renderingLogic'
|
import { getResolvedContentForConfig } from './renderingLogic'
|
||||||
import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry'
|
import type { NodeTypeDescriptor } from '@/lib/graph/nodeRegistry'
|
||||||
import ConfigNode from './ConfigNode'
|
import ConfigNode from './ConfigNode'
|
||||||
|
import { createImageExportHandlers, type OutputMenuContext } from '@/components/nodes/render/outputMenuRegistry'
|
||||||
|
|
||||||
const ICON_CLASS = 'mr-2 h-4 w-4'
|
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 {
|
export function getConfigNodeDescriptor(): NodeTypeDescriptor {
|
||||||
return createNodeTypeBuilder(
|
return createNodeTypeBuilder(
|
||||||
'config',
|
'config',
|
||||||
@@ -38,5 +88,6 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor {
|
|||||||
defaultUpdateMode: 'auto',
|
defaultUpdateMode: 'auto',
|
||||||
getResolvedContent: getResolvedContentForConfig,
|
getResolvedContent: getResolvedContentForConfig,
|
||||||
})
|
})
|
||||||
|
.outputMenuContent(configOutputMenuContent)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* Config node rendering logic: resolves Nunjucks (extends/include/import, variables, data, functions)
|
* Config node rendering logic: implements the "Resolve" step for config sources.
|
||||||
* and returns the resolved string and output type for the Rendering node.
|
* 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 nunjucks from 'nunjucks'
|
||||||
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic'
|
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||||
import { getConfigContent, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/configTypes'
|
import { getConfigContent, getConfigType, getConfigTypeId, type ConfigTypeId } from '@/lib/graph/rendering'
|
||||||
|
|
||||||
type Node = { id: string; type?: string; data?: unknown }
|
type Node = { id: string; type?: string; data?: unknown }
|
||||||
type Edge = { id: string; source: string; target: string }
|
type Edge = { id: string; source: string; target: string }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,4 @@
|
|||||||
export { default as RenderingNode, type RenderingNodeData } from './RenderingNode'
|
export { default as RenderingNode, type RenderingNodeData } from './RenderingNode'
|
||||||
export { getRenderNodeDescriptor } from './descriptor'
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
618
frontend/src/components/nodes/render/useRenderingNodeState.ts
Normal file
618
frontend/src/components/nodes/render/useRenderingNodeState.ts
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
/**
|
||||||
|
* 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 FlowContext from '@/lib/graph/flowContext'
|
||||||
|
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 {
|
||||||
|
getConfigContent,
|
||||||
|
getConfigType,
|
||||||
|
getConfigTypeId,
|
||||||
|
getSourceRenderingLogic,
|
||||||
|
parseThinkSections,
|
||||||
|
processSvgDisplay,
|
||||||
|
stripTemplateSyntax,
|
||||||
|
} from '@/lib/graph/rendering'
|
||||||
|
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
id: string,
|
||||||
|
data: RenderingNodeData | undefined
|
||||||
|
): RenderingNodeState {
|
||||||
|
const {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
sourceIds: incomingIds,
|
||||||
|
updateData,
|
||||||
|
} = useAbstractNode<RenderingNodeData>(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<string, unknown> | undefined)
|
||||||
|
: isAgentSource
|
||||||
|
? 'markdown'
|
||||||
|
: 'plantuml'
|
||||||
|
const configType = getConfigType(configTypeId)
|
||||||
|
const outputType = configType.outputType
|
||||||
|
const sourceContent =
|
||||||
|
srcNode?.type === 'config'
|
||||||
|
? getConfigContent((srcNode?.data ?? undefined) as Record<string, unknown> | undefined)
|
||||||
|
: isAgentSource
|
||||||
|
? agentOutputMarkdown
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const connectedNodeIds = useMemo(() => {
|
||||||
|
const out = new Set<string>()
|
||||||
|
const isReachable = (startId: string, targetId: string) => {
|
||||||
|
const q: string[] = [startId]
|
||||||
|
const seen = new Set<string>([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<string>) => {
|
||||||
|
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<string, unknown> | 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<string>()
|
||||||
|
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<string, unknown>)}`)
|
||||||
|
.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<string | null>(
|
||||||
|
() => (data?.cachedRenderedContent as string | undefined) ?? null
|
||||||
|
)
|
||||||
|
const [resolvedContent, setResolvedContent] = useState<string | null>(
|
||||||
|
() => (data?.cachedResolvedContent as string | undefined) ?? null
|
||||||
|
)
|
||||||
|
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [streamingMarkdown, setStreamingMarkdown] = useState<string | null>(null)
|
||||||
|
const [streamingPreviewHtml, setStreamingPreviewHtml] = useState<string>('')
|
||||||
|
const [reasoningContent, setReasoningContent] = useState<string>(
|
||||||
|
() => (data?.cachedReasoningContent as string | undefined) ?? ''
|
||||||
|
)
|
||||||
|
const [reasoningHtml, setReasoningHtml] = useState<string>('')
|
||||||
|
const [retryCount, setRetryCountState] = useState(0)
|
||||||
|
|
||||||
|
const runIdRef = useRef(0)
|
||||||
|
const loadingStartedAtRef = useRef<number | null>(null)
|
||||||
|
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 {
|
||||||
|
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
|
||||||
|
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<string>)(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<string>)(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() && /<svg[\s>]/i.test(renderedContent.trim())
|
||||||
|
)
|
||||||
|
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 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<string, unknown>) ?? {}, [srcNode?.data])
|
||||||
|
const sourceNodeType = (srcNode?.type as string) ?? null
|
||||||
|
|
||||||
|
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,
|
||||||
|
setUpdateMode,
|
||||||
|
emptyStateAction,
|
||||||
|
sourceData,
|
||||||
|
sourceNodeType,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="rendering-viewport nodrag nopan relative min-h-0 flex-1 w-full min-w-0 overflow-hidden bg-white dark:bg-secondary rounded outline-none"
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={onViewportFocus}
|
||||||
|
onBlur={onViewportBlur}
|
||||||
|
>
|
||||||
|
<TransformWrapper
|
||||||
|
initialScale={1}
|
||||||
|
initialPositionX={0}
|
||||||
|
initialPositionY={0}
|
||||||
|
minScale={0.2}
|
||||||
|
maxScale={4}
|
||||||
|
centerOnInit={false}
|
||||||
|
panning={{ disabled: !selected && !viewportFocused }}
|
||||||
|
wheel={{ disabled: !selected && !viewportFocused }}
|
||||||
|
doubleClick={{ disabled: !selected && !viewportFocused }}
|
||||||
|
>
|
||||||
|
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||||
|
<>
|
||||||
|
<div className="react-flow__controls absolute bottom-2 left-2 z-10 nodrag nopan">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomIn()}
|
||||||
|
className="react-flow__controls-button"
|
||||||
|
title="Zoom in"
|
||||||
|
>
|
||||||
|
<ZoomIn className="size-3 max-w-[12px] max-h-[12px]" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => zoomOut()}
|
||||||
|
className="react-flow__controls-button"
|
||||||
|
title="Zoom out"
|
||||||
|
>
|
||||||
|
<ZoomOut className="size-3 max-w-[12px] max-h-[12px]" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => resetTransform()}
|
||||||
|
className="react-flow__controls-button"
|
||||||
|
title="Reset view (fit all)"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 nodrag nopan overflow-hidden [&_.react-transform-component]:!w-full [&_.react-transform-component]:!h-full [&_.react-transform-wrapper]:!w-full [&_.react-transform-wrapper]:!h-full">
|
||||||
|
<TransformComponent
|
||||||
|
wrapperClass="!w-full !h-full"
|
||||||
|
contentClass="nodrag nopan !w-full !h-full !block !min-h-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rendering-diagram absolute inset-0 w-full h-full min-w-0 min-h-0 nodrag nopan"
|
||||||
|
dangerouslySetInnerHTML={{ __html: state.displayContent ?? '' }}
|
||||||
|
/>
|
||||||
|
</TransformComponent>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TransformWrapper>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||||
|
{state.streamingThinkSplit.think ? (
|
||||||
|
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||||
|
Thinking
|
||||||
|
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
{state.streamingPreviewHtml ? (
|
||||||
|
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.think }} />
|
||||||
|
) : (
|
||||||
|
<pre className="rendering-markdown max-h-48 overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans text-muted-foreground">
|
||||||
|
{state.streamingThinkSplit.think}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
) : null}
|
||||||
|
{state.streamingPreviewHtml ? (
|
||||||
|
<div className={MARKDOWN_MAIN_CLASS} dangerouslySetInnerHTML={{ __html: state.streamingThinkSplit.main || state.streamingPreviewHtml }} />
|
||||||
|
) : (
|
||||||
|
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
|
||||||
|
{state.streamingThinkSplit.main || state.streamingMarkdown}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||||
|
{state.reasoningHtml ? (
|
||||||
|
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||||
|
Reasoning
|
||||||
|
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.reasoningHtml }} />
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
) : null}
|
||||||
|
{state.renderedThinkSplit.think ? (
|
||||||
|
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||||
|
Thinking
|
||||||
|
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className={MARKDOWN_CLASS + ' max-h-48 overflow-auto'} dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.think }} />
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
) : null}
|
||||||
|
<div className={MARKDOWN_MAIN_CLASS} dangerouslySetInnerHTML={{ __html: state.renderedThinkSplit.main }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
frontend/src/components/nodes/render/views/RawOutputView.tsx
Normal file
65
frontend/src/components/nodes/render/views/RawOutputView.tsx
Normal file
@@ -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<HTMLDivElement | null>
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2 z-10 h-7 w-7 shrink-0 rounded-md shadow-sm"
|
||||||
|
onClick={() => {
|
||||||
|
const text = state.rawDisplayContent
|
||||||
|
if (text) {
|
||||||
|
navigator.clipboard
|
||||||
|
?.writeText(text)
|
||||||
|
.then(() => toast.success('Copied to clipboard'))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!state.rawDisplayContent}
|
||||||
|
title="Copy raw output"
|
||||||
|
>
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<CodeMirror
|
||||||
|
value={state.rawDisplayContent}
|
||||||
|
height={`${height}px`}
|
||||||
|
theme={theme}
|
||||||
|
extensions={extensions}
|
||||||
|
readOnly
|
||||||
|
editable={false}
|
||||||
|
basicSetup={{ lineNumbers: true, foldGutter: false }}
|
||||||
|
className="text-xs [&_.cm-editor]:outline-none [&_.cm-editor]:cursor-text [&_.cm-gutters]:border-0 [&_.cm-scroller]:min-h-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
frontend/src/components/nodes/render/views/index.ts
Normal file
12
frontend/src/components/nodes/render/views/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
export { MarkdownOutputView } from './MarkdownOutputView'
|
||||||
|
export { RawOutputView } from './RawOutputView'
|
||||||
|
|
||||||
|
export type { ImageOutputViewProps } from './ImageOutputView'
|
||||||
|
export type { MarkdownOutputViewProps } from './MarkdownOutputView'
|
||||||
|
export type { RawOutputViewProps } from './RawOutputView'
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Registry of config node types. Each type defines syntax highlighting,
|
* Config (output) types: render-step contract and registry.
|
||||||
* 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.
|
* 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'
|
export type ConfigTypeId = 'plantuml' | 'markdown' | 'wireframe'
|
||||||
@@ -24,17 +26,29 @@ 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. */
|
/** How the renderer should display this type: HTML in a div, or image (SVG/PNG) in a viewport. */
|
||||||
export type ConfigOutputType = 'html' | 'image'
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Config type: implements {@link IOutputTypeRenderer} and adds editor/insert options for ConfigNode. */
|
||||||
export type ConfigType = {
|
export type ConfigType = {
|
||||||
id: ConfigTypeId
|
id: ConfigTypeId
|
||||||
label: string
|
label: string
|
||||||
/** How the RenderingNode should display output: 'html' (div) or 'image' (viewport). */
|
|
||||||
outputType: ConfigOutputType
|
outputType: ConfigOutputType
|
||||||
/** CodeMirror language key; used to pick the extension in ConfigNode. */
|
|
||||||
language: ConfigTypeId
|
language: ConfigTypeId
|
||||||
/** Options under Insert → [type-specific]. Can be flat blocks or groups. */
|
|
||||||
insertBlocks: InsertBlockOrGroup[]
|
insertBlocks: InsertBlockOrGroup[]
|
||||||
/** Render resolved content (after Nunjucks) to HTML/SVG string for the renderer. */
|
|
||||||
render: (content: string, options?: RenderOptions) => Promise<string>
|
render: (content: string, options?: RenderOptions) => Promise<string>
|
||||||
|
outputMenuDescriptor?: OutputMenuDescriptor
|
||||||
}
|
}
|
||||||
|
|
||||||
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
const KROKI_PLANTUML_SVG = '/api/kroki/plantuml/svg'
|
||||||
@@ -169,6 +183,15 @@ export const CONFIG_TYPES: ConfigType[] = [
|
|||||||
throw err
|
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',
|
id: 'markdown',
|
||||||
@@ -185,6 +208,15 @@ export const CONFIG_TYPES: ConfigType[] = [
|
|||||||
language: 'markdown',
|
language: 'markdown',
|
||||||
insertBlocks: WIREFRAME_INSERT_BLOCKS,
|
insertBlocks: WIREFRAME_INSERT_BLOCKS,
|
||||||
render: renderWireframe,
|
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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Extensible node type registry. Register node types with registerNodeType() or use NodeTypeBuilder.
|
* Extensible node type registry. The descriptor is the central place for what a node type uses:
|
||||||
* Built-in types are registered in registerBuiltinNodes.ts.
|
* rendering logic (when it feeds the Renderer), Output menu content (when it is the Renderer's source),
|
||||||
* Use getRegisteredNodeTypes() / getNodeType(id) for defaults, validation, and UI.
|
* 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'
|
import type React from 'react'
|
||||||
@@ -48,8 +48,20 @@ export type NodeTypeDescriptor = {
|
|||||||
connectionLabel?: string
|
connectionLabel?: string
|
||||||
/** When true, double-clicking the node header opens a fullscreen dialog for this node. */
|
/** When true, double-clicking the node header opens a fullscreen dialog for this node. */
|
||||||
supportsFullscreen?: boolean
|
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
|
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>()
|
const registry = new Map<string, NodeTypeDescriptor>()
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Fluent builder for NodeTypeDescriptor. Use to define node types with common behavior
|
* Fluent builder for NodeTypeDescriptor. Use to define node types with common behavior,
|
||||||
* and optional source rendering logic (for types that feed the Renderer).
|
* 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:
|
* Example:
|
||||||
* createNodeTypeBuilder('config', ConfigNode, { width: 320, height: 320 }, { configType: 'plantuml', content: '', title: '' })
|
* createNodeTypeBuilder('config', ConfigNode, ...)
|
||||||
* .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()
|
|
||||||
* .sourceRenderingLogic({ defaultUpdateMode: 'auto', getResolvedContent: ... })
|
* .sourceRenderingLogic({ defaultUpdateMode: 'auto', getResolvedContent: ... })
|
||||||
|
* .outputMenuContent((outputTypeId, ctx) => <ExportSubmenu ... />)
|
||||||
* .build()
|
* .build()
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -116,6 +112,18 @@ export class NodeTypeBuilder {
|
|||||||
return this
|
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 {
|
build(): NodeTypeDescriptor {
|
||||||
const {
|
const {
|
||||||
idPrefix,
|
idPrefix,
|
||||||
@@ -125,6 +133,8 @@ export class NodeTypeBuilder {
|
|||||||
menuLabel,
|
menuLabel,
|
||||||
menuIcon,
|
menuIcon,
|
||||||
sourceRenderingLogic,
|
sourceRenderingLogic,
|
||||||
|
getOutputMenuContent,
|
||||||
|
getNodeMenuExtraContent,
|
||||||
...rest
|
...rest
|
||||||
} = this.partial
|
} = this.partial
|
||||||
if (idPrefix == null || hasInput == null || hasOutput == null || !help || !menuLabel || menuIcon == null) {
|
if (idPrefix == null || hasInput == null || hasOutput == null || !help || !menuLabel || menuIcon == null) {
|
||||||
@@ -145,9 +155,9 @@ export class NodeTypeBuilder {
|
|||||||
menuIcon,
|
menuIcon,
|
||||||
...rest,
|
...rest,
|
||||||
}
|
}
|
||||||
if (sourceRenderingLogic != null) {
|
if (sourceRenderingLogic != null) descriptor.sourceRenderingLogic = sourceRenderingLogic
|
||||||
descriptor.sourceRenderingLogic = sourceRenderingLogic
|
if (getOutputMenuContent != null) descriptor.getOutputMenuContent = getOutputMenuContent
|
||||||
}
|
if (getNodeMenuExtraContent != null) descriptor.getNodeMenuExtraContent = getNodeMenuExtraContent
|
||||||
return descriptor
|
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'
|
||||||
39
frontend/src/lib/graph/renderingUtils.ts
Normal file
39
frontend/src/lib/graph/renderingUtils.ts
Normal file
@@ -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 <think>...</think> blocks from HTML/markdown; return main (with blocks removed) and think for collapsible. */
|
||||||
|
export function parseThinkSections(html: string): { main: string; think: string } {
|
||||||
|
const thinkRegex = /<think>([\s\S]*?)<\/think>/gi
|
||||||
|
const thinkParts: string[] = []
|
||||||
|
let match
|
||||||
|
while ((match = thinkRegex.exec(html)) !== null) thinkParts.push(match[1].trim())
|
||||||
|
const think = thinkParts.join('\n\n')
|
||||||
|
const main = html.replace(thinkRegex, '').replace(/\n{3,}/g, '\n\n').trim()
|
||||||
|
return { main, think }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Source rendering logic: each node type that can feed the Rendering node
|
* Source rendering logic: resolve-step contract and registry.
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* Register in registerBuiltinNodes (or at app init) via registerSourceRenderingLogic(nodeType, logic).
|
* Implements the "Resolve" step of the rendering pipeline (see lib/graph/rendering.ts).
|
||||||
* Node types that are allowed sources for the Renderer should register here (see nodeRegistry NodeTypeDescriptor).
|
* Each node type that can feed the Rendering node registers an implementation of
|
||||||
|
* {@link IRenderingSource} via registerSourceRenderingLogic(nodeType, logic).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ConfigTypeId } 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 = {
|
export type SourceRenderingLogicContext = {
|
||||||
nodes: { id: string; type?: string; data?: unknown }[]
|
nodes: { id: string; type?: string; data?: unknown }[]
|
||||||
edges: { id: string; source: string; target: string }[]
|
edges: { id: string; source: string; target: string }[]
|
||||||
@@ -16,32 +17,20 @@ export type SourceRenderingLogicContext = {
|
|||||||
renderNodeId: string
|
renderNodeId: string
|
||||||
viewportWidth?: number
|
viewportWidth?: number
|
||||||
viewportHeight?: 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
|
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
|
aiConnection?: unknown
|
||||||
/** Optional: called when agent streaming starts (Rendering node can show streaming preview). */
|
|
||||||
onStreamingStart?: () => void
|
onStreamingStart?: () => void
|
||||||
/** Optional: called with each text chunk during agent stream (Rendering node can update preview). */
|
|
||||||
onStreamingChunk?: (chunk: string) => void
|
onStreamingChunk?: (chunk: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Result of the resolve step. See {@link IResolveResult}. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export type ResolvedContentResult = {
|
export type ResolvedContentResult = {
|
||||||
resolved: string
|
resolved: string
|
||||||
outputTypeId: ConfigTypeId
|
outputTypeId: ConfigTypeId
|
||||||
/** Optional reasoning section (e.g. agent with reasoning enabled); renderer shows it in a collapsible. */
|
|
||||||
reasoning?: string
|
reasoning?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Source logic implementation. Implements {@link IRenderingSource}. Output menu content is provided per output type via the frontend registry (see outputMenuRegistry). */
|
||||||
* 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
|
|
||||||
*/
|
|
||||||
export type SourceRenderingLogic = {
|
export type SourceRenderingLogic = {
|
||||||
defaultUpdateMode: 'auto' | 'manual'
|
defaultUpdateMode: 'auto' | 'manual'
|
||||||
getResolvedContent: (context: SourceRenderingLogicContext) => Promise<ResolvedContentResult>
|
getResolvedContent: (context: SourceRenderingLogicContext) => Promise<ResolvedContentResult>
|
||||||
|
|||||||
Reference in New Issue
Block a user