From f43e2df019657f49e8e60c007fc870fae11296bd Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 12 Mar 2026 13:05:52 +0100 Subject: [PATCH] feat: rendering for agents --- .../src/components/nodes/agent/AgentNode.tsx | 147 ++---------------- .../components/nodes/agent/renderingLogic.ts | 132 +++++++++++++++- .../components/nodes/render/RenderingNode.tsx | 16 +- frontend/src/lib/graph/flowContext.tsx | 2 +- frontend/src/lib/graph/graphPath.ts | 2 +- frontend/src/lib/graph/nodeLifecycle.ts | 4 +- .../src/lib/graph/sourceRenderingLogic.ts | 4 + 7 files changed, 156 insertions(+), 151 deletions(-) diff --git a/frontend/src/components/nodes/agent/AgentNode.tsx b/frontend/src/components/nodes/agent/AgentNode.tsx index f93b997..6ce4f88 100644 --- a/frontend/src/components/nodes/agent/AgentNode.tsx +++ b/frontend/src/components/nodes/agent/AgentNode.tsx @@ -1,10 +1,9 @@ -import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useContext, useMemo } from 'react' import { AbstractNodeProps, createAbstractNodeComponent, useAbstractNode, } from '@/lib/graph/abstractNode' -import { getConfigContent } from '@/lib/graph/configTypes' import { BaseNode, BaseNodeContent, @@ -18,50 +17,24 @@ import { InputHandle, OutputHandle } from '@/components/graph/NodeHandles' import FlowContext from '@/lib/graph/flowContext' import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle' import { getNodeType } from '@/lib/graph/nodeRegistry' -import { Button } from '@/components/ui/button' -import { usePlatform } from '@/app/kosmos/KosmosContext' -import { Bot, Play, Loader2 } from 'lucide-react' +import { Bot } from 'lucide-react' export type AgentNodeData = { + /** Additional context for the agent (configuration). */ context?: string + /** Cached output from last run; set by connected Rendering node. Not displayed on this node. */ outputMarkdown?: string - error?: string - loading?: boolean - /** Signature of inputs (sourceIds + config contents) from the last successful run. Used to show on-hold when current inputs differ. */ + /** Signature of inputs from last successful run; set by Rendering node when it runs the agent. Used as cache. */ lastRunSourceSignature?: string } type Props = AbstractNodeProps -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 | 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[] | 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) { const flowContext = useContext(FlowContext) const setFullscreenNodeId = flowContext?.setFullscreenNodeId const supportsFullscreen = getNodeType('agent')?.supportsFullscreen const { nodes, sourceIds, updateData } = useAbstractNode(id, data ?? {}) - const { aiConnection } = usePlatform() - const [running, setRunning] = useState(false) const dimensions = width != null && height != null && width > 0 && height > 0 @@ -69,9 +42,6 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) { : undefined const contextText = data?.context ?? '' - const outputMarkdown = data?.outputMarkdown - const error = data?.error - const loading = data?.loading ?? false const connectedSources = useMemo(() => { return sourceIds.map((sid) => { @@ -80,84 +50,17 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) { }) }, [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 | 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 | 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( (e: React.ChangeEvent) => updateData({ context: e.target.value }), [updateData] ) - const pathNodeIds = flowContext?.connectionPathNodeIds - const triggerNodeIds = flowContext?.connectionPathTriggerNodeIds ?? [] - const lastRunSourceSignature = data?.lastRunSourceSignature - const hasPendingInputs = - pathNodeIds?.has(id) && - triggerNodeIds.length > 0 && - !loading && - sourceSignature !== lastRunSourceSignature - + // Agent is configuration-only; it does not block the flow. Only the Rendering node reports + // paused/updating, so the connection path runs trigger → … → agent (on-path) → rendering (paused). useSyncConnectionStatus(id, { - updating: running || loading, - error: !!error, - paused: hasPendingInputs && !running && !loading, + updating: false, + error: false, + paused: false, }) return ( @@ -177,23 +80,6 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) { } title={} - right={ - - } onHeaderDoubleClick={supportsFullscreen && setFullscreenNodeId ? () => setFullscreenNodeId(id) : undefined} /> @@ -224,19 +110,14 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) { )} - {error && ( -

{error}

- )} - {outputMarkdown != null && outputMarkdown !== '' && !error && ( -
- Output: {outputMarkdown.length} chars (connect to Renderer to view) -
- )} +

+ Connect to a Renderer node and click Run there to generate output. +

