refactor: nodes as builder pattern
This commit is contained in:
247
frontend/src/components/nodes/agent/AgentNode.tsx
Normal file
247
frontend/src/components/nodes/agent/AgentNode.tsx
Normal 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
|
||||
24
frontend/src/components/nodes/agent/descriptor.tsx
Normal file
24
frontend/src/components/nodes/agent/descriptor.tsx
Normal 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()
|
||||
}
|
||||
2
frontend/src/components/nodes/agent/index.ts
Normal file
2
frontend/src/components/nodes/agent/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AgentNode, type AgentNodeData } from './AgentNode'
|
||||
export { getAgentNodeDescriptor } from './descriptor'
|
||||
15
frontend/src/components/nodes/agent/renderingLogic.ts
Normal file
15
frontend/src/components/nodes/agent/renderingLogic.ts
Normal 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' }
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user