feat: render image in markdown
This commit is contained in:
@@ -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} />)
|
||||
|
||||
@@ -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
|
||||
? ``
|
||||
: `{{ ${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>
|
||||
|
||||
@@ -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} />)
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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'
|
||||
: '—'}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user