feat: render image in markdown

This commit is contained in:
2026-03-14 22:58:57 +01:00
parent ce58673d18
commit 2326bbe035
7 changed files with 113 additions and 21 deletions

View File

@@ -13,7 +13,7 @@ export function getAgentNodeDescriptor(): NodeTypeDescriptor {
.idPrefix('agt_') .idPrefix('agt_')
.withInputOutput(true, true) .withInputOutput(true, true)
.classification('archon') .classification('archon')
.allowedSourceTypes(['config', 'variable', 'data']) .allowedSourceTypes(['config', 'variable', 'data', 'render'])
.allowedTargetTypes(['render']) .allowedTargetTypes(['render'])
.help(NODE_HELP.agent) .help(NODE_HELP.agent)
.menu('Agent', <Bot className={ICON_CLASS} />) .menu('Agent', <Bot className={ICON_CLASS} />)

View File

@@ -25,7 +25,7 @@ import {
BaseNodeFooter, BaseNodeFooter,
BaseNodeHeaderRow, BaseNodeHeaderRow,
} from '@/components/graph/BaseNode' } from '@/components/graph/BaseNode'
import { Code2, Database, ScrollText, Variable } from 'lucide-react' import { Code2, Database, ScrollText, Sparkles, Variable } from 'lucide-react'
import { import {
MenubarItem, MenubarItem,
MenubarSeparator, MenubarSeparator,
@@ -34,6 +34,7 @@ import {
MenubarSubContent, MenubarSubContent,
MenubarSubTrigger, MenubarSubTrigger,
} from '@/components/ui/menubar' } from '@/components/ui/menubar'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Kbd } from '@/components/ui/kbd' import { Kbd } from '@/components/ui/kbd'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles' import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
@@ -78,7 +79,16 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
() => getConnectedNodesByType(nodes, sourceIds, 'data'), () => getConnectedNodesByType(nodes, sourceIds, 'data'),
[nodes, sourceIds] [nodes, sourceIds]
) )
const hasDependencies = connectedConfigNodes.length > 0 || connectedVariableNodes.length > 0 || connectedFunctionNodes.length > 0 || connectedDataNodes.length > 0 const connectedRenderNodes = useMemo(
() => getConnectedNodesByType(nodes, sourceIds, 'render'),
[nodes, sourceIds]
)
const hasDependencies =
connectedConfigNodes.length > 0 ||
connectedVariableNodes.length > 0 ||
connectedFunctionNodes.length > 0 ||
connectedDataNodes.length > 0 ||
connectedRenderNodes.length > 0
const setConfigType = useCallback( const setConfigType = useCallback(
(newTypeId: ConfigTypeId) => { (newTypeId: ConfigTypeId) => {
@@ -133,6 +143,18 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
[insertAt] [insertAt]
) )
const insertRenderReference = useCallback(
(sourceNode: any, mode: 'prepend' | 'append' | 'cursor') => {
const isMarkdownImage =
configTypeId === 'markdown' && (sourceNode.data?.outputMode ?? 'image') === 'image'
const snippet = isMarkdownImage
? `![Rendered]({{ ${sourceNode.id} }})`
: `{{ ${sourceNode.id} }}`
insertAt(snippet, mode)
},
[insertAt, configTypeId]
)
const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes]) const variableIds = useMemo(() => connectedVariableNodes.map((n: any) => n.id), [connectedVariableNodes])
const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes]) const functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
const configTitles = useMemo( const configTitles = useMemo(
@@ -296,6 +318,40 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut> <MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity"><Kbd>Insert</Kbd></MenubarShortcut>
</MenubarItem> </MenubarItem>
))} ))}
{connectedRenderNodes.map((n: any) => {
const renderInputDisabled =
configTypeId === 'plantuml' || configTypeId === 'wireframe'
const item = (
<MenubarItem
key={n.id}
className="text-xs flex items-center gap-2 group"
disabled={renderInputDisabled}
onClick={() =>
!renderInputDisabled && insertRenderReference(n, 'cursor')
}
>
<Sparkles className="size-3.5 shrink-0" />
{n.id}
<MenubarShortcut className="opacity-0 group-hover:opacity-100 transition-opacity">
<Kbd>Insert</Kbd>
</MenubarShortcut>
</MenubarItem>
)
return renderInputDisabled ? (
<TooltipProvider key={n.id} delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>{item}</TooltipTrigger>
<TooltipContent side="right">
Rendering output is not available for Diagram and
Wireframe configs. Use a Markdown config to embed
render output.
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
item
)
})}
</> </>
) : ( ) : (
<span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span> <span className="text-xs text-muted-foreground px-2 py-1">Connect nodes to insert references</span>

View File

@@ -68,7 +68,7 @@ export function getConfigNodeDescriptor(): NodeTypeDescriptor {
.idPrefix('cfg_') .idPrefix('cfg_')
.withInputOutput(true, true) .withInputOutput(true, true)
.classification('psyche') .classification('psyche')
.allowedSourceTypes(['config', 'variable', 'function', 'data']) .allowedSourceTypes(['config', 'variable', 'function', 'data', 'render'])
.allowedTargetTypes(['config', 'render', 'agent']) .allowedTargetTypes(['config', 'render', 'agent'])
.help(NODE_HELP.config) .help(NODE_HELP.config)
.menu('Config', <ScrollText className={ICON_CLASS} />) .menu('Config', <ScrollText className={ICON_CLASS} />)

View File

@@ -74,6 +74,10 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
}) })
nunjucksContext[src.id] = filteredRows nunjucksContext[src.id] = filteredRows
} }
if (src?.type === 'render') {
nunjucksContext[src.id] =
((src.data as Record<string, unknown>)?.cachedOutputValue as string | undefined) ?? ''
}
} }
const functionIdsToRegister = new Set<string>() const functionIdsToRegister = new Set<string>()

