feat: renderin imrpovements
This commit is contained in:
@@ -18,10 +18,13 @@ import FlowContext from '@/lib/graph/flowContext'
|
||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||
import { getNodeType } from '@/lib/graph/nodeRegistry'
|
||||
import { Bot } from 'lucide-react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
|
||||
export type AgentNodeData = {
|
||||
/** Additional context for the agent (configuration). */
|
||||
context?: string
|
||||
/** When true, agent returns reasoning in a "## Reasoning" section and output in "## Output". */
|
||||
reasoning?: boolean
|
||||
/** Cached output from last run; set by connected Rendering node. Not displayed on this node. */
|
||||
outputMarkdown?: string
|
||||
/** Signature of inputs from last successful run; set by Rendering node when it runs the agent. Used as cache. */
|
||||
@@ -42,6 +45,12 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
: undefined
|
||||
|
||||
const contextText = data?.context ?? ''
|
||||
const reasoningEnabled = data?.reasoning ?? false
|
||||
|
||||
const onReasoningChange = useCallback(
|
||||
(checked: boolean) => updateData({ reasoning: checked }),
|
||||
[updateData]
|
||||
)
|
||||
|
||||
const connectedSources = useMemo(() => {
|
||||
return sourceIds.map((sid) => {
|
||||
@@ -87,6 +96,21 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
<NodeMenubar nodeId={id} nodeType="agent" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 p-2 min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor={`agent-reasoning-${id}`} className="flex items-center gap-2 text-xs font-medium text-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
id={`agent-reasoning-${id}`}
|
||||
checked={reasoningEnabled}
|
||||
onCheckedChange={(c) => onReasoningChange(c === true)}
|
||||
className="nodrag nopan"
|
||||
aria-label="Enable reasoning"
|
||||
/>
|
||||
Enable reasoning
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the model will output a Reasoning section and an Output section; the renderer shows them separately.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">Context</label>
|
||||
<textarea
|
||||
|
||||
@@ -52,13 +52,29 @@ function buildSourceSignature(
|
||||
return JSON.stringify({ sourceIds: sourceIds.slice().sort(), configContents })
|
||||
}
|
||||
|
||||
/** Parse agent response into reasoning and output when format is "## Reasoning" / "## Output". */
|
||||
function parseReasoningAndOutput(fullMarkdown: string): { reasoning?: string; output: string } {
|
||||
const normalized = fullMarkdown.trim()
|
||||
const outputMatch = normalized.match(/\s*##\s*Output\s*/i)
|
||||
if (!outputMatch) return { output: fullMarkdown }
|
||||
const idx = outputMatch.index! + outputMatch[0].length
|
||||
const output = normalized.slice(idx).trim()
|
||||
const beforeOutput = normalized.slice(0, outputMatch.index).trim()
|
||||
const reasoningMatch = beforeOutput.match(/\s*##\s*Reasoning\s*/i)
|
||||
const reasoning = reasoningMatch
|
||||
? beforeOutput.slice(reasoningMatch.index! + reasoningMatch[0].length).trim()
|
||||
: beforeOutput
|
||||
return { reasoning: reasoning || undefined, output: output || fullMarkdown }
|
||||
}
|
||||
|
||||
export const agentRenderingLogic = {
|
||||
defaultUpdateMode: 'manual' as const,
|
||||
getResolvedContent: async (context: SourceRenderingLogicContext): Promise<ResolvedContentResult> => {
|
||||
const { nodes, sourceNodeId, setNodes, aiConnection } = context
|
||||
const { nodes, sourceNodeId, setNodes, aiConnection, onStreamingStart, onStreamingChunk } = context
|
||||
const sourceNode = nodes.find((n) => n.id === sourceNodeId)
|
||||
const agentData = sourceNode?.data as { context?: string; outputMarkdown?: string } | undefined
|
||||
const agentData = sourceNode?.data as { context?: string; outputMarkdown?: string; reasoning?: boolean } | undefined
|
||||
const contextText = agentData?.context ?? ''
|
||||
const reasoningEnabled = Boolean(agentData?.reasoning)
|
||||
const sourceIds = (context.edges
|
||||
.filter((e) => e.target === sourceNodeId)
|
||||
.map((e) => e.source)) as string[]
|
||||
@@ -94,8 +110,61 @@ export const agentRenderingLogic = {
|
||||
content: serializeNodeForContext(nodes, sid),
|
||||
}))
|
||||
const sourceSignature = buildSourceSignature(nodes, sourceIds)
|
||||
const useStream = Boolean(onStreamingChunk)
|
||||
|
||||
try {
|
||||
if (useStream) {
|
||||
onStreamingStart?.()
|
||||
const res = await fetch('/api/agent/stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
context: contextText.trim() || undefined,
|
||||
contextNodes,
|
||||
connection,
|
||||
reasoning: reasoningEnabled,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
let msg = `Request failed: ${res.status}`
|
||||
try {
|
||||
const json = text ? JSON.parse(text) : {}
|
||||
if (typeof (json as { error?: string }).error === 'string') msg = (json as { error: string }).error
|
||||
} catch {
|
||||
if (text) msg = text.slice(0, 200)
|
||||
}
|
||||
setAgentData({ outputMarkdown: undefined })
|
||||
throw new Error(msg)
|
||||
}
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let markdown = ''
|
||||
try {
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
markdown += chunk
|
||||
onStreamingChunk?.(chunk)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader?.cancel()
|
||||
}
|
||||
setAgentData({
|
||||
outputMarkdown: markdown,
|
||||
lastRunSourceSignature: sourceSignature,
|
||||
})
|
||||
if (reasoningEnabled) {
|
||||
const { reasoning: reasoningText, output } = parseReasoningAndOutput(markdown)
|
||||
return { resolved: output, outputTypeId: 'markdown' as const, reasoning: reasoningText }
|
||||
}
|
||||
return { resolved: markdown, outputTypeId: 'markdown' }
|
||||
}
|
||||
|
||||
const res = await fetch('/api/agent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -104,6 +173,7 @@ export const agentRenderingLogic = {
|
||||
context: contextText.trim() || undefined,
|
||||
contextNodes,
|
||||
connection,
|
||||
reasoning: reasoningEnabled,
|
||||
}),
|
||||
})
|
||||
const json = await res.json().catch(() => ({}))
|
||||
@@ -117,6 +187,10 @@ export const agentRenderingLogic = {
|
||||
outputMarkdown: markdown,
|
||||
lastRunSourceSignature: sourceSignature,
|
||||
})
|
||||
if (reasoningEnabled) {
|
||||
const { reasoning: reasoningText, output } = parseReasoningAndOutput(markdown)
|
||||
return { resolved: output, outputTypeId: 'markdown' as const, reasoning: reasoningText }
|
||||
}
|
||||
return { resolved: markdown, outputTypeId: 'markdown' }
|
||||
} catch (err: unknown) {
|
||||
setAgentData({ outputMarkdown: undefined })
|
||||
@@ -126,6 +200,10 @@ export const agentRenderingLogic = {
|
||||
|
||||
const outputMarkdown =
|
||||
(sourceNode?.data as { outputMarkdown?: string } | undefined)?.outputMarkdown ?? ''
|
||||
if (reasoningEnabled && outputMarkdown) {
|
||||
const { reasoning: reasoningText, output } = parseReasoningAndOutput(outputMarkdown)
|
||||
return { resolved: output, outputTypeId: 'markdown' as const, reasoning: reasoningText }
|
||||
}
|
||||
return { resolved: outputMarkdown, outputTypeId: 'markdown' }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
|
||||
import { NodeMenubar } from '@/components/graph/NodeMenubar'
|
||||
import { NodeStatusIndicator } from '@/components/graph/NodeStatusIndicator'
|
||||
import { MenubarCheckboxItem, MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '@/components/ui/menubar'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy, Play, ChevronDown, Loader2 } from 'lucide-react'
|
||||
import { InputHandle } from '@/components/graph/NodeHandles'
|
||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
||||
@@ -55,6 +56,10 @@ export type RenderingNodeData = {
|
||||
runTrigger?: number
|
||||
/** Signature of inputs used in the last successful render. Used in manual mode to show paused (yellow) when upstream changed. */
|
||||
lastRunSourceSignature?: string
|
||||
/** Cached render output so fullscreen can show content (set when render completes, cleared when new run starts). */
|
||||
cachedRenderedContent?: string
|
||||
cachedResolvedContent?: string
|
||||
cachedReasoningContent?: string
|
||||
}
|
||||
|
||||
const DEFAULT_VIEWPORT_WIDTH = 1200
|
||||
@@ -64,14 +69,31 @@ type Props = AbstractNodeProps<RenderingNodeData>
|
||||
|
||||
type ViewMode = 'preview' | 'raw'
|
||||
|
||||
/** Extract <think>...</think> blocks from HTML/markdown; return main content (with blocks removed) and think content for a collapsible. */
|
||||
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 }
|
||||
}
|
||||
|
||||
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const flowContext = useContext(FlowContext)
|
||||
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
|
||||
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
||||
const [renderedContent, setRenderedContent] = useState<string | null>(null)
|
||||
const [resolvedContent, setResolvedContent] = useState<string | null>(null)
|
||||
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, setRetryCount] = useState(0)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('preview')
|
||||
const [viewportFocused, setViewportFocused] = useState(false)
|
||||
@@ -79,7 +101,10 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const loadingStartedAtRef = useRef<number | null>(null)
|
||||
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const lastManualRunTriggerRef = useRef<number>(0)
|
||||
const manualRunTriggerSyncedRef = useRef<boolean>(false)
|
||||
const nodesEdgesRef = useRef<{ nodes: unknown[]; edges: unknown[] }>({ nodes: [], edges: [] })
|
||||
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
||||
nodesEdgesRef.current = { nodes, edges }
|
||||
const { aiConnection } = usePlatform()
|
||||
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
|
||||
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
|
||||
@@ -262,20 +287,30 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (effectiveUpdateMode === 'manual' && runTrigger === 0) {
|
||||
setRenderedContent(null)
|
||||
setResolvedContent(null)
|
||||
setError({
|
||||
kind: 'no-content',
|
||||
message: 'Click Run to render.',
|
||||
})
|
||||
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
|
||||
}
|
||||
if (effectiveUpdateMode === 'manual' && runTrigger === lastManualRunTriggerRef.current) {
|
||||
return
|
||||
}
|
||||
if (effectiveUpdateMode === 'manual') lastManualRunTriggerRef.current = runTrigger
|
||||
|
||||
runIdRef.current += 1
|
||||
const thisRunId = runIdRef.current
|
||||
@@ -287,43 +322,59 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
loadingStartedAtRef.current = Date.now()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setRenderedContent(null)
|
||||
setResolvedContent(null)
|
||||
setStreamingMarkdown(null)
|
||||
setReasoningContent('')
|
||||
updateData({ cachedRenderedContent: undefined, cachedResolvedContent: undefined, cachedReasoningContent: undefined })
|
||||
try {
|
||||
const { nodes: ctxNodes, edges: ctxEdges } = nodesEdgesRef.current
|
||||
const context = {
|
||||
nodes,
|
||||
edges,
|
||||
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 } = await logic.getResolvedContent(context)
|
||||
if (cancelled || thisRunId !== runIdRef.current) return
|
||||
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 (cancelled || thisRunId !== runIdRef.current) return
|
||||
if (thisRunId !== runIdRef.current) return
|
||||
setRenderedContent(htmlOrSvg)
|
||||
setError(null)
|
||||
if (isManualMode) {
|
||||
updateData({ lastRunSourceSignature: signatureForThisRun })
|
||||
}
|
||||
updateData({
|
||||
cachedRenderedContent: htmlOrSvg,
|
||||
cachedResolvedContent: resolved,
|
||||
cachedReasoningContent: reasoning ?? '',
|
||||
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
|
||||
})
|
||||
} catch (err: any) {
|
||||
if (!cancelled && thisRunId === runIdRef.current) {
|
||||
setRenderedContent(null)
|
||||
setReasoningContent('')
|
||||
setError({ kind: 'render', message: err?.message ?? 'Render error' })
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled && thisRunId === runIdRef.current) {
|
||||
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 (!cancelled && thisRunId === runIdRef.current) {
|
||||
if (thisRunId === runIdRef.current) {
|
||||
setLoading(false)
|
||||
}
|
||||
}, remaining)
|
||||
@@ -349,13 +400,14 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
setStreamingMarkdown(null)
|
||||
if (minLoadingTimeoutRef.current != null) {
|
||||
clearTimeout(minLoadingTimeoutRef.current)
|
||||
minLoadingTimeoutRef.current = null
|
||||
}
|
||||
setLoading(false)
|
||||
// Do not setLoading(false) here: the in-flight run() will set it in finally. Clearing it here would hide the Run button spinner when the effect re-runs (e.g. agent updates nodes).
|
||||
}
|
||||
}, [id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceContent, sourceSignature, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, nodes, edges, updateData, setNodes, aiConnection])
|
||||
}, [id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceContent, sourceSignature, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, updateData, setNodes, aiConnection, isAgentSource])
|
||||
|
||||
|
||||
const dimensions =
|
||||
@@ -363,20 +415,70 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
? { width, height }
|
||||
: undefined
|
||||
|
||||
// Render streaming agent markdown to HTML for preview
|
||||
useEffect(() => {
|
||||
if (streamingMarkdown === null) {
|
||||
setStreamingPreviewHtml('')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
import('marked').then(async ({ marked }) => {
|
||||
if (cancelled) return
|
||||
const html = typeof marked.parse === 'function' ? await marked.parse(streamingMarkdown) : (marked as (s: string) => string)(streamingMarkdown)
|
||||
const str = typeof html === 'string' ? html : String(html)
|
||||
if (!cancelled) setStreamingPreviewHtml(str)
|
||||
}).catch(() => {
|
||||
if (!cancelled) setStreamingPreviewHtml(streamingMarkdown)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [streamingMarkdown])
|
||||
|
||||
// Render reasoning markdown to HTML for collapsible section
|
||||
useEffect(() => {
|
||||
if (!reasoningContent) {
|
||||
setReasoningHtml('')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
import('marked').then(async ({ marked }) => {
|
||||
if (cancelled) return
|
||||
const html = typeof marked.parse === 'function' ? await marked.parse(reasoningContent) : (marked as (s: string) => string)(reasoningContent)
|
||||
const str = typeof html === 'string' ? html : String(html)
|
||||
if (!cancelled) setReasoningHtml(str)
|
||||
}).catch(() => {
|
||||
if (!cancelled) setReasoningHtml(reasoningContent)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [reasoningContent])
|
||||
|
||||
// Kroki and other SVG sources may prepend <?xml ... ?> so we detect by presence of <svg> tag
|
||||
const isSvgOutput = Boolean(renderedContent?.trim() && /<svg[\s>]/i.test(renderedContent.trim()))
|
||||
|
||||
/** Strip remaining Nunjucks tags from resolved content for display in raw view (so tags don't show as literal lines). */
|
||||
/** Split final markdown HTML into <think> (collapsible) and main. Only for non-image output. */
|
||||
const renderedThinkSplit = useMemo(() => {
|
||||
if (outputType === 'image' || !renderedContent) return { main: '', think: '' }
|
||||
return parseThinkSections(renderedContent)
|
||||
}, [renderedContent, outputType])
|
||||
|
||||
/** Split streaming content into <think> (collapsible) and main. Uses HTML when ready, else raw markdown. */
|
||||
const streamingThinkSplit = useMemo(() => {
|
||||
if (streamingMarkdown === null) return { main: '', think: '' }
|
||||
const src = streamingPreviewHtml || streamingMarkdown
|
||||
return parseThinkSections(src)
|
||||
}, [streamingMarkdown, streamingPreviewHtml])
|
||||
|
||||
/** Raw markdown/text for Raw view: resolved content when set, otherwise streaming accumulation (so raw updates while streaming). */
|
||||
const rawDisplayContent = useMemo(() => {
|
||||
if (resolvedContent == null) return ''
|
||||
return resolvedContent
|
||||
const src = resolvedContent != null ? resolvedContent : (streamingMarkdown ?? '')
|
||||
if (!src) return ''
|
||||
return src
|
||||
.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()
|
||||
}, [resolvedContent])
|
||||
}, [resolvedContent, streamingMarkdown])
|
||||
|
||||
/** Process SVG HTML so it keeps aspect ratio and fills the viewport (used only for display, not download). */
|
||||
const displayContent = useMemo(() => {
|
||||
@@ -653,7 +755,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 flex flex-col">
|
||||
{incomingIds.length === 0 ? (
|
||||
{incomingIds.length === 0 && !renderedContent && !streamingMarkdown && !loading ? (
|
||||
<Empty className="min-h-0 flex-1">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
@@ -700,8 +802,6 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : loading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
||||
) : viewMode === 'raw' ? (
|
||||
<div ref={rawEditorContainerRef} className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||
<Button
|
||||
@@ -732,6 +832,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
/>
|
||||
</div>
|
||||
) : renderedContent ? (
|
||||
/* Show final content as soon as it's ready (even if still loading), so streamed preview isn't replaced by "Rendering…" */
|
||||
outputType === 'image' ? (
|
||||
<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"
|
||||
@@ -815,11 +916,76 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="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"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{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="rendering-markdown max-h-48 overflow-auto 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"
|
||||
dangerouslySetInnerHTML={{ __html: reasoningHtml }}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{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="rendering-markdown max-h-48 overflow-auto 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"
|
||||
dangerouslySetInnerHTML={{ __html: renderedThinkSplit.think }}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<div
|
||||
className="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"
|
||||
dangerouslySetInnerHTML={{ __html: renderedThinkSplit.main }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : loading && viewMode === 'preview' && streamingMarkdown !== null ? (
|
||||
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||
{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>
|
||||
{streamingPreviewHtml ? (
|
||||
<div
|
||||
className="rendering-markdown max-h-48 overflow-auto 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"
|
||||
dangerouslySetInnerHTML={{ __html: 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">
|
||||
{streamingThinkSplit.think}
|
||||
</pre>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
{streamingPreviewHtml ? (
|
||||
<div
|
||||
className="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"
|
||||
dangerouslySetInnerHTML={{ __html: streamingThinkSplit.main || streamingPreviewHtml }}
|
||||
/>
|
||||
) : (
|
||||
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
|
||||
{streamingThinkSplit.main || streamingMarkdown}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
||||
) : null}
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
|
||||
@@ -20,14 +20,21 @@ export type SourceRenderingLogicContext = {
|
||||
setNodes?: (updater: (nodes: { id: string; type?: string; data?: unknown }[]) => { id: string; type?: string; data?: unknown }[]) => void
|
||||
/** Optional: AI connection for agent source (used when Rendering node runs the agent). */
|
||||
aiConnection?: unknown
|
||||
/** Optional: called when agent streaming starts (Rendering node can show streaming preview). */
|
||||
onStreamingStart?: () => void
|
||||
/** Optional: called with each text chunk during agent stream (Rendering node can update preview). */
|
||||
onStreamingChunk?: (chunk: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of getResolvedContent: resolved string plus the config type to use for final render.
|
||||
* When the source is an agent with reasoning enabled, reasoning may be set for a collapsible section.
|
||||
*/
|
||||
export type ResolvedContentResult = {
|
||||
resolved: string
|
||||
outputTypeId: ConfigTypeId
|
||||
/** Optional reasoning section (e.g. agent with reasoning enabled); renderer shows it in a collapsible. */
|
||||
reasoning?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user