534 lines
20 KiB
TypeScript
534 lines
20 KiB
TypeScript
/**
|
|
* runs the resolve → render pipeline, manages streaming/cache,
|
|
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
|
* pipeline interface.
|
|
*/
|
|
|
|
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
|
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
|
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
|
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
|
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
|
import {
|
|
getConfigContent,
|
|
getConfigType,
|
|
getConfigTypeId,
|
|
getSourceRenderingLogic,
|
|
parseThinkSections,
|
|
processSvgDisplay,
|
|
stripTemplateSyntax,
|
|
} from '@/lib/graph/rendering'
|
|
import { buildSourceSignatures, type NodeLike, type EdgeLike } from '@/lib/graph/renderingSignatures'
|
|
import { getNodeDisplayStatus, type NodeDisplayStatus } from '@/lib/graph/state'
|
|
import type { ConfigTypeId, SourceRenderingLogicContext } from '@/lib/graph/rendering'
|
|
|
|
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
|
|
|
|
/** Lifecycle state to pass to useSyncConnectionStatus so edge status (updating/error) stays in sync. */
|
|
export type RenderingNodeLifecycle = {
|
|
updating: boolean
|
|
error: boolean
|
|
}
|
|
|
|
export type RenderingNodeState = {
|
|
/** For NodeStatusIndicator and empty/error UI. Derived from loading, error, renderedContent. */
|
|
displayStatus: NodeDisplayStatus
|
|
/** Pass to useSyncConnectionStatus so edges show correct status (blue/yellow/red). */
|
|
lifecycle: RenderingNodeLifecycle
|
|
|
|
// 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
|
|
|
|
// 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>
|
|
|
|
/** Source node type id (e.g. 'config', 'agent') for Output menu from descriptor. */
|
|
sourceNodeType: string | null
|
|
}
|
|
|
|
export function useRenderingNodeState(
|
|
id: string,
|
|
data: RenderingNodeData | undefined
|
|
): RenderingNodeState {
|
|
const {
|
|
nodes: contextNodes,
|
|
edges: contextEdges,
|
|
setNodes,
|
|
setEdges,
|
|
sourceIds: incomingIds,
|
|
updateData,
|
|
} = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
|
|
|
// Subscribe to store graph so we re-render when any node/edge changes (e.g. ascendant data).
|
|
// Context only exposes a ref, so we wouldn't re-render when another node updates otherwise.
|
|
const storeNodes = useCanvasStore((s) => s.graph.nodes)
|
|
const storeEdges = useCanvasStore((s) => s.graph.edges)
|
|
const nodes = storeNodes.length > 0 ? storeNodes : contextNodes
|
|
const edges = storeEdges.length > 0 ? storeEdges : contextEdges
|
|
|
|
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 deferredNodes = useDeferredValue(nodes)
|
|
const deferredEdges = useDeferredValue(edges)
|
|
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 signatures = useMemo(
|
|
() => buildSourceSignatures(deferredNodes as NodeLike[], deferredEdges as EdgeLike[], id, incomingIds),
|
|
[deferredNodes, deferredEdges, id, incomingIds]
|
|
)
|
|
const {
|
|
connectedNodeIds,
|
|
configSignature,
|
|
edgesSignature,
|
|
variablesSignature,
|
|
functionsSignature,
|
|
dataSignature,
|
|
sourceSignature,
|
|
} = signatures
|
|
|
|
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 updateDataRef = useRef(updateData)
|
|
updateDataRef.current = updateData
|
|
const setNodesRef = useRef(setNodes)
|
|
setNodesRef.current = setNodes
|
|
const aiConnectionRef = useRef(aiConnection)
|
|
aiConnectionRef.current = aiConnection
|
|
const hasPendingInputs =
|
|
effectiveUpdateMode === 'manual' &&
|
|
!loading &&
|
|
incomingIds.length > 0 &&
|
|
sourceSignature !== lastRunSourceSignature
|
|
|
|
const lifecycle = useMemo<RenderingNodeLifecycle>(
|
|
() => ({ updating: loading, error: error != null }),
|
|
[loading, error]
|
|
)
|
|
useSyncConnectionStatus(id, lifecycle)
|
|
|
|
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 () => {
|
|
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
|
loadingStartedAtRef.current = Date.now()
|
|
setLoading(true)
|
|
setError(null)
|
|
setRenderedContent(null)
|
|
setResolvedContent(null)
|
|
setStreamingMarkdown(null)
|
|
setReasoningContent('')
|
|
updateDataRef.current({
|
|
cachedRenderedContent: undefined,
|
|
cachedResolvedContent: undefined,
|
|
cachedReasoningContent: undefined,
|
|
})
|
|
try {
|
|
// Pipeline: 1) Resolve (source) → 2) Render (output type) → 3) Cache
|
|
const { nodes: ctxNodes, edges: ctxEdges } = nodesEdgesRef.current
|
|
const context = {
|
|
nodes: ctxNodes,
|
|
edges: ctxEdges,
|
|
sourceNodeId: srcId,
|
|
renderNodeId: id,
|
|
viewportWidth,
|
|
viewportHeight,
|
|
setNodes: setNodesRef.current ?? undefined,
|
|
aiConnection: aiConnectionRef.current,
|
|
...(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)
|
|
updateDataRef.current({
|
|
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
|
|
}
|
|
}
|
|
// Content updates only when connected-node data changes (sourceSignature) or explicit run/viewport.
|
|
// React Flow updates (position, selection, context ref churn) do not trigger re-runs.
|
|
}, [
|
|
id,
|
|
srcId,
|
|
srcNode?.type,
|
|
effectiveUpdateMode,
|
|
runTrigger,
|
|
sourceSignature,
|
|
viewportWidth,
|
|
viewportHeight,
|
|
retryCount,
|
|
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 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 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])
|
|
const sourceNodeType = (srcNode?.type as string) ?? null
|
|
|
|
const displayStatus = useMemo<NodeDisplayStatus>(
|
|
() => getNodeDisplayStatus({ loading, error, hasContent: !!renderedContent }),
|
|
[loading, error, renderedContent]
|
|
)
|
|
|
|
return {
|
|
displayStatus,
|
|
lifecycle,
|
|
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,
|
|
setUpdateMode,
|
|
emptyStateAction,
|
|
sourceData,
|
|
sourceNodeType,
|
|
}
|
|
}
|