- {outputMarkdown != null ? `${outputMarkdown.length} chars` : error ? 'Error' : '—'} + — diff --git a/frontend/src/components/nodes/agent/renderingLogic.ts b/frontend/src/components/nodes/agent/renderingLogic.ts index ffb2ed1..7d7cf18 100644 --- a/frontend/src/components/nodes/agent/renderingLogic.ts +++ b/frontend/src/components/nodes/agent/renderingLogic.ts @@ -1,15 +1,131 @@ /** - * Agent node rendering logic: provides the agent's markdown output to the Rendering node. - * Update mode is manual (user clicks Run on the renderer). + * Agent node rendering logic: when the Rendering node runs, it calls the agent API + * 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 = { - defaultUpdateMode: 'manual', - getResolvedContent: async (context) => { - const sourceNode = context.nodes.find((n) => n.id === context.sourceNodeId) - const outputMarkdown = (sourceNode?.data as { outputMarkdown?: string } | undefined)?.outputMarkdown ?? '' +function serializeNodeForContext( + nodes: { id: string; type?: string; data?: unknown }[], + nodeId: string +): string { + 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 | 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[] | 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 | undefined + ) + ) + return JSON.stringify({ sourceIds: sourceIds.slice().sort(), configContents }) +} + +export const agentRenderingLogic = { + defaultUpdateMode: 'manual' as const, + getResolvedContent: async (context: SourceRenderingLogicContext): Promise => { + 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) => { + 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 | 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' } }, } diff --git a/frontend/src/components/nodes/render/RenderingNode.tsx b/frontend/src/components/nodes/render/RenderingNode.tsx index 861ddab..bc7e36a 100644 --- a/frontend/src/components/nodes/render/RenderingNode.tsx +++ b/frontend/src/components/nodes/render/RenderingNode.tsx @@ -9,7 +9,7 @@ import { useAbstractNode, } from '@/lib/graph/abstractNode' 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 { useResizeHeight } from '@/hooks/useResizeHeight' import { plantumlLanguage } from '@/lib/plantumlLanguage' @@ -41,6 +41,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { usePlatform } from '@/app/kosmos/KosmosContext' import { cn } from '@/lib/utils' import { useTheme } from '@/lib/themeContext' import { toast } from 'sonner' @@ -79,6 +80,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const minLoadingTimeoutRef = useRef | null>(null) const lastManualRunTriggerRef = useRef(0) const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode(id, data ?? {}) + const { aiConnection } = usePlatform() const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT @@ -265,7 +267,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { setResolvedContent(null) setError({ 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) return @@ -289,11 +291,13 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { const context = { nodes, edges, - sourceNodeId: srcId, + sourceNodeId: srcId!, renderNodeId: id, viewportWidth, viewportHeight, - } + setNodes: setNodes ?? undefined, + aiConnection, + } as SourceRenderingLogicContext const { resolved, outputTypeId } = await logic.getResolvedContent(context) if (cancelled || thisRunId !== runIdRef.current) return setResolvedContent(resolved) @@ -351,7 +355,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { } 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 = @@ -579,9 +583,9 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) { > Raw - {outputType === 'image' && ( + Viewport size
diff --git a/frontend/src/lib/graph/flowContext.tsx b/frontend/src/lib/graph/flowContext.tsx index 55f368f..896925b 100644 --- a/frontend/src/lib/graph/flowContext.tsx +++ b/frontend/src/lib/graph/flowContext.tsx @@ -40,7 +40,7 @@ export type FlowContextValue = { connectionPathPausedSegmentNodeIds: Set /** Path nodes not in the paused segment (downstream of pause). Only those edges are blue (updating). */ connectionPathActiveSegmentNodeIds: Set - /** 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[] /** Add this node as paused (on hold); remove when user continues. */ addConnectionPathPausedNode: (nodeId: string) => void diff --git a/frontend/src/lib/graph/graphPath.ts b/frontend/src/lib/graph/graphPath.ts index 5ac4668..3d896e8 100644 --- a/frontend/src/lib/graph/graphPath.ts +++ b/frontend/src/lib/graph/graphPath.ts @@ -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. */ export function getPausedSegmentNodeIds( diff --git a/frontend/src/lib/graph/nodeLifecycle.ts b/frontend/src/lib/graph/nodeLifecycle.ts index f44eb56..aecbcbd 100644 --- a/frontend/src/lib/graph/nodeLifecycle.ts +++ b/frontend/src/lib/graph/nodeLifecycle.ts @@ -8,10 +8,10 @@ * - **Trigger** – Node's output changed (e.g. config content, variable value). Reported * automatically when the node calls `updateData()` from useAbstractNode. Downstream * 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 * 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 * segment show "paused" (yellow). * - **Error** – Node has an error to show. Report `error: true` when error is set, diff --git a/frontend/src/lib/graph/sourceRenderingLogic.ts b/frontend/src/lib/graph/sourceRenderingLogic.ts index c45af4e..8637190 100644 --- a/frontend/src/lib/graph/sourceRenderingLogic.ts +++ b/frontend/src/lib/graph/sourceRenderingLogic.ts @@ -16,6 +16,10 @@ export type SourceRenderingLogicContext = { renderNodeId: string viewportWidth?: 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 } /**