feat: rendering for agents

This commit is contained in:
2026-03-12 13:05:52 +01:00
parent 871fd346e3
commit f43e2df019
7 changed files with 156 additions and 151 deletions

View File

@@ -1,10 +1,9 @@
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' import React, { useCallback, useContext, useMemo } from 'react'
import { import {
AbstractNodeProps, AbstractNodeProps,
createAbstractNodeComponent, createAbstractNodeComponent,
useAbstractNode, useAbstractNode,
} from '@/lib/graph/abstractNode' } from '@/lib/graph/abstractNode'
import { getConfigContent } from '@/lib/graph/configTypes'
import { import {
BaseNode, BaseNode,
BaseNodeContent, BaseNodeContent,
@@ -18,50 +17,24 @@ import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles'
import FlowContext from '@/lib/graph/flowContext' import FlowContext from '@/lib/graph/flowContext'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle' import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { getNodeType } from '@/lib/graph/nodeRegistry' import { getNodeType } from '@/lib/graph/nodeRegistry'
import { Button } from '@/components/ui/button' import { Bot } from 'lucide-react'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { Bot, Play, Loader2 } from 'lucide-react'
export type AgentNodeData = { export type AgentNodeData = {
/** Additional context for the agent (configuration). */
context?: string context?: string
/** Cached output from last run; set by connected Rendering node. Not displayed on this node. */
outputMarkdown?: string outputMarkdown?: string
error?: string /** Signature of inputs from last successful run; set by Rendering node when it runs the agent. Used as cache. */
loading?: boolean
/** Signature of inputs (sourceIds + config contents) from the last successful run. Used to show on-hold when current inputs differ. */
lastRunSourceSignature?: string lastRunSourceSignature?: string
} }
type Props = AbstractNodeProps<AgentNodeData> type Props = AbstractNodeProps<AgentNodeData>
function serializeNodeForContext(nodes: { id: string; type?: string; data?: unknown }[], nodeId: string): string {
const node = nodes.find((n: { id: string }) => n.id === nodeId)
if (!node) return `${nodeId}: (not found)`
const type = node.type ?? 'unknown'
const data = node.data as Record<string, unknown> | undefined
if (type === 'config') {
const content = getConfigContent(data)
return `[config ${nodeId}]\n${content || '(empty)'}`
}
if (type === 'variable') {
const v = data?.value
return `[variable ${nodeId}]: ${v === undefined || v === null ? '' : String(v)}`
}
if (type === 'data') {
const rows = (data?.rows as Record<string, string>[] | undefined) ?? []
const columns = (data?.columns as string[] | undefined) ?? []
const preview = rows.slice(0, 20).map((r) => columns.map((c) => r[c] ?? '').join(', ')).join('\n')
return `[data ${nodeId}] ${columns.length} columns, ${rows.length} rows\n${preview}${rows.length > 20 ? '\n...' : ''}`
}
return `[${type} ${nodeId}]: ${JSON.stringify(data ?? {}).slice(0, 200)}`
}
function AgentNodeComponent({ id, data, width, height, selected }: Props) { function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const flowContext = useContext(FlowContext) const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {}) const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
const { aiConnection } = usePlatform()
const [running, setRunning] = useState(false)
const dimensions = const dimensions =
width != null && height != null && width > 0 && height > 0 width != null && height != null && width > 0 && height > 0
@@ -69,9 +42,6 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
: undefined : undefined
const contextText = data?.context ?? '' const contextText = data?.context ?? ''
const outputMarkdown = data?.outputMarkdown
const error = data?.error
const loading = data?.loading ?? false
const connectedSources = useMemo(() => { const connectedSources = useMemo(() => {
return sourceIds.map((sid) => { return sourceIds.map((sid) => {
@@ -80,84 +50,17 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
}) })
}, [sourceIds, nodes]) }, [sourceIds, nodes])
const sourceSignature = useMemo(() => {
const configContents = sourceIds
.filter((sid) => {
const n = nodes.find((n: { id: string }) => n.id === sid)
return (n as { type?: string } | undefined)?.type === 'config'
})
.map((sid) => getConfigContent((nodes.find((n: { id: string }) => n.id === sid)?.data ?? undefined) as Record<string, unknown> | undefined))
return JSON.stringify({ sourceIds: sourceIds.slice().sort(), configContents })
}, [sourceIds, nodes])
const runAgent = useCallback(async () => {
const configContents = sourceIds
.filter((sid) => {
const n = nodes.find((n: { id: string }) => n.id === sid)
return (n as { type?: string } | undefined)?.type === 'config'
})
.map((sid) => getConfigContent((nodes.find((n: { id: string }) => n.id === sid)?.data ?? undefined) as Record<string, unknown> | undefined))
const prompt = configContents.length > 0 ? configContents.join('\n\n---\n\n') : 'No prompt provided. Please describe what you want in structured markdown.'
const contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) }))
updateData({ error: undefined, loading: true })
setRunning(true)
try {
const res = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
context: contextText.trim() || undefined,
contextNodes,
connection: aiConnection,
}),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
updateData({
loading: false,
error: (json as { error?: string }).error ?? `Request failed: ${res.status}`,
outputMarkdown: undefined,
})
return
}
const markdown = (json as { markdown?: string }).markdown ?? ''
updateData({
loading: false,
error: undefined,
outputMarkdown: markdown,
lastRunSourceSignature: sourceSignature,
})
} catch (err: unknown) {
updateData({
loading: false,
error: err instanceof Error ? err.message : 'Agent request failed',
outputMarkdown: undefined,
})
} finally {
setRunning(false)
}
}, [sourceIds, nodes, contextText, sourceSignature, updateData, aiConnection])
const onContextChange = useCallback( const onContextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => updateData({ context: e.target.value }), (e: React.ChangeEvent<HTMLTextAreaElement>) => updateData({ context: e.target.value }),
[updateData] [updateData]
) )
const pathNodeIds = flowContext?.connectionPathNodeIds // Agent is configuration-only; it does not block the flow. Only the Rendering node reports
const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? [] // paused/updating, so the connection path runs trigger → … → agent (on-path) → rendering (paused).
const lastRunSourceSignature = data?.lastRunSourceSignature
const hasPendingInputs =
pathNodeIds?.has(id) &&
triggerNodeIds.length > 0 &&
!loading &&
sourceSignature !== lastRunSourceSignature
useSyncConnectionStatus(id, { useSyncConnectionStatus(id, {
updating: running || loading, updating: false,
error: !!error, error: false,
paused: hasPendingInputs && !running && !loading, paused: false,
}) })
return ( return (
@@ -177,23 +80,6 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
<BaseNodeHeaderRow <BaseNodeHeaderRow
icon={<Bot className="size-4" />} icon={<Bot className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
right={
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 h-7 nodrag nopan"
onClick={runAgent}
disabled={running || loading}
>
{running || loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Play className="size-3.5" />
)}
<span className="ml-1.5">Run</span>
</Button>
}
onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined} onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined}
/> />
<BaseNodeContent> <BaseNodeContent>
@@ -224,19 +110,14 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
</ul> </ul>
)} )}
</div> </div>
{error && ( <p className="text-xs text-muted-foreground">
<p className="text-xs text-destructive">{error}</p> Connect to a Renderer node and click Run there to generate output.
)} </p>
{outputMarkdown != null && outputMarkdown !== '' && !error && (
<div className="text-xs text-muted-foreground border rounded p-2 max-h-24 overflow-auto">
<span className="font-medium">Output:</span> {outputMarkdown.length} chars (connect to Renderer to view)
</div>
)}
</div> </div>
</BaseNodeContent> </BaseNodeContent>
<BaseNodeFooter> <BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="agent"> <NodeFooterEdgeIndicators nodeId={id} nodeType="agent">
{outputMarkdown != null ? `${outputMarkdown.length} chars` : error ? 'Error' : '—'}
</NodeFooterEdgeIndicators> </NodeFooterEdgeIndicators>
</BaseNodeFooter> </BaseNodeFooter>
</BaseNode> </BaseNode>

