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. +

+