683 lines
26 KiB
TypeScript
683 lines
26 KiB
TypeScript
/**
|
||
* All logic for the Rendering node: source resolution, signatures, run effect,
|
||
* streaming, and derived display state. The RenderingNode UI is kept dumb and
|
||
* only consumes this hook’s return value.
|
||
*/
|
||
|
||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
||
import { getConfigContent, getConfigType, getConfigTypeId } from '@/lib/graph/configTypes'
|
||
import type { ConfigTypeId, OutputMenuDescriptor } from '@/lib/graph/configTypes'
|
||
import FlowContext from '@/lib/graph/flowContext'
|
||
import { getSourceRenderingLogic, type SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic'
|
||
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 { parseThinkSections, processSvgDisplay, stripTemplateSyntax } from '@/lib/graph/renderingUtils'
|
||
|
||
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
|
||
|
||
// Callbacks
|
||
downloadSvg: () => void
|
||
downloadPng: () => void
|
||
copySvg: () => void
|
||
copyPng: () => void
|
||
|
||
/** Output menu descriptor from the source (e.g. Export submenu for image config types). Null when no source or source provides none. */
|
||
outputMenuDescriptor: OutputMenuDescriptor | null
|
||
|
||
// 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>
|
||
}
|
||
|
||
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 {
|
||
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 outputMenuDescriptor = useMemo(
|
||
() => sourceLogic?.getOutputMenuDescriptor?.(configTypeId) ?? null,
|
||
[sourceLogic, configTypeId]
|
||
)
|
||
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 downloadSvg = useCallback(() => {
|
||
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 = `${id}.svg`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}, [id, renderedContent, isSvgOutput])
|
||
|
||
const downloadPng = useCallback(() => {
|
||
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 ctx = canvas.getContext('2d')
|
||
if (!ctx) return
|
||
ctx.drawImage(img, 0, 0)
|
||
const pngUrl = canvas.toDataURL('image/png')
|
||
const a = document.createElement('a')
|
||
a.href = pngUrl
|
||
a.download = `${id}.png`
|
||
a.click()
|
||
}
|
||
img.onerror = () => {}
|
||
img.src = dataUrl
|
||
}, [id, renderedContent, isSvgOutput])
|
||
|
||
const copyPng = useCallback(() => {
|
||
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 ctx = canvas.getContext('2d')
|
||
if (!ctx) return
|
||
ctx.drawImage(img, 0, 0)
|
||
canvas.toBlob((blob) => {
|
||
if (blob)
|
||
navigator.clipboard?.write([new ClipboardItem({ 'image/png': blob })]).catch(() => {})
|
||
}, 'image/png')
|
||
}
|
||
img.onerror = () => {}
|
||
img.src = dataUrl
|
||
}, [renderedContent, isSvgOutput])
|
||
|
||
const copySvg = useCallback(() => {
|
||
if (!renderedContent || !isSvgOutput) return
|
||
const blob = new Blob([renderedContent], { type: 'image/svg+xml' })
|
||
navigator.clipboard?.write([new ClipboardItem({ 'image/svg+xml': blob })]).catch(() => {})
|
||
}, [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])
|
||
|
||
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,
|
||
downloadSvg,
|
||
downloadPng,
|
||
copySvg,
|
||
copyPng,
|
||
setUpdateMode,
|
||
outputMenuDescriptor,
|
||
emptyStateAction,
|
||
sourceData,
|
||
}
|
||
}
|