Compare commits
4 Commits
871fd346e3
...
2a0507ca02
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a0507ca02 | |||
| e4d58750d3 | |||
| 909359ea53 | |||
| f43e2df019 |
@@ -80,18 +80,13 @@ app.delete('/api/todos/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** POST /api/agent — run AI agent; body: { prompt, context?, contextNodes?, connection? }; returns { markdown }.
|
/** Build OpenAI client and full prompt from request body. Returns { openai, modelId, fullPrompt } or { error }. */
|
||||||
* connection (from Settings): { provider, baseURL?, model?, apiKey? }. If omitted, uses env (OPENAI_API_KEY / AI_BASE_URL / AI_MODEL).
|
function buildAgentRequest(body) {
|
||||||
*/
|
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
|
||||||
app.post('/api/agent', async (req, res) => {
|
const reasoningEnabled = Boolean(reasoning)
|
||||||
try {
|
|
||||||
const body = req.body ?? {}
|
|
||||||
const { prompt, context, contextNodes, connection: conn } = body
|
|
||||||
|
|
||||||
let baseURL = process.env.AI_BASE_URL?.trim() || null
|
let baseURL = process.env.AI_BASE_URL?.trim() || null
|
||||||
let apiKey = process.env.OPENAI_API_KEY?.trim() || null
|
let apiKey = process.env.OPENAI_API_KEY?.trim() || null
|
||||||
let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
|
let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
|
||||||
|
|
||||||
if (conn && typeof conn === 'object') {
|
if (conn && typeof conn === 'object') {
|
||||||
const c = conn
|
const c = conn
|
||||||
const provider = c.provider === 'openai' ? 'openai' : 'local'
|
const provider = c.provider === 'openai' ? 'openai' : 'local'
|
||||||
@@ -104,30 +99,36 @@ app.post('/api/agent', async (req, res) => {
|
|||||||
}
|
}
|
||||||
if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
|
if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!baseURL && !apiKey) {
|
if (!baseURL && !apiKey) {
|
||||||
return res.status(503).json({
|
return { error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.' }
|
||||||
error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
const basePrompt = [
|
||||||
const fullPrompt = [
|
|
||||||
typeof prompt === 'string' ? prompt : 'No prompt provided.',
|
typeof prompt === 'string' ? prompt : 'No prompt provided.',
|
||||||
context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
|
context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
|
||||||
Array.isArray(contextNodes) && contextNodes.length > 0
|
Array.isArray(contextNodes) && contextNodes.length > 0
|
||||||
? `\n\nContext from connected nodes:\n${contextNodes.map((n) => (n.content != null ? n.content : `${n.id}: (no content)`)).join('\n\n')}`
|
? `\n\nContext from connected nodes:\n${contextNodes.map((n) => (n.content != null ? n.content : `${n.id}: (no content)`)).join('\n\n')}`
|
||||||
: '',
|
: '',
|
||||||
].join('')
|
].join('')
|
||||||
|
const fullPrompt = reasoningEnabled
|
||||||
const { generateText } = await import('ai')
|
? basePrompt + '\n\nRespond in exactly two markdown sections. First: "## Reasoning" with your step-by-step reasoning. Then: "## Output" with only the final answer. No preamble.'
|
||||||
const { createOpenAI } = await import('@ai-sdk/openai')
|
: basePrompt + '\n\nRespond with structured markdown only. No preamble.'
|
||||||
|
const { createOpenAI } = require('@ai-sdk/openai')
|
||||||
const openai = createOpenAI({
|
const openai = createOpenAI({
|
||||||
apiKey: apiKey || 'lm-studio',
|
apiKey: apiKey || 'lm-studio',
|
||||||
...(baseURL && { baseURL, compatibility: 'compatible' }),
|
...(baseURL && { baseURL, compatibility: 'compatible' }),
|
||||||
})
|
})
|
||||||
|
return { openai, modelId, fullPrompt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/agent — run AI agent; body: { prompt, context?, contextNodes?, connection? }; returns { markdown }. */
|
||||||
|
app.post('/api/agent', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const built = buildAgentRequest(req.body)
|
||||||
|
if (built.error) return res.status(503).json({ error: built.error })
|
||||||
|
const { generateText } = await import('ai')
|
||||||
const result = await generateText({
|
const result = await generateText({
|
||||||
model: openai(modelId),
|
model: built.openai(built.modelId),
|
||||||
prompt: fullPrompt + '\n\nRespond with structured markdown only. No preamble.',
|
prompt: built.fullPrompt,
|
||||||
})
|
})
|
||||||
const markdown = result?.text ?? ''
|
const markdown = result?.text ?? ''
|
||||||
res.json({ markdown })
|
res.json({ markdown })
|
||||||
@@ -137,6 +138,25 @@ app.post('/api/agent', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** POST /api/agent/stream — same as /api/agent but streams plain text (markdown) chunks. */
|
||||||
|
app.post('/api/agent/stream', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const built = buildAgentRequest(req.body)
|
||||||
|
if (built.error) return res.status(503).json({ error: built.error })
|
||||||
|
const { streamText } = await import('ai')
|
||||||
|
const result = streamText({
|
||||||
|
model: built.openai(built.modelId),
|
||||||
|
prompt: built.fullPrompt,
|
||||||
|
})
|
||||||
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
res.setHeader('Transfer-Encoding', 'chunked')
|
||||||
|
result.pipeTextStreamToResponse(res)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Agent stream error:', err)
|
||||||
|
res.status(500).json({ error: err?.message ?? 'Agent request failed' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/** Health check for Docker / orchestration */
|
/** Health check for Docker / orchestration */
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.status(200).json({ ok: true })
|
res.status(200).json({ ok: true })
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { ComponentProps, ReactNode } from "react";
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
import { NodeResizer } from "@xyflow/react";
|
import { NodeResizer } from "@xyflow/react";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
import FlowContext from "@/lib/graph/flowContext";
|
||||||
import { useConnectionPathRole } from "@/lib/graph/flowContext";
|
import { useConnectionPathRole } from "@/lib/graph/flowContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -35,6 +37,8 @@ export function BaseNode({
|
|||||||
resizeConstraints,
|
resizeConstraints,
|
||||||
...props
|
...props
|
||||||
}: BaseNodeProps) {
|
}: BaseNodeProps) {
|
||||||
|
const flowContext = useContext(FlowContext);
|
||||||
|
const isFullscreenInstance = Boolean(nodeId && flowContext?.fullscreenNodeId === nodeId);
|
||||||
const connectionPathRole = useConnectionPathRole(nodeId);
|
const connectionPathRole = useConnectionPathRole(nodeId);
|
||||||
const hasSize =
|
const hasSize =
|
||||||
dimensions &&
|
dimensions &&
|
||||||
@@ -81,7 +85,7 @@ export function BaseNode({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{resizable && nodeId && (
|
{resizable && nodeId && !isFullscreenInstance && (
|
||||||
<NodeResizer
|
<NodeResizer
|
||||||
nodeId={nodeId}
|
nodeId={nodeId}
|
||||||
isVisible={selected}
|
isVisible={selected}
|
||||||
@@ -93,7 +97,7 @@ export function BaseNode({
|
|||||||
handleClassName="base-node-resize-handle nodrag nopan"
|
handleClassName="base-node-resize-handle nodrag nopan"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{handles}
|
{!isFullscreenInstance && handles}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,27 @@ 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 { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Bot, Play, Loader2 } from 'lucide-react'
|
|
||||||
|
|
||||||
export type AgentNodeData = {
|
export type AgentNodeData = {
|
||||||
|
/** Additional context for the agent (configuration). */
|
||||||
context?: string
|
context?: string
|
||||||
|
/** When true, agent returns reasoning in a "## Reasoning" section and output in "## Output". */
|
||||||
|
reasoning?: boolean
|
||||||
|
/** 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 +45,12 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const contextText = data?.context ?? ''
|
const contextText = data?.context ?? ''
|
||||||
const outputMarkdown = data?.outputMarkdown
|
const reasoningEnabled = data?.reasoning ?? false
|
||||||
const error = data?.error
|
|
||||||
const loading = data?.loading ?? false
|
const onReasoningChange = useCallback(
|
||||||
|
(checked: boolean) => updateData({ reasoning: checked }),
|
||||||
|
[updateData]
|
||||||
|
)
|
||||||
|
|
||||||
const connectedSources = useMemo(() => {
|
const connectedSources = useMemo(() => {
|
||||||
return sourceIds.map((sid) => {
|
return sourceIds.map((sid) => {
|
||||||
@@ -80,84 +59,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 +89,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>
|
||||||
@@ -201,6 +96,21 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
<NodeMenubar nodeId={id} nodeType="agent" />
|
<NodeMenubar nodeId={id} nodeType="agent" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 p-2 min-h-0 flex-1">
|
<div className="flex flex-col gap-3 p-2 min-h-0 flex-1">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor={`agent-reasoning-${id}`} className="flex items-center gap-2 text-xs font-medium text-foreground cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
id={`agent-reasoning-${id}`}
|
||||||
|
checked={reasoningEnabled}
|
||||||
|
onCheckedChange={(c) => onReasoningChange(c === true)}
|
||||||
|
className="nodrag nopan"
|
||||||
|
aria-label="Enable reasoning"
|
||||||
|
/>
|
||||||
|
Enable reasoning
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When enabled, the model will output a Reasoning section and an Output section; the renderer shows them separately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-xs font-medium text-foreground">Context</label>
|
<label className="text-xs font-medium text-foreground">Context</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -224,19 +134,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>
|
||||||
|
|||||||
@@ -1,15 +1,209 @@
|
|||||||
/**
|
/**
|
||||||
* 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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse agent response into reasoning and output when format is "## Reasoning" / "## Output". */
|
||||||
|
function parseReasoningAndOutput(fullMarkdown: string): { reasoning?: string; output: string } {
|
||||||
|
const normalized = fullMarkdown.trim()
|
||||||
|
const outputMatch = normalized.match(/\s*##\s*Output\s*/i)
|
||||||
|
if (!outputMatch) return { output: fullMarkdown }
|
||||||
|
const idx = outputMatch.index! + outputMatch[0].length
|
||||||
|
const output = normalized.slice(idx).trim()
|
||||||
|
const beforeOutput = normalized.slice(0, outputMatch.index).trim()
|
||||||
|
const reasoningMatch = beforeOutput.match(/\s*##\s*Reasoning\s*/i)
|
||||||
|
const reasoning = reasoningMatch
|
||||||
|
? beforeOutput.slice(reasoningMatch.index! + reasoningMatch[0].length).trim()
|
||||||
|
: beforeOutput
|
||||||
|
return { reasoning: reasoning || undefined, output: output || fullMarkdown }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentRenderingLogic = {
|
||||||
|
defaultUpdateMode: 'manual' as const,
|
||||||
|
getResolvedContent: async (context: SourceRenderingLogicContext): Promise<ResolvedContentResult> => {
|
||||||
|
const { nodes, sourceNodeId, setNodes, aiConnection, onStreamingStart, onStreamingChunk } = context
|
||||||
|
const sourceNode = nodes.find((n) => n.id === sourceNodeId)
|
||||||
|
const agentData = sourceNode?.data as { context?: string; outputMarkdown?: string; reasoning?: boolean } | undefined
|
||||||
|
const contextText = agentData?.context ?? ''
|
||||||
|
const reasoningEnabled = Boolean(agentData?.reasoning)
|
||||||
|
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)
|
||||||
|
const useStream = Boolean(onStreamingChunk)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (useStream) {
|
||||||
|
onStreamingStart?.()
|
||||||
|
const res = await fetch('/api/agent/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
context: contextText.trim() || undefined,
|
||||||
|
contextNodes,
|
||||||
|
connection,
|
||||||
|
reasoning: reasoningEnabled,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '')
|
||||||
|
let msg = `Request failed: ${res.status}`
|
||||||
|
try {
|
||||||
|
const json = text ? JSON.parse(text) : {}
|
||||||
|
if (typeof (json as { error?: string }).error === 'string') msg = (json as { error: string }).error
|
||||||
|
} catch {
|
||||||
|
if (text) msg = text.slice(0, 200)
|
||||||
|
}
|
||||||
|
setAgentData({ outputMarkdown: undefined })
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
const reader = res.body?.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let markdown = ''
|
||||||
|
try {
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
|
markdown += chunk
|
||||||
|
onStreamingChunk?.(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader?.cancel()
|
||||||
|
}
|
||||||
|
setAgentData({
|
||||||
|
outputMarkdown: markdown,
|
||||||
|
lastRunSourceSignature: sourceSignature,
|
||||||
|
})
|
||||||
|
if (reasoningEnabled) {
|
||||||
|
const { reasoning: reasoningText, output } = parseReasoningAndOutput(markdown)
|
||||||
|
return { resolved: output, outputTypeId: 'markdown' as const, reasoning: reasoningText }
|
||||||
|
}
|
||||||
|
return { resolved: markdown, outputTypeId: 'markdown' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/agent', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
context: contextText.trim() || undefined,
|
||||||
|
contextNodes,
|
||||||
|
connection,
|
||||||
|
reasoning: reasoningEnabled,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
if (reasoningEnabled) {
|
||||||
|
const { reasoning: reasoningText, output } = parseReasoningAndOutput(markdown)
|
||||||
|
return { resolved: output, outputTypeId: 'markdown' as const, reasoning: reasoningText }
|
||||||
|
}
|
||||||
|
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 ?? ''
|
||||||
|
if (reasoningEnabled && outputMarkdown) {
|
||||||
|
const { reasoning: reasoningText, output } = parseReasoningAndOutput(outputMarkdown)
|
||||||
|
return { resolved: output, outputTypeId: 'markdown' as const, reasoning: reasoningText }
|
||||||
|
}
|
||||||
return { resolved: outputMarkdown, outputTypeId: 'markdown' }
|
return { resolved: outputMarkdown, outputTypeId: 'markdown' }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -28,10 +28,10 @@ import { NodeHeaderTitle } from '@/components/graph/NodeHeaderTitle'
|
|||||||
import { NodeMenubar } from '@/components/graph/NodeMenubar'
|
import { NodeMenubar } from '@/components/graph/NodeMenubar'
|
||||||
import { NodeStatusIndicator } from '@/components/graph/NodeStatusIndicator'
|
import { NodeStatusIndicator } from '@/components/graph/NodeStatusIndicator'
|
||||||
import { MenubarCheckboxItem, MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '@/components/ui/menubar'
|
import { MenubarCheckboxItem, MenubarItem, MenubarSeparator, MenubarSub, MenubarSubContent, MenubarSubTrigger } from '@/components/ui/menubar'
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||||
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy, Play, ChevronDown, Loader2 } from 'lucide-react'
|
import { Sparkles, ZoomIn, ZoomOut, RotateCcw, RotateCw, Copy, Play, ChevronDown, Loader2 } from 'lucide-react'
|
||||||
import { InputHandle } from '@/components/graph/NodeHandles'
|
import { InputHandle } from '@/components/graph/NodeHandles'
|
||||||
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ButtonGroup } from '@/components/ui/button-group'
|
import { ButtonGroup } from '@/components/ui/button-group'
|
||||||
import {
|
import {
|
||||||
@@ -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'
|
||||||
@@ -54,6 +55,10 @@ export type RenderingNodeData = {
|
|||||||
runTrigger?: number
|
runTrigger?: number
|
||||||
/** Signature of inputs used in the last successful render. Used in manual mode to show paused (yellow) when upstream changed. */
|
/** Signature of inputs used in the last successful render. Used in manual mode to show paused (yellow) when upstream changed. */
|
||||||
lastRunSourceSignature?: string
|
lastRunSourceSignature?: string
|
||||||
|
/** Cached render output so fullscreen can show content (set when render completes, cleared when new run starts). */
|
||||||
|
cachedRenderedContent?: string
|
||||||
|
cachedResolvedContent?: string
|
||||||
|
cachedReasoningContent?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_VIEWPORT_WIDTH = 1200
|
const DEFAULT_VIEWPORT_WIDTH = 1200
|
||||||
@@ -63,14 +68,31 @@ type Props = AbstractNodeProps<RenderingNodeData>
|
|||||||
|
|
||||||
type ViewMode = 'preview' | 'raw'
|
type ViewMode = 'preview' | 'raw'
|
||||||
|
|
||||||
|
/** Extract <think>...</think> blocks from HTML/markdown; return main content (with blocks removed) and think content for a collapsible. */
|
||||||
|
function parseThinkSections(html: string): { main: string; think: string } {
|
||||||
|
const thinkRegex = /<think>([\s\S]*?)<\/think>/gi
|
||||||
|
const thinkParts: string[] = []
|
||||||
|
let match
|
||||||
|
while ((match = thinkRegex.exec(html)) !== null) {
|
||||||
|
thinkParts.push(match[1].trim())
|
||||||
|
}
|
||||||
|
const think = thinkParts.join('\n\n')
|
||||||
|
const main = html.replace(thinkRegex, '').replace(/\n{3,}/g, '\n\n').trim()
|
||||||
|
return { main, think }
|
||||||
|
}
|
||||||
|
|
||||||
function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
function RenderingNodeComponent({ 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('render')?.supportsFullscreen
|
const supportsFullscreen = getNodeType('render')?.supportsFullscreen
|
||||||
const [renderedContent, setRenderedContent] = useState<string | null>(null)
|
const [renderedContent, setRenderedContent] = useState<string | null>(() => (data?.cachedRenderedContent as string | undefined) ?? null)
|
||||||
const [resolvedContent, setResolvedContent] = useState<string | null>(null)
|
const [resolvedContent, setResolvedContent] = useState<string | null>(() => (data?.cachedResolvedContent as string | undefined) ?? null)
|
||||||
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
const [error, setError] = useState<null | { kind: string; message: string }>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [streamingMarkdown, setStreamingMarkdown] = useState<string | null>(null)
|
||||||
|
const [streamingPreviewHtml, setStreamingPreviewHtml] = useState<string>('')
|
||||||
|
const [reasoningContent, setReasoningContent] = useState<string>(() => (data?.cachedReasoningContent as string | undefined) ?? '')
|
||||||
|
const [reasoningHtml, setReasoningHtml] = useState<string>('')
|
||||||
const [retryCount, setRetryCount] = useState(0)
|
const [retryCount, setRetryCount] = useState(0)
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('preview')
|
const [viewMode, setViewMode] = useState<ViewMode>('preview')
|
||||||
const [viewportFocused, setViewportFocused] = useState(false)
|
const [viewportFocused, setViewportFocused] = useState(false)
|
||||||
@@ -78,7 +100,11 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
const loadingStartedAtRef = useRef<number | null>(null)
|
const loadingStartedAtRef = useRef<number | null>(null)
|
||||||
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 manualRunTriggerSyncedRef = useRef<boolean>(false)
|
||||||
|
const nodesEdgesRef = useRef<{ nodes: unknown[]; edges: unknown[] }>({ nodes: [], edges: [] })
|
||||||
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
const { nodes, edges, setNodes, setEdges, sourceIds, updateData } = useAbstractNode<RenderingNodeData>(id, data ?? {})
|
||||||
|
nodesEdgesRef.current = { nodes, edges }
|
||||||
|
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
|
||||||
|
|
||||||
@@ -260,20 +286,30 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (effectiveUpdateMode === 'manual' && runTrigger === 0) {
|
if (effectiveUpdateMode === 'manual') {
|
||||||
|
if (!manualRunTriggerSyncedRef.current) {
|
||||||
|
lastManualRunTriggerRef.current = runTrigger
|
||||||
|
manualRunTriggerSyncedRef.current = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (runTrigger === 0) {
|
||||||
|
const hasCachedOutput = Boolean((data?.cachedRenderedContent ?? data?.cachedResolvedContent) as string | undefined)
|
||||||
|
if (!hasCachedOutput) {
|
||||||
setRenderedContent(null)
|
setRenderedContent(null)
|
||||||
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
|
||||||
}
|
}
|
||||||
if (effectiveUpdateMode === 'manual' && runTrigger === lastManualRunTriggerRef.current) {
|
if (runTrigger === lastManualRunTriggerRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (effectiveUpdateMode === 'manual') lastManualRunTriggerRef.current = runTrigger
|
lastManualRunTriggerRef.current = runTrigger
|
||||||
|
}
|
||||||
|
|
||||||
runIdRef.current += 1
|
runIdRef.current += 1
|
||||||
const thisRunId = runIdRef.current
|
const thisRunId = runIdRef.current
|
||||||
@@ -285,41 +321,59 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
loadingStartedAtRef.current = Date.now()
|
loadingStartedAtRef.current = Date.now()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setRenderedContent(null)
|
||||||
|
setResolvedContent(null)
|
||||||
|
setStreamingMarkdown(null)
|
||||||
|
setReasoningContent('')
|
||||||
|
updateData({ cachedRenderedContent: undefined, cachedResolvedContent: undefined, cachedReasoningContent: undefined })
|
||||||
try {
|
try {
|
||||||
|
const { nodes: ctxNodes, edges: ctxEdges } = nodesEdgesRef.current
|
||||||
const context = {
|
const context = {
|
||||||
nodes,
|
nodes: ctxNodes,
|
||||||
edges,
|
edges: ctxEdges,
|
||||||
sourceNodeId: srcId,
|
sourceNodeId: srcId!,
|
||||||
renderNodeId: id,
|
renderNodeId: id,
|
||||||
viewportWidth,
|
viewportWidth,
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
}
|
setNodes: setNodes ?? undefined,
|
||||||
const { resolved, outputTypeId } = await logic.getResolvedContent(context)
|
aiConnection,
|
||||||
if (cancelled || thisRunId !== runIdRef.current) return
|
...(isAgentSource && {
|
||||||
|
onStreamingStart: () => setStreamingMarkdown(''),
|
||||||
|
onStreamingChunk: (chunk: string) => setStreamingMarkdown((prev) => (prev ?? '') + chunk),
|
||||||
|
}),
|
||||||
|
} as SourceRenderingLogicContext
|
||||||
|
const { resolved, outputTypeId, reasoning } = await logic.getResolvedContent(context)
|
||||||
|
if (thisRunId !== runIdRef.current) return
|
||||||
setResolvedContent(resolved)
|
setResolvedContent(resolved)
|
||||||
|
setReasoningContent(reasoning ?? '')
|
||||||
const typeRenderer = getConfigType(outputTypeId)
|
const typeRenderer = getConfigType(outputTypeId)
|
||||||
const renderOptions = outputTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined
|
const renderOptions = outputTypeId === 'wireframe' ? { width: viewportWidth, height: viewportHeight } : undefined
|
||||||
const htmlOrSvg = await typeRenderer.render(resolved, renderOptions)
|
const htmlOrSvg = await typeRenderer.render(resolved, renderOptions)
|
||||||
if (cancelled || thisRunId !== runIdRef.current) return
|
if (thisRunId !== runIdRef.current) return
|
||||||
setRenderedContent(htmlOrSvg)
|
setRenderedContent(htmlOrSvg)
|
||||||
setError(null)
|
setError(null)
|
||||||
if (isManualMode) {
|
updateData({
|
||||||
updateData({ lastRunSourceSignature: signatureForThisRun })
|
cachedRenderedContent: htmlOrSvg,
|
||||||
}
|
cachedResolvedContent: resolved,
|
||||||
|
cachedReasoningContent: reasoning ?? '',
|
||||||
|
...(isManualMode ? { lastRunSourceSignature: signatureForThisRun } : {}),
|
||||||
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (!cancelled && thisRunId === runIdRef.current) {
|
if (!cancelled && thisRunId === runIdRef.current) {
|
||||||
setRenderedContent(null)
|
setRenderedContent(null)
|
||||||
|
setReasoningContent('')
|
||||||
setError({ kind: 'render', message: err?.message ?? 'Render error' })
|
setError({ kind: 'render', message: err?.message ?? 'Render error' })
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled && thisRunId === runIdRef.current) {
|
setStreamingMarkdown(null)
|
||||||
|
if (thisRunId === runIdRef.current) {
|
||||||
const startedAt = loadingStartedAtRef.current ?? 0
|
const startedAt = loadingStartedAtRef.current ?? 0
|
||||||
const elapsed = Date.now() - startedAt
|
const elapsed = Date.now() - startedAt
|
||||||
const remaining = Math.max(0, 1000 - elapsed)
|
const remaining = Math.max(0, 1000 - elapsed)
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
minLoadingTimeoutRef.current = setTimeout(() => {
|
minLoadingTimeoutRef.current = setTimeout(() => {
|
||||||
minLoadingTimeoutRef.current = null
|
minLoadingTimeoutRef.current = null
|
||||||
if (!cancelled && thisRunId === runIdRef.current) {
|
if (thisRunId === runIdRef.current) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, remaining)
|
}, remaining)
|
||||||
@@ -345,13 +399,14 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
run()
|
run()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
setStreamingMarkdown(null)
|
||||||
if (minLoadingTimeoutRef.current != null) {
|
if (minLoadingTimeoutRef.current != null) {
|
||||||
clearTimeout(minLoadingTimeoutRef.current)
|
clearTimeout(minLoadingTimeoutRef.current)
|
||||||
minLoadingTimeoutRef.current = null
|
minLoadingTimeoutRef.current = null
|
||||||
}
|
}
|
||||||
setLoading(false)
|
// Do not setLoading(false) here: the in-flight run() will set it in finally. Clearing it here would hide the Run button spinner when the effect re-runs (e.g. agent updates nodes).
|
||||||
}
|
}
|
||||||
}, [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, updateData, setNodes, aiConnection, isAgentSource])
|
||||||
|
|
||||||
|
|
||||||
const dimensions =
|
const dimensions =
|
||||||
@@ -359,20 +414,70 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
? { width, height }
|
? { width, height }
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
// Render streaming agent markdown to HTML for preview
|
||||||
|
useEffect(() => {
|
||||||
|
if (streamingMarkdown === null) {
|
||||||
|
setStreamingPreviewHtml('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
import('marked').then(async ({ marked }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const html = typeof marked.parse === 'function' ? await marked.parse(streamingMarkdown) : (marked as (s: string) => string)(streamingMarkdown)
|
||||||
|
const str = typeof html === 'string' ? html : String(html)
|
||||||
|
if (!cancelled) setStreamingPreviewHtml(str)
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setStreamingPreviewHtml(streamingMarkdown)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [streamingMarkdown])
|
||||||
|
|
||||||
|
// Render reasoning markdown to HTML for collapsible section
|
||||||
|
useEffect(() => {
|
||||||
|
if (!reasoningContent) {
|
||||||
|
setReasoningHtml('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
import('marked').then(async ({ marked }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const html = typeof marked.parse === 'function' ? await marked.parse(reasoningContent) : (marked as (s: string) => string)(reasoningContent)
|
||||||
|
const str = typeof html === 'string' ? html : String(html)
|
||||||
|
if (!cancelled) setReasoningHtml(str)
|
||||||
|
}).catch(() => {
|
||||||
|
if (!cancelled) setReasoningHtml(reasoningContent)
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [reasoningContent])
|
||||||
|
|
||||||
// Kroki and other SVG sources may prepend <?xml ... ?> so we detect by presence of <svg> tag
|
// Kroki and other SVG sources may prepend <?xml ... ?> so we detect by presence of <svg> tag
|
||||||
const isSvgOutput = Boolean(renderedContent?.trim() && /<svg[\s>]/i.test(renderedContent.trim()))
|
const isSvgOutput = Boolean(renderedContent?.trim() && /<svg[\s>]/i.test(renderedContent.trim()))
|
||||||
|
|
||||||
/** Strip remaining Nunjucks tags from resolved content for display in raw view (so tags don't show as literal lines). */
|
/** Split final markdown HTML into <think> (collapsible) and main. Only for non-image output. */
|
||||||
|
const renderedThinkSplit = useMemo(() => {
|
||||||
|
if (outputType === 'image' || !renderedContent) return { main: '', think: '' }
|
||||||
|
return parseThinkSections(renderedContent)
|
||||||
|
}, [renderedContent, outputType])
|
||||||
|
|
||||||
|
/** Split streaming content into <think> (collapsible) and main. Uses HTML when ready, else raw markdown. */
|
||||||
|
const streamingThinkSplit = useMemo(() => {
|
||||||
|
if (streamingMarkdown === null) return { main: '', think: '' }
|
||||||
|
const src = streamingPreviewHtml || streamingMarkdown
|
||||||
|
return parseThinkSections(src)
|
||||||
|
}, [streamingMarkdown, streamingPreviewHtml])
|
||||||
|
|
||||||
|
/** Raw markdown/text for Raw view: resolved content when set, otherwise streaming accumulation (so raw updates while streaming). */
|
||||||
const rawDisplayContent = useMemo(() => {
|
const rawDisplayContent = useMemo(() => {
|
||||||
if (resolvedContent == null) return ''
|
const src = resolvedContent != null ? resolvedContent : (streamingMarkdown ?? '')
|
||||||
return resolvedContent
|
if (!src) return ''
|
||||||
|
return src
|
||||||
.replace(/\{%[\s\S]*?%\}/g, '')
|
.replace(/\{%[\s\S]*?%\}/g, '')
|
||||||
.replace(/\{\{[\s\S]*?\}\}/g, '')
|
.replace(/\{\{[\s\S]*?\}\}/g, '')
|
||||||
.replace(/\{#[\s\S]*?#\}/g, '')
|
.replace(/\{#[\s\S]*?#\}/g, '')
|
||||||
.replace(/(\r?\n)\s*(\r?\n)/g, '$1$2')
|
.replace(/(\r?\n)\s*(\r?\n)/g, '$1$2')
|
||||||
.replace(/^\s*\n|\n\s*$/g, (m) => (m === '\n' ? '\n' : ''))
|
.replace(/^\s*\n|\n\s*$/g, (m) => (m === '\n' ? '\n' : ''))
|
||||||
.trim()
|
.trim()
|
||||||
}, [resolvedContent])
|
}, [resolvedContent, streamingMarkdown])
|
||||||
|
|
||||||
/** Process SVG HTML so it keeps aspect ratio and fills the viewport (used only for display, not download). */
|
/** Process SVG HTML so it keeps aspect ratio and fills the viewport (used only for display, not download). */
|
||||||
const displayContent = useMemo(() => {
|
const displayContent = useMemo(() => {
|
||||||
@@ -450,18 +555,6 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
|
|
||||||
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
|
const status = loading ? 'loading' : error ? 'error' : renderedContent ? 'success' : 'initial'
|
||||||
|
|
||||||
const [viewportDraft, setViewportDraft] = useState({ width: viewportWidth, height: viewportHeight })
|
|
||||||
useEffect(() => {
|
|
||||||
setViewportDraft({ width: viewportWidth, height: viewportHeight })
|
|
||||||
}, [viewportWidth, viewportHeight])
|
|
||||||
|
|
||||||
const onViewportDraftChange = useCallback((field: 'width' | 'height', value: number) => {
|
|
||||||
setViewportDraft((prev) => ({ ...prev, [field]: Math.min(4000, Math.max(200, value)) }))
|
|
||||||
}, [])
|
|
||||||
const onViewportApply = useCallback(() => {
|
|
||||||
updateData({ viewportWidth: viewportDraft.width, viewportHeight: viewportDraft.height })
|
|
||||||
}, [updateData, viewportDraft.width, viewportDraft.height])
|
|
||||||
|
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode])
|
const [rawEditorHeight, rawEditorContainerRef] = useResizeHeight(180, [viewMode])
|
||||||
const rawExtensions = useMemo(() => {
|
const rawExtensions = useMemo(() => {
|
||||||
@@ -580,51 +673,6 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
Raw
|
Raw
|
||||||
</MenubarCheckboxItem>
|
</MenubarCheckboxItem>
|
||||||
<MenubarSeparator />
|
<MenubarSeparator />
|
||||||
{outputType === 'image' && (
|
|
||||||
<MenubarSub>
|
|
||||||
<MenubarSubTrigger className="text-xs">Viewport size</MenubarSubTrigger>
|
|
||||||
<MenubarSubContent className="min-w-[12rem] p-2">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-xs text-muted-foreground shrink-0">Width</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={200}
|
|
||||||
max={4000}
|
|
||||||
value={viewportDraft.width}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = parseInt(e.target.value, 10)
|
|
||||||
if (!Number.isNaN(v)) onViewportDraftChange('width', v)
|
|
||||||
}}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-xs text-muted-foreground shrink-0">Height</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={200}
|
|
||||||
max={4000}
|
|
||||||
value={viewportDraft.height}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = parseInt(e.target.value, 10)
|
|
||||||
if (!Number.isNaN(v)) onViewportDraftChange('height', v)
|
|
||||||
}}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onViewportApply}
|
|
||||||
className="mt-1 w-full rounded bg-primary px-2 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</MenubarSubContent>
|
|
||||||
</MenubarSub>
|
|
||||||
)}
|
|
||||||
<MenubarSeparator />
|
|
||||||
<MenubarSub>
|
<MenubarSub>
|
||||||
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
|
<MenubarSubTrigger className="text-xs" disabled={!isSvgOutput}>
|
||||||
Export
|
Export
|
||||||
@@ -649,7 +697,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 flex flex-col">
|
<div className="min-h-0 flex-1 flex flex-col">
|
||||||
{incomingIds.length === 0 ? (
|
{incomingIds.length === 0 && !renderedContent && !streamingMarkdown && !loading ? (
|
||||||
<Empty className="min-h-0 flex-1">
|
<Empty className="min-h-0 flex-1">
|
||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
@@ -696,8 +744,6 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : loading ? (
|
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
|
||||||
) : viewMode === 'raw' ? (
|
) : viewMode === 'raw' ? (
|
||||||
<div ref={rawEditorContainerRef} className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
<div ref={rawEditorContainerRef} className="relative min-h-0 flex-1 w-full nodrag nopan overflow-hidden border-t border-input">
|
||||||
<Button
|
<Button
|
||||||
@@ -728,6 +774,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : renderedContent ? (
|
) : renderedContent ? (
|
||||||
|
/* Show final content as soon as it's ready (even if still loading), so streamed preview isn't replaced by "Rendering…" */
|
||||||
outputType === 'image' ? (
|
outputType === 'image' ? (
|
||||||
<div
|
<div
|
||||||
className="rendering-viewport nodrag nopan relative min-h-0 flex-1 w-full min-w-0 overflow-hidden bg-white dark:bg-secondary rounded outline-none"
|
className="rendering-viewport nodrag nopan relative min-h-0 flex-1 w-full min-w-0 overflow-hidden bg-white dark:bg-secondary rounded outline-none"
|
||||||
@@ -741,28 +788,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
initialPositionY={0}
|
initialPositionY={0}
|
||||||
minScale={0.2}
|
minScale={0.2}
|
||||||
maxScale={4}
|
maxScale={4}
|
||||||
centerOnInit={true}
|
centerOnInit={false}
|
||||||
onInit={(ctx) => {
|
|
||||||
if (!ctx?.instance?.wrapperComponent || !ctx?.instance?.contentComponent) return
|
|
||||||
const fitToView = () => {
|
|
||||||
const wrapper = ctx.instance.wrapperComponent
|
|
||||||
const content = ctx.instance.contentComponent
|
|
||||||
if (!wrapper || !content) return
|
|
||||||
const wW = wrapper.clientWidth
|
|
||||||
const wH = wrapper.clientHeight
|
|
||||||
const cW = content.scrollWidth || content.clientWidth
|
|
||||||
const cH = content.scrollHeight || content.clientHeight
|
|
||||||
if (cW > 0 && cH > 0) {
|
|
||||||
const scale = Math.min(wW / cW, wH / cH, 1)
|
|
||||||
const posX = (wW - cW * scale) / 2
|
|
||||||
const posY = (wH - cH * scale) / 2
|
|
||||||
ctx.setTransform(posX, posY, scale, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(fitToView)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
panning={{ disabled: !selected && !viewportFocused }}
|
panning={{ disabled: !selected && !viewportFocused }}
|
||||||
wheel={{ disabled: !selected && !viewportFocused }}
|
wheel={{ disabled: !selected && !viewportFocused }}
|
||||||
doubleClick={{ disabled: !selected && !viewportFocused }}
|
doubleClick={{ disabled: !selected && !viewportFocused }}
|
||||||
@@ -795,13 +821,13 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
|
<RotateCcw className="size-3 max-w-[12px] max-h-[12px]" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 nodrag nopan overflow-hidden flex flex-col">
|
<div className="absolute inset-0 nodrag nopan overflow-hidden [&_.react-transform-component]:!w-full [&_.react-transform-component]:!h-full [&_.react-transform-wrapper]:!w-full [&_.react-transform-wrapper]:!h-full">
|
||||||
<TransformComponent
|
<TransformComponent
|
||||||
wrapperClass="!w-full !h-full"
|
wrapperClass="!w-full !h-full"
|
||||||
contentClass="inline-flex flex-col nodrag nopan !w-full !min-h-0 !flex-1"
|
contentClass="nodrag nopan !w-full !h-full !block !min-h-0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="rendering-diagram inline-flex w-full min-h-0 flex-1 p-4 nodrag nopan"
|
className="rendering-diagram absolute inset-0 w-full h-full min-w-0 min-h-0 nodrag nopan"
|
||||||
dangerouslySetInnerHTML={{ __html: displayContent ?? '' }}
|
dangerouslySetInnerHTML={{ __html: displayContent ?? '' }}
|
||||||
/>
|
/>
|
||||||
</TransformComponent>
|
</TransformComponent>
|
||||||
@@ -811,11 +837,76 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
|||||||
</TransformWrapper>
|
</TransformWrapper>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||||
|
{reasoningHtml ? (
|
||||||
|
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||||
|
Reasoning
|
||||||
|
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div
|
||||||
|
className="rendering-markdown max-h-48 overflow-auto p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground"
|
||||||
|
dangerouslySetInnerHTML={{ __html: reasoningHtml }}
|
||||||
|
/>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
) : null}
|
||||||
|
{renderedThinkSplit.think ? (
|
||||||
|
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||||
|
Thinking
|
||||||
|
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div
|
||||||
|
className="rendering-markdown max-h-48 overflow-auto p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground"
|
||||||
|
dangerouslySetInnerHTML={{ __html: renderedThinkSplit.think }}
|
||||||
|
/>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
className="rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded"
|
className="rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded"
|
||||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
dangerouslySetInnerHTML={{ __html: renderedThinkSplit.main }}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
|
) : loading && viewMode === 'preview' && streamingMarkdown !== null ? (
|
||||||
|
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
|
||||||
|
{streamingThinkSplit.think ? (
|
||||||
|
<Collapsible defaultOpen={false} className="group shrink-0 border-b border-border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:bg-muted/50 nodrag nopan">
|
||||||
|
Thinking
|
||||||
|
<ChevronDown className="size-3.5 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
{streamingPreviewHtml ? (
|
||||||
|
<div
|
||||||
|
className="rendering-markdown max-h-48 overflow-auto p-3 text-sm [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-1.5 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded text-muted-foreground"
|
||||||
|
dangerouslySetInnerHTML={{ __html: streamingThinkSplit.think }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="rendering-markdown max-h-48 overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans text-muted-foreground">
|
||||||
|
{streamingThinkSplit.think}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
) : null}
|
||||||
|
{streamingPreviewHtml ? (
|
||||||
|
<div
|
||||||
|
className="rendering-markdown min-h-0 flex-1 w-full overflow-auto p-3 text-sm [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 [&_p]:my-2 [&_pre]:bg-muted [&_pre]:p-2 [&_pre]:rounded"
|
||||||
|
dangerouslySetInnerHTML={{ __html: streamingThinkSplit.main || streamingPreviewHtml }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="min-h-0 flex-1 w-full overflow-auto whitespace-pre-wrap break-words p-3 text-sm font-sans">
|
||||||
|
{streamingThinkSplit.main || streamingMarkdown}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center text-xs text-muted-foreground">Rendering…</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</BaseNodeContent>
|
</BaseNodeContent>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -16,14 +16,25 @@ 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
|
||||||
|
/** Optional: called when agent streaming starts (Rendering node can show streaming preview). */
|
||||||
|
onStreamingStart?: () => void
|
||||||
|
/** Optional: called with each text chunk during agent stream (Rendering node can update preview). */
|
||||||
|
onStreamingChunk?: (chunk: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of getResolvedContent: resolved string plus the config type to use for final render.
|
* Result of getResolvedContent: resolved string plus the config type to use for final render.
|
||||||
|
* When the source is an agent with reasoning enabled, reasoning may be set for a collapsible section.
|
||||||
*/
|
*/
|
||||||
export type ResolvedContentResult = {
|
export type ResolvedContentResult = {
|
||||||
resolved: string
|
resolved: string
|
||||||
outputTypeId: ConfigTypeId
|
outputTypeId: ConfigTypeId
|
||||||
|
/** Optional reasoning section (e.g. agent with reasoning enabled); renderer shows it in a collapsible. */
|
||||||
|
reasoning?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user