View File

@@ -23,7 +23,7 @@ import {
MenubarSubTrigger, MenubarSubTrigger,
} from '@/components/ui/menubar' } from '@/components/ui/menubar'
import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react' import { Sparkles, Play, ChevronDown, Loader2, RotateCw } from 'lucide-react'
import { InputHandle } from '@/components/graph/NodeHandles' import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group' import { ButtonGroup } from '@/components/ui/button-group'
import { import {
@@ -50,16 +50,14 @@ export type { RenderingNodeData }
type Props = AbstractNodeProps<RenderingNodeData> type Props = AbstractNodeProps<RenderingNodeData>
type ViewMode = 'preview' | 'raw'
function RenderingNodeComponent({ id, data, width, height, selected }: Props) { function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const flowUIContext = useContext(FlowUIContext) const flowUIContext = useContext(FlowUIContext)
const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId const setFullscreenNodeId = flowUIContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('render')?.supportsFullscreen const supportsFullscreen = getNodeType('render')?.supportsFullscreen
const state = useRenderingNodeState(id, data) const state = useRenderingNodeState(id, data)
const outputMode = state.outputMode
const [viewMode, setViewMode] = useState<ViewMode>('preview')
const [viewportFocused, setViewportFocused] = useState(false) const [viewportFocused, setViewportFocused] = useState(false)
const dimensions = const dimensions =
@@ -68,7 +66,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
: undefined : undefined
const { theme } = useTheme() const { theme } = useTheme()
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode]) const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [outputMode])
const showEmpty = const showEmpty =
state.incomingIds.length === 0 && state.incomingIds.length === 0 &&
@@ -151,7 +149,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
resizable resizable
nodeId={id} nodeId={id}
selected={selected} selected={selected}
handles={<InputHandle id="ain" nodeId={id} />} handles={
<>
<InputHandle id="ain" nodeId={id} />
<OutputHandle id="out" />
</>
}
> >
<BaseNodeHeaderRow <BaseNodeHeaderRow
icon={<Sparkles className="size-4" />} icon={<Sparkles className="size-4" />}
@@ -264,17 +267,17 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
<> <>
<MenubarCheckboxItem <MenubarCheckboxItem
className="text-xs" className="text-xs"
checked={viewMode === 'preview'} checked={outputMode === 'image'}
onCheckedChange={(checked) => checked && setViewMode('preview')} onCheckedChange={(checked) => checked && state.setOutputMode('image')}
> >
Preview Image
</MenubarCheckboxItem> </MenubarCheckboxItem>
<MenubarCheckboxItem <MenubarCheckboxItem
className="text-xs" className="text-xs"
checked={viewMode === 'raw'} checked={outputMode === 'string'}
onCheckedChange={(checked) => checked && setViewMode('raw')} onCheckedChange={(checked) => checked && state.setOutputMode('string')}
> >
Raw String
</MenubarCheckboxItem> </MenubarCheckboxItem>
{getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })} {getNodeType(state.sourceNodeType ?? '')?.getOutputMenuContent?.(state.rawLanguage, { state, nodeId: id })}
</> </>
@@ -306,7 +309,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
</Empty> </Empty>
) : state.error ? ( ) : state.error ? (
errorUi errorUi
) : viewMode === 'raw' ? ( ) : outputMode === 'string' ? (
<RawOutputView <RawOutputView
state={state} state={state}
height={rawEditorHeight} height={rawEditorHeight}
@@ -321,12 +324,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
<BaseNodeFooter> <BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="render"> <NodeFooterEdgeIndicators nodeId={id} nodeType="render">
{viewMode === 'raw' {outputMode === 'string'
? state.rawDisplayContent ? state.rawDisplayContent
? `Raw · ${state.rawDisplayContent.length} chars` ? `String · ${state.rawDisplayContent.length} chars`
: '—' : '—'
: state.renderedContent : state.renderedContent
? `${state.outputLabel} · ${state.renderedContent.length} chars` ? `Image · ${state.renderedContent.length} chars`
: state.error : state.error
? 'Error' ? 'Error'
: '—'} : '—'}

View File

@@ -15,9 +15,10 @@ export function getRenderNodeDescriptor(): NodeTypeDescriptor {
{ viewportWidth: 1200, viewportHeight: 800 } { viewportWidth: 1200, viewportHeight: 800 }
) )
.idPrefix('rnd_') .idPrefix('rnd_')
.withInputOutput(true, false) .withInputOutput(true, true)
.classification('pneuma') .classification('pneuma')
.allowedSourceTypes(['config', 'agent']) .allowedSourceTypes(['config', 'agent'])
.allowedTargetTypes(['config', 'agent'])
.help(NODE_HELP.render) .help(NODE_HELP.render)
.menu('Renderer', <Sparkles className={ICON_CLASS} />) .menu('Renderer', <Sparkles className={ICON_CLASS} />)
.connectionLabelByStatus({ default: 'listening', updating: 'giving life', paused: 'pending', error: 'corrupted' }) .connectionLabelByStatus({ default: 'listening', updating: 'giving life', paused: 'pending', error: 'corrupted' })

