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_')
.withInputOutput(true, true)
.classification('archon')
.allowedSourceTypes(['config', 'variable', 'data'])
.allowedSourceTypes(['config', 'variable', 'data', 'render'])
.allowedTargetTypes(['render'])
.help(NODE_HELP.agent)
.menu('Agent', <Bot className={ICON_CLASS} />)

View File

@@ -25,7 +25,7 @@ import {
BaseNodeFooter,
BaseNodeHeaderRow,
} from '@/components/graph/BaseNode'
import { Code2, Database, ScrollText, Variable } from 'lucide-react'
import { Code2, Database, ScrollText, Sparkles, Variable } from 'lucide-react'
import {
MenubarItem,
MenubarSeparator,
@@ -34,6 +34,7 @@ import {
MenubarSubContent,
MenubarSubTrigger,
} from '@/components/ui/menubar'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Kbd } from '@/components/ui/kbd'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
@@ -78,7 +79,16 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
() => getConnectedNodesByType(nodes, sourceIds, 'data'),
[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(
(newTypeId: ConfigTypeId) => {
@@ -133,6 +143,18 @@ function ConfigNodeComponent({ id, data, width, height, selected }: Props) {
[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 functionIds = useMemo(() => connectedFunctionNodes.map((n: any) => n.id), [connectedFunctionNodes])
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>
</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>

View File

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

View File

@@ -74,6 +74,10 @@ export async function getResolvedContentForConfig(context: SourceRenderingLogicC
})
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>()

View File

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

View File

@@ -15,9 +15,10 @@ export function getRenderNodeDescriptor(): NodeTypeDescriptor {
{ viewportWidth: 1200, viewportHeight: 800 }
)
.idPrefix('rnd_')
.withInputOutput(true, false)
.withInputOutput(true, true)
.classification('pneuma')
.allowedSourceTypes(['config', 'agent'])
.allowedTargetTypes(['config', 'agent'])
.help(NODE_HELP.render)
.menu('Renderer', <Sparkles className={ICON_CLASS} />)
.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 type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
export type OutputMode = 'image' | 'string'
export type RenderingNodeData = {
viewportWidth?: number
viewportHeight?: number
updateMode?: 'auto' | 'manual'
runTrigger?: number
lastRunSourceSignature?: string
/** Controls which view is shown and what the node emits to downstream (Image = markdown embed, String = resolved text). */
outputMode?: OutputMode
cachedRenderedContent?: string
cachedResolvedContent?: 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
@@ -96,6 +102,10 @@ export type RenderingNodeState = {
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
sourceNodeType: string | null
/** Output mode (Image vs String); controls view and emitted cachedOutputValue. */
outputMode: OutputMode
setOutputMode: (mode: OutputMode) => void
}
export function useRenderingNodeState(
@@ -140,6 +150,11 @@ export function useRenderingNodeState(
)
const effectiveUpdateMode = (data?.updateMode ?? sourceLogic?.defaultUpdateMode ?? 'auto') as 'auto' | 'manual'
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 agentOutputMarkdown = isAgentSource
? ((srcNode?.data as { outputMarkdown?: string })?.outputMarkdown ?? '')
@@ -201,6 +216,8 @@ export function useRenderingNodeState(
const updateDataRef = useRef(updateData)
updateDataRef.current = updateData
const outputModeRef = useRef(outputMode)
outputModeRef.current = outputMode
const setNodesRef = useRef(setNodes)
setNodesRef.current = setNodes
const aiConnectionRef = useRef(aiConnection)
@@ -294,6 +311,7 @@ export function useRenderingNodeState(
cachedRenderedContent: undefined,
cachedResolvedContent: undefined,
cachedReasoningContent: undefined,
cachedOutputValue: undefined,
})
try {
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
@@ -324,10 +342,18 @@ export function useRenderingNodeState(
if (thisRunId !== runIdRef.current) return
setRenderedContent(htmlOrSvg)
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({
cachedRenderedContent: htmlOrSvg,
cachedResolvedContent: resolved,
cachedReasoningContent: reasoning ?? '',
cachedOutputValue,
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
})
} catch (err: unknown) {
@@ -546,5 +572,7 @@ export function useRenderingNodeState(
emptyStateAction,
sourceData,
sourceNodeType,
outputMode,
setOutputMode,
}
}