refactor: nodes as builder pattern

This commit is contained in:
2026-03-12 11:01:35 +01:00
parent 302107e710
commit 86238b7efc
28 changed files with 1130 additions and 611 deletions

View File

@@ -0,0 +1,247 @@
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
} from '@/lib/abstractNode'
import { getConfigContent } from '@/lib/configTypes'
import {
BaseNode,
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from '@/components/base/BaseNode'
import { NodeMenubar } from '@/components/base/NodeMenubar'
import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
import { InputHandle, OutputHandle } from '@/components/base/NodeHandles'
import FlowContext from '@/lib/flowContext'
import { useSyncConnectionStatus } from '@/lib/nodeLifecycle'
import { getNodeType } from '@/lib/nodeRegistry'
import { Button } from '@/components/ui/button'
import { usePlatform } from '@/app/kosmos/KosmosContext'
import { Bot, Play, Loader2 } from 'lucide-react'
export type AgentNodeData = {
context?: string
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. */
lastRunSourceSignature?: string
}
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) {
const flowContext = useContext(FlowContext)
const setFullscreenNodeId = flowContext?.setFullscreenNodeId
const supportsFullscreen = getNodeType('agent')?.supportsFullscreen
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
const { aiConnection } = usePlatform()
const [running, setRunning] = useState(false)
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: 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) => {
const node = nodes.find((n: { id: string }) => n.id === sid)
return { id: sid, type: (node as { type?: string } | undefined)?.type ?? 'unknown' }
})
}, [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(
(e: React.ChangeEvent<HTMLTextAreaElement>) => 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
useSyncConnectionStatus(id, {
updating: running || loading,
error: !!error,
paused: hasPendingInputs && !running && !loading,
})
return (
<BaseNode
className="min-w-[360px] min-h-[320px]"
dimensions={dimensions}
resizable
nodeId={id}
selected={selected}
handles={
<>
<InputHandle id="in" nodeId={id} />
<OutputHandle id="out" />
</>
}
>
<BaseNodeHeaderRow
icon={<Bot className="size-4" />}
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}
/>
<BaseNodeContent>
<div className="shrink-0 w-full">
<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 className="text-xs font-medium text-foreground">Context</label>
<textarea
className="nodrag nopan w-full min-h-[72px] rounded-md border border-input bg-background px-2 py-1.5 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Additional context for the agent…"
value={contextText}
onChange={onContextChange}
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">Context from connected nodes</span>
{connectedSources.length === 0 ? (
<p className="text-xs text-muted-foreground">Connect Config, Variable, or Data nodes as input.</p>
) : (
<ul className="text-xs text-muted-foreground list-disc list-inside space-y-0.5">
{connectedSources.map(({ id: sid, type }) => (
<li key={sid}>
<code className="rounded bg-muted px-1">{sid}</code> ({type})
</li>
))}
</ul>
)}
</div>
{error && (
<p className="text-xs text-destructive">{error}</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>
</BaseNodeContent>
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="agent">
{outputMarkdown != null ? `${outputMarkdown.length} chars` : error ? 'Error' : '—'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>
)
}
export const AgentNode = createAbstractNodeComponent<AgentNodeData>('AgentNode', AgentNodeComponent)
export default AgentNode

View File

@@ -0,0 +1,24 @@
import React from 'react'
import { Bot } from 'lucide-react'
import { createNodeTypeBuilder } from '@/lib/nodeTypeBuilder'
import { NODE_HELP } from '@/lib/nodeHelp'
import type { NodeTypeDescriptor } from '@/lib/nodeRegistry'
import AgentNode from './AgentNode'
import { agentRenderingLogic } from './renderingLogic'
const ICON_CLASS = 'mr-2 h-4 w-4'
export function getAgentNodeDescriptor(): NodeTypeDescriptor {
return createNodeTypeBuilder('agent', AgentNode, { width: 360, height: 320 }, { context: '' })
.idPrefix('agt_')
.withInputOutput(true, true)
.classification('archon')
.allowedSourceTypes(['config', 'variable', 'data'])
.allowedTargetTypes(['render'])
.help(NODE_HELP.agent)
.menu('Agent', <Bot className={ICON_CLASS} />)
.connectionLabel('prompt/context')
.withFullscreen()
.sourceRenderingLogic(agentRenderingLogic)
.build()
}

View File

@@ -0,0 +1,2 @@
export { default as AgentNode, type AgentNodeData } from './AgentNode'
export { getAgentNodeDescriptor } from './descriptor'

View File

@@ -0,0 +1,15 @@
/**
* Agent node rendering logic: provides the agent's markdown output to the Rendering node.
* Update mode is manual (user clicks Run on the renderer).
*/
import type { SourceRenderingLogic } from '@/lib/sourceRenderingLogic'
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 ?? ''
return { resolved: outputMarkdown, outputTypeId: 'markdown' }
},
}