View File

@@ -24,15 +24,21 @@ import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state' import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering' import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
export type OutputMode = 'image' | 'string'
export type RenderingNodeData = { export type RenderingNodeData = {
viewportWidth?: number viewportWidth?: number
viewportHeight?: number viewportHeight?: number
updateMode?: 'auto' | 'manual' updateMode?: 'auto' | 'manual'
runTrigger?: number runTrigger?: number
lastRunSourceSignature?: string lastRunSourceSignature?: string
/** Controls which view is shown and what the node emits to downstream (Image = markdown embed, String = resolved text). */
outputMode?: OutputMode
cachedRenderedContent?: string cachedRenderedContent?: string
cachedResolvedContent?: string cachedResolvedContent?: string
cachedReasoningContent?: string cachedReasoningContent?: string
/** Value exposed to config/agent when they reference this node (e.g. {{ renderId }}). Set on pipeline completion. */
cachedOutputValue?: string
} }
const DEFAULT_VIEWPORT_WIDTH = 1200 const DEFAULT_VIEWPORT_WIDTH = 1200
@@ -96,6 +102,10 @@ export type RenderingNodeState = {
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */ /** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
sourceNodeType: string | null sourceNodeType: string | null
/** Output mode (Image vs String); controls view and emitted cachedOutputValue. */
outputMode: OutputMode
setOutputMode: (mode: OutputMode) => void
} }
export function useRenderingNodeState( export function useRenderingNodeState(
@@ -140,6 +150,11 @@ export function useRenderingNodeState(
) )
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual' const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual'
const runTrigger = data?.runTrigger ?? 0 const runTrigger = data?.runTrigger ?? 0
const outputMode: OutputMode = (data?.outputMode ?? 'image') as OutputMode
const setOutputMode = useCallback(
(mode: OutputMode) => updateData({ outputMode: mode }),
[updateData]
)
const isAgentSource = srcNode?.type === 'agent' const isAgentSource = srcNode?.type === 'agent'
const agentOutputMarkdown = isAgentSource const agentOutputMarkdown = isAgentSource
? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '') ? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '')
@@ -201,6 +216,8 @@ export function useRenderingNodeState(
const updateDataRef = useRef(updateData) const updateDataRef = useRef(updateData)
updateDataRef.current = updateData updateDataRef.current = updateData
const outputModeRef = useRef(outputMode)
outputModeRef.current = outputMode
const setNodesRef = useRef(setNodes) const setNodesRef = useRef(setNodes)
setNodesRef.current = setNodes setNodesRef.current = setNodes
const aiConnectionRef = useRef(aiConnection) const aiConnectionRef = useRef(aiConnection)
@@ -294,6 +311,7 @@ export function useRenderingNodeState(
cachedRenderedContent: undefined, cachedRenderedContent: undefined,
cachedResolvedContent: undefined, cachedResolvedContent: undefined,
cachedReasoningContent: undefined, cachedReasoningContent: undefined,
cachedOutputValue: undefined,
}) })
try { try {
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache // Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
@@ -324,10 +342,18 @@ export function useRenderingNodeState(
if (thisRunId !== runIdRef.current) return if (thisRunId !== runIdRef.current) return
setRenderedContent(htmlOrSvg) setRenderedContent(htmlOrSvg)
setError(null) setError(null)
const mode = outputModeRef.current
const cachedOutputValue =
mode === 'string'
? resolved
: mode === 'image' && htmlOrSvg?.trim() && /<svg[\s>]/i.test(htmlOrSvg.trim())
? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(htmlOrSvg)}`
: ''
updateDataRef.current({ updateDataRef.current({
cachedRenderedContent: htmlOrSvg, cachedRenderedContent: htmlOrSvg,
cachedResolvedContent: resolved, cachedResolvedContent: resolved,
cachedReasoningContent: reasoning ?? '', cachedReasoningContent: reasoning ?? '',
cachedOutputValue,
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}), ...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
}) })
} catch (err: unknown) { } catch (err: unknown) {
@@ -546,5 +572,7 @@ export function useRenderingNodeState(
emptyStateAction, emptyStateAction,
sourceData, sourceData,
sourceNodeType, sourceNodeType,
outputMode,
setOutputMode,
} }
} }