diff --git a/backend/src/index.js b/backend/src/index.js
index df87765..d56adac 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -80,54 +80,55 @@ app.delete('/api/todos/:id', (req, res) => {
}
})
-/** POST /api/agent — run AI agent; body: { prompt, context?, contextNodes?, connection? }; returns { markdown }.
- * connection (from Settings): { provider, baseURL?, model?, apiKey? }. If omitted, uses env (OPENAI_API_KEY / AI_BASE_URL / AI_MODEL).
- */
+/** Build OpenAI client and full prompt from request body. Returns { openai, modelId, fullPrompt } or { error }. */
+function buildAgentRequest(body) {
+ const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
+ const reasoningEnabled = Boolean(reasoning)
+ let baseURL = process.env.AI_BASE_URL?.trim() || null
+ let apiKey = process.env.OPENAI_API_KEY?.trim() || null
+ let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
+ if (conn && typeof conn === 'object') {
+ const c = conn
+ const provider = c.provider === 'openai' ? 'openai' : 'local'
+ if (provider === 'local') {
+ baseURL = (typeof c.baseURL === 'string' && c.baseURL.trim()) ? c.baseURL.trim() : baseURL
+ apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : (apiKey || 'lm-studio')
+ } else {
+ baseURL = null
+ apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : apiKey
+ }
+ if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
+ }
+ if (!baseURL && !apiKey) {
+ return { error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.' }
+ }
+ const basePrompt = [
+ typeof prompt === 'string' ? prompt : 'No prompt provided.',
+ context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
+ 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')}`
+ : '',
+ ].join('')
+ const fullPrompt = reasoningEnabled
+ ? 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.'
+ : basePrompt + '\n\nRespond with structured markdown only. No preamble.'
+ const { createOpenAI } = require('@ai-sdk/openai')
+ const openai = createOpenAI({
+ apiKey: apiKey || 'lm-studio',
+ ...(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 body = req.body ?? {}
- const { prompt, context, contextNodes, connection: conn } = body
-
- let baseURL = process.env.AI_BASE_URL?.trim() || null
- let apiKey = process.env.OPENAI_API_KEY?.trim() || null
- let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
-
- if (conn && typeof conn === 'object') {
- const c = conn
- const provider = c.provider === 'openai' ? 'openai' : 'local'
- if (provider === 'local') {
- baseURL = (typeof c.baseURL === 'string' && c.baseURL.trim()) ? c.baseURL.trim() : baseURL
- apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : (apiKey || 'lm-studio')
- } else {
- baseURL = null
- apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : apiKey
- }
- if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
- }
-
- if (!baseURL && !apiKey) {
- return res.status(503).json({
- error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.',
- })
- }
-
- const fullPrompt = [
- typeof prompt === 'string' ? prompt : 'No prompt provided.',
- context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
- 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')}`
- : '',
- ].join('')
-
+ const built = buildAgentRequest(req.body)
+ if (built.error) return res.status(503).json({ error: built.error })
const { generateText } = await import('ai')
- const { createOpenAI } = await import('@ai-sdk/openai')
- const openai = createOpenAI({
- apiKey: apiKey || 'lm-studio',
- ...(baseURL && { baseURL, compatibility: 'compatible' }),
- })
const result = await generateText({
- model: openai(modelId),
- prompt: fullPrompt + '\n\nRespond with structured markdown only. No preamble.',
+ model: built.openai(built.modelId),
+ prompt: built.fullPrompt,
})
const markdown = result?.text ?? ''
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 */
app.get('/health', (req, res) => {
res.status(200).json({ ok: true })
diff --git a/frontend/src/components/nodes/agent/AgentNode.tsx b/frontend/src/components/nodes/agent/AgentNode.tsx
index 6ce4f88..c516357 100644
--- a/frontend/src/components/nodes/agent/AgentNode.tsx
+++ b/frontend/src/components/nodes/agent/AgentNode.tsx
@@ -18,10 +18,13 @@ import FlowContext from '@/lib/graph/flowContext'
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
import { getNodeType } from '@/lib/graph/nodeRegistry'
import { Bot } from 'lucide-react'
+import { Checkbox } from '@/components/ui/checkbox'
export type AgentNodeData = {
/** Additional context for the agent (configuration). */
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
/** Signature of inputs from last successful run; set by Rendering node when it runs the agent. Used as cache. */
@@ -42,6 +45,12 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
: undefined
const contextText = data?.context ?? ''
+ const reasoningEnabled = data?.reasoning ?? false
+
+ const onReasoningChange = useCallback(
+ (checked: boolean) => updateData({ reasoning: checked }),
+ [updateData]
+ )
const connectedSources = useMemo(() => {
return sourceIds.map((sid) => {
@@ -87,6 +96,21 @@ function AgentNodeComponent({ id, data, width, height, selected }: Props) {
+ When enabled, the model will output a Reasoning section and an Output section; the renderer shows them separately. +
+
+ {streamingThinkSplit.think}
+
+ )}
+
+ {streamingThinkSplit.main || streamingMarkdown}
+
+ )}
+