feat: rendering for agents
This commit is contained in:
@@ -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<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
|
||||
@@ -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<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
|
||||
|
||||
// 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) {
|
||||
<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>
|
||||
@@ -224,19 +110,14 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
</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>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connect to a Renderer node and click Run there to generate output.
|
||||
</p>
|
||||
</div>
|
||||
</BaseNodeContent>
|
||||
<BaseNodeFooter>
|
||||
<NodeFooterEdgeIndicators nodeId={id} nodeType="agent">
|
||||
{outputMarkdown != null ? `${outputMarkdown.length} chars` : error ? 'Error' : '—'}
|
||||
—
|
||||
</NodeFooterEdgeIndicators>
|
||||
</BaseNodeFooter>
|
||||
</BaseNode>
|
||||
|
||||
@@ -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<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' }
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user