View File

@@ -1,15 +1,131 @@
/** /**
* Agent node rendering logic: provides the agent's markdown output to the Rendering node. * Agent node rendering logic: when the Rendering node runs, it calls the agent API
* Update mode is manual (user clicks Run on the renderer). * and updates the agent node's output. Update mode is manual (user clicks Run on the renderer).
*/ */
import type { SourceRenderingLogic } from '@/lib/graph/sourceRenderingLogic' import { getConfigContent } from '@/lib/graph/configTypes'
import type { ResolvedContentResult, SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic'
import type { AiConnection } from '@/app/kosmos/KosmosContext'
export const agentRenderingLogic: SourceRenderingLogic = { function serializeNodeForContext(
defaultUpdateMode: 'manual', nodes: { id: string; type?: string; data?: unknown }[],
getResolvedContent: async (context) => { nodeId: string
const sourceNode = context.nodes.find((n) => n.id === context.sourceNodeId) ): string {
const outputMarkdown = (sourceNode?.data as { outputMarkdown?: string } | undefined)?.outputMarkdown ?? '' const node = nodes.find((n) => n.id === nodeId)
if (!node) return `${nodeId}: (not found)`
const type = node.type ?? 'unknown'
const data = node.data as Record<string, unknown> | undefined
if (type === 'config') {
const content = getConfigContent(data)
return `[config ${nodeId}]\n${content || '(empty)'}`
}
if (type === 'variable') {
const v = data?.value
return `[variable ${nodeId}]: ${v === undefined || v === null ? '' : String(v)}`
}
if (type === 'data') {
const rows = (data?.rows as Record<string, string>[] | undefined) ?? []
const columns = (data?.columns as string[] | undefined) ?? []
const preview = rows
.slice(0, 20)
.map((r) => columns.map((c) => r[c] ?? '').join(', '))
.join('\n')
return `[data ${nodeId}] ${columns.length} columns, ${rows.length} rows\n${preview}${rows.length > 20 ? '\n...' : ''}`
}
return `[${type} ${nodeId}]: ${JSON.stringify(data ?? {}).slice(0, 200)}`
}
function buildSourceSignature(
nodes: { id: string; type?: string; data?: unknown }[],
sourceIds: string[]
): string {
const configContents = sourceIds
.filter((sid) => {
const n = nodes.find((nn) => nn.id === sid)
return (n as { type?: string } | undefined)?.type === 'config'
})
.map((sid) =>
getConfigContent(
(nodes.find((n) => n.id === sid)?.data ?? undefined) as Record<string, unknown> | undefined
)
)
return JSON.stringify({ sourceIds: sourceIds.slice().sort(), configContents })
}
export const agentRenderingLogic = {
defaultUpdateMode: 'manual' as const,
getResolvedContent: async (context: SourceRenderingLogicContext): Promise<ResolvedContentResult> => {
const { nodes, sourceNodeId, setNodes, aiConnection } = context
const sourceNode = nodes.find((n) => n.id === sourceNodeId)
const agentData = sourceNode?.data as { context?: string; outputMarkdown?: string } | undefined
const contextText = agentData?.context ?? ''
const sourceIds = (context.edges
.filter((e) => e.target === sourceNodeId)
.map((e) => e.source)) as string[]
type NodeWithData = { id: string; type?: string; data?: unknown }
const setAgentData = (patch: Record<string, unknown>) => {
if (!setNodes) return
setNodes((prev: unknown[]) =>
(prev as NodeWithData[]).map((n) =>
n.id === sourceNodeId ? { ...n, data: { ...(n.data as object), ...patch } } : n
)
)
}
if (setNodes && aiConnection) {
const connection = aiConnection as AiConnection
const configContents = sourceIds
.filter((sid) => {
const n = nodes.find((nn) => nn.id === sid)
return (n as { type?: string } | undefined)?.type === 'config'
})
.map((sid) =>
getConfigContent(
(nodes.find((n) => n.id === sid)?.data ?? undefined) as Record<string, unknown> | undefined
)
)
const prompt =
configContents.length > 0
? configContents.join('\n\n---\n\n')
: 'No prompt provided. Please describe what you want in structured markdown.'
const contextNodes = sourceIds.map((sid) => ({
id: sid,
content: serializeNodeForContext(nodes, sid),
}))
const sourceSignature = buildSourceSignature(nodes, sourceIds)
try {
const res = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
context: contextText.trim() || undefined,
contextNodes,
connection,
}),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
setAgentData({ outputMarkdown: undefined })
const msg = (json as { error?: string }).error ?? `Request failed: ${res.status}`
throw new Error(msg)
}
const markdown = (json as { markdown?: string }).markdown ?? ''
setAgentData({
outputMarkdown: markdown,
lastRunSourceSignature: sourceSignature,
})
return { resolved: markdown, outputTypeId: 'markdown' }
} catch (err: unknown) {
setAgentData({ outputMarkdown: undefined })
throw err instanceof Error ? err : new Error('Agent request failed')
}
}
const outputMarkdown =
(sourceNode?.data as { outputMarkdown?: string } | undefined)?.outputMarkdown ?? ''
return { resolved: outputMarkdown, outputTypeId: 'markdown' } return { resolved: outputMarkdown, outputTypeId: 'markdown' }
}, },
} }

View File

@@ -9,7 +9,7 @@ import {
useAbstractNode, useAbstractNode,
} from '@/lib/graph/abstractNode' } from '@/lib/graph/abstractNode'
import { getConfigContent, getConfigType, getConfigTypeId } from '@/lib/graph/configTypes' import { getConfigContent, getConfigType, getConfigTypeId } from '@/lib/graph/configTypes'
import { getSourceRenderingLogic } from '@/lib/graph/sourceRenderingLogic' import { getSourceRenderingLogic, type SourceRenderingLogicContext } from '@/lib/graph/sourceRenderingLogic'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle' import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { useResizeHeight } from '@/hooks/useResizeHeight' import { useResizeHeight } from '@/hooks/useResizeHeight'
import { plantumlLanguage } from '@/lib/plantumlLanguage' import { plantumlLanguage } from '@/lib/plantumlLanguage'
@@ -41,6 +41,7 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTheme } from '@/lib/themeContext' import { useTheme } from '@/lib/themeContext'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -79,6 +80,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const minLoadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastManualRunTriggerRef = useRef<number>(0) const lastManualRunTriggerRef = useRef<number>(0)
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {}) const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
const { aiConnection } = usePlatform()
const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH
const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT
@@ -265,7 +267,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setResolvedContent(null) setResolvedContent(null)
setError({ setError({
kind: 'no-content', kind: 'no-content',
message: isAgentSource ? 'Run the Agent node to generate output, then click Run here.' : 'Click Run to render.', message: 'Click Run to render.',
}) })
setLoading(false) setLoading(false)
return return
@@ -289,11 +291,13 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const context = { const context = {
nodes, nodes,
edges, edges,
sourceNodeId: srcId, sourceNodeId: srcId!,
renderNodeId: id, renderNodeId: id,
viewportWidth, viewportWidth,
viewportHeight, viewportHeight,
} setNodes: setNodes ?? undefined,
aiConnection,
} as SourceRenderingLogicContext
const { resolved, outputTypeId } = await logic.getResolvedContent(context) const { resolved, outputTypeId } = await logic.getResolvedContent(context)
if (cancelled || thisRunId !== runIdRef.current) return if (cancelled || thisRunId !== runIdRef.current) return
setResolvedContent(resolved) setResolvedContent(resolved)
@@ -351,7 +355,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
} }
setLoading(false) setLoading(false)
} }
}, [id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceContent, sourceSignature, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, nodes, edges, updateData]) }, [id, srcId, srcNode?.type, effectiveUpdateMode, runTrigger, sourceContent, sourceSignature, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, nodes, edges, updateData, setNodes, aiConnection])
const dimensions = const dimensions =
@@ -579,9 +583,9 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
> >
Raw Raw
</MenubarCheckboxItem> </MenubarCheckboxItem>
<MenubarSeparator />
{outputType === 'image' && ( {outputType === 'image' && (
<MenubarSub> <MenubarSub>
<MenubarSeparator />
<MenubarSubTrigger className="text-xs">Viewport size</MenubarSubTrigger> <MenubarSubTrigger className="text-xs">Viewport size</MenubarSubTrigger>
<MenubarSubContent className="min-w-[12rem] p-2"> <MenubarSubContent className="min-w-[12rem] p-2">
<div className="grid gap-2"> <div className="grid gap-2">

View File

@@ -40,7 +40,7 @@ export type FlowContextValue = {
connectionPathPausedSegmentNodeIds: Set<string> connectionPathPausedSegmentNodeIds: Set<string>
/** Path nodes not in the paused segment (downstream of pause). Only those edges are blue (updating). */ /** Path nodes not in the paused segment (downstream of pause). Only those edges are blue (updating). */
connectionPathActiveSegmentNodeIds: Set<string> connectionPathActiveSegmentNodeIds: Set<string>
/** Archon-type nodes that are on hold (e.g. Agent waiting for Run). */ /** Nodes that are on hold (e.g. Renderer in manual mode waiting for Run). */
connectionPathPausedNodeIds: string[] connectionPathPausedNodeIds: string[]
/** Add this node as paused (on hold); remove when user continues. */ /** Add this node as paused (on hold); remove when user continues. */
addConnectionPathPausedNode: (nodeId: string) => void addConnectionPathPausedNode: (nodeId: string) => void

View File

@@ -92,7 +92,7 @@ export function getPathNodeIds(
} }
/** /**
* Path nodes from triggers up to and including the first paused node (Archon on hold). * Path nodes from triggers up to and including the first paused node (e.g. Renderer waiting for Run).
* Used to color those edges yellow; rest of path stays blue. * Used to color those edges yellow; rest of path stays blue.
*/ */
export function getPausedSegmentNodeIds( export function getPausedSegmentNodeIds(

View File

@@ -8,10 +8,10 @@
* - **Trigger** Node's output changed (e.g. config content, variable value). Reported * - **Trigger** Node's output changed (e.g. config content, variable value). Reported
* automatically when the node calls `updateData()` from useAbstractNode. Downstream * automatically when the node calls `updateData()` from useAbstractNode. Downstream
* path is computed from triggers + updating nodes. * path is computed from triggers + updating nodes.
* - **Updating** Node is doing async work (e.g. agent running, renderer loading). * - **Updating** Node is doing async work (e.g. renderer loading, agent run triggered by renderer).
* Report `updating: true` at start, `updating: false` when done. Incoming/outgoing * Report `updating: true` at start, `updating: false` when done. Incoming/outgoing
* edges on the path show "updating" (blue). * edges on the path show "updating" (blue).
* - **Paused** Node is on hold (e.g. agent waiting for Run after inputs changed). * - **Paused** Node is on hold (e.g. renderer in manual mode waiting for Run after inputs changed).
* Report `paused: true` when waiting, `paused: false` when not. Edges in the paused * Report `paused: true` when waiting, `paused: false` when not. Edges in the paused
* segment show "paused" (yellow). * segment show "paused" (yellow).
* - **Error** Node has an error to show. Report `error: true` when error is set, * - **Error** Node has an error to show. Report `error: true` when error is set,

View File

@@ -16,6 +16,10 @@ export type SourceRenderingLogicContext = {
renderNodeId: string renderNodeId: string
viewportWidth?: number viewportWidth?: number
viewportHeight?: number viewportHeight?: number
/** Optional: allows source logic to update other nodes (e.g. agent run updates agent node). */
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
} }
/** /**