diff --git a/README.md b/README.md index 4ed560d..f9c8587 100644 --- a/README.md +++ b/README.md @@ -95,5 +95,38 @@ For self-hosting (e.g. Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL; | PUT | `/api/todos/:id` | Update. | | DELETE | `/api/todos/:id` | Delete. | | GET | `/health` | Health check (e.g. for Docker). | +| POST | `/api/agent` | Run AI agent; body `{ "prompt", "context?", "contextNodes?" }` → `{ "markdown" }`. | Data is in-memory (resets on restart). Add a JSON file or DB later if needed. + +--- + +## Agent node (local LLM or OpenAI) + +The **Agent** node uses an OpenAI-compatible API. You can use: + +**1. Local LLM (e.g. LM Studio)** + +1. Install [LM Studio](https://lmstudio.ai/) and load a model. +2. Start the local server: in LM Studio open the **Developer** tab and run the **Local Server** (default: `http://localhost:1234`). +3. In the project root or `backend/`, set: + + ```bash + export AI_BASE_URL=http://localhost:1234/v1 + # Optional: set to the model name shown in LM Studio (e.g. the loaded model id). Default is "local-model". + export AI_MODEL=your-model-name + ``` + +4. Start the backend (`cd backend && npm run dev`). The Agent node will use your local model. + +**2. OpenAI** + +Set `OPENAI_API_KEY` to your API key. The backend will use `gpt-4o-mini` unless you set `AI_MODEL`. + +**Env summary (backend)** + +| Variable | When to use | Description | +|----------|--------------|-------------| +| `AI_BASE_URL` | Local LLM (LM Studio, Ollama, etc.) | OpenAI-compatible base URL, e.g. `http://localhost:1234/v1`. | +| `AI_MODEL` | Optional | Model id (for local: use the name shown in LM Studio; for OpenAI: e.g. `gpt-4o-mini`). | +| `OPENAI_API_KEY` | OpenAI only | Your OpenAI API key. Not required when using `AI_BASE_URL` only. | diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d7954cb --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +# Optional: backend env vars. Copy to .env and adjust. + +# --- Agent (AI) --- +# For OpenAI: set your key (model defaults to gpt-4o-mini). +# OPENAI_API_KEY=sk-... + +# For local LLM (LM Studio, Ollama, etc.): set base URL. No API key required. +# AI_BASE_URL=http://localhost:1234/v1 +# Optional: model name (LM Studio shows the loaded model id). +# AI_MODEL=local-model + +# --- Server --- +# PORT=8080 +# CORS_ORIGIN=http://localhost:3000 diff --git a/backend/package-lock.json b/backend/package-lock.json index 3c41082..eefd13a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,8 @@ "name": "zui-backend", "version": "1.0.0", "dependencies": { + "@ai-sdk/openai": "^1.0.0", + "ai": "^4.0.0", "cors": "^2.8.5", "express": "^4.21.0" }, @@ -15,6 +17,107 @@ "node": ">=20" } }, + "node_modules/@ai-sdk/openai": { + "version": "1.3.24", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.24.tgz", + "integrity": "sha512-GYXnGJTHRTZc4gJMSmFRgEQudjqd4PUN0ZjQhPwOAYH1yOAvQoG/Ikqs+HyISRbLPCrhbZnPKCNHuRU4OfpW0Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.12.tgz", + "integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "2.2.8", + "@ai-sdk/ui-utils": "1.2.11", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/ui-utils": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", + "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -28,6 +131,32 @@ "node": ">= 0.6" } }, + "node_modules/ai": { + "version": "4.3.19", + "resolved": "https://registry.npmjs.org/ai/-/ai-4.3.19.tgz", + "integrity": "sha512-dIE2bfNpqHN3r6IINp9znguYdhIOheKW2LDigAMrgt/upT3B8eBGPSCblENvaZGoq+hxaN9fSMzjWpbqloP+7Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "@ai-sdk/react": "1.2.12", + "@ai-sdk/ui-utils": "1.2.11", + "@opentelemetry/api": "1.9.0", + "jsondiffpatch": "0.6.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -96,6 +225,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -167,6 +308,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -177,6 +327,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -462,6 +618,29 @@ "node": ">= 0.10" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/jsondiffpatch": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", + "integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==", + "license": "MIT", + "dependencies": { + "@types/diff-match-patch": "^1.0.36", + "chalk": "^5.3.0", + "diff-match-patch": "^1.0.5" + }, + "bin": { + "jsondiffpatch": "bin/jsondiffpatch.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -537,6 +716,24 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -646,6 +843,16 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -672,6 +879,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -804,6 +1017,31 @@ "node": ">= 0.8" } }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -835,6 +1073,15 @@ "node": ">= 0.8" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -852,6 +1099,25 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/backend/package.json b/backend/package.json index c12a744..b7ee66e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,8 @@ "node": ">=20" }, "dependencies": { + "ai": "^4.0.0", + "@ai-sdk/openai": "^1.0.0", "cors": "^2.8.5", "express": "^4.21.0" } diff --git a/backend/src/index.js b/backend/src/index.js index 15c1268..df87765 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -80,6 +80,63 @@ 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). + */ +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 { 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.', + }) + const markdown = result?.text ?? '' + res.json({ markdown }) + } catch (err) { + console.error('Agent 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/app/platform/SettingsDialog.tsx b/frontend/src/app/platform/SettingsDialog.tsx index 5b71b70..5a0e27c 100644 --- a/frontend/src/app/platform/SettingsDialog.tsx +++ b/frontend/src/app/platform/SettingsDialog.tsx @@ -2,7 +2,7 @@ * Settings dialog with sidebar-style nav (Appearance, AI). Reference: shadcn sidebar-13. */ -import React, { useState } from 'react' +import React, { useCallback, useState } from 'react' import { Dialog, DialogContent, @@ -19,9 +19,11 @@ import { SelectValue, } from '@/components/ui/select' import { Switch } from '@/components/ui/switch' +import { Input } from '@/components/ui/input' import { useTheme } from '@/lib/themeContext' import type { Theme } from '@/lib/themeContext' import { usePlatform } from './platformContext' +import type { AiConnection, AiConnectionProvider } from './platformContext' import { Sun, Sparkles } from 'lucide-react' type SettingsSection = 'appearance' | 'ai' @@ -34,7 +36,14 @@ export function SettingsDialog({ const [open, setOpen] = useState(false) const [section, setSection] = useState('appearance') const { theme, setTheme } = useTheme() - const { showMinimap, setShowMinimap } = usePlatform() + const { showMinimap, setShowMinimap, aiConnection, setAiConnection } = usePlatform() + + const updateAiConnection = useCallback( + (partial: Partial) => { + setAiConnection({ ...aiConnection, ...partial }) + }, + [aiConnection, setAiConnection] + ) return ( @@ -110,14 +119,70 @@ export function SettingsDialog({ )} {section === 'ai' && (
-

AI

-
- - AI settings - - - Coming soon - +

AI connection

+

+ Used by the Agent node. Choose OpenAI or a local server (e.g. LM Studio). +

+
+
+ + +
+ {aiConnection.provider === 'local' && ( +
+ + updateAiConnection({ baseURL: e.target.value })} + className="font-mono text-xs" + /> +
+ )} +
+ + updateAiConnection({ model: e.target.value })} + className="font-mono text-xs" + /> +
+
+ + updateAiConnection({ apiKey: e.target.value })} + className="font-mono text-xs" + /> +
)} diff --git a/frontend/src/app/platform/platformContext.tsx b/frontend/src/app/platform/platformContext.tsx index 232c097..426f678 100644 --- a/frontend/src/app/platform/platformContext.tsx +++ b/frontend/src/app/platform/platformContext.tsx @@ -12,8 +12,49 @@ const STORAGE_KEY = 'zui_platform_projects' const ORDER_STORAGE_KEY = 'zui_platform_project_order' const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids' const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap' +const AI_CONNECTION_KEY = 'zui_ai_connection' const RECENT_MAX = 5 +export type AiConnectionProvider = 'openai' | 'local' + +export type AiConnection = { + provider: AiConnectionProvider + baseURL: string + model: string + apiKey: string +} + +const DEFAULT_AI_CONNECTION: AiConnection = { + provider: 'local', + baseURL: 'http://localhost:1234/v1', + model: 'local-model', + apiKey: '', +} + +function loadAiConnection(): AiConnection { + try { + const raw = localStorage.getItem(AI_CONNECTION_KEY) + if (!raw) return DEFAULT_AI_CONNECTION + const parsed = JSON.parse(raw) as unknown + if (!parsed || typeof parsed !== 'object') return DEFAULT_AI_CONNECTION + const p = parsed as Record + return { + provider: p.provider === 'openai' ? 'openai' : 'local', + baseURL: typeof p.baseURL === 'string' ? p.baseURL : DEFAULT_AI_CONNECTION.baseURL, + model: typeof p.model === 'string' ? p.model : DEFAULT_AI_CONNECTION.model, + apiKey: typeof p.apiKey === 'string' ? p.apiKey : '', + } + } catch { + return DEFAULT_AI_CONNECTION + } +} + +function saveAiConnection(value: AiConnection) { + try { + localStorage.setItem(AI_CONNECTION_KEY, JSON.stringify(value)) + } catch {} +} + function loadShowMinimap(): boolean { try { const raw = localStorage.getItem(CANVAS_MINIMAP_KEY) @@ -126,6 +167,9 @@ export type PlatformContextValue = { /** Canvas: show React Flow minimap (persisted) */ showMinimap: boolean setShowMinimap: (value: boolean) => void + /** Agent node: AI connection (persisted). Sent to backend when running agent. */ + aiConnection: AiConnection + setAiConnection: (value: AiConnection) => void } const PlatformContext = createContext(null) @@ -135,12 +179,18 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) { const [projectOrder, setProjectOrder] = useState(loadOrder) const [recentProjectIds, setRecentProjectIds] = useState(loadRecentIds) const [showMinimap, setShowMinimapState] = useState(loadShowMinimap) + const [aiConnection, setAiConnectionState] = useState(loadAiConnection) const setShowMinimap = useCallback((value: boolean) => { setShowMinimapState(value) saveShowMinimap(value) }, []) + const setAiConnection = useCallback((value: AiConnection) => { + setAiConnectionState(value) + saveAiConnection(value) + }, []) + const persist = useCallback((next: Project[]) => { setProjects(next) saveProjects(next) @@ -247,6 +297,8 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) { restoreProject, showMinimap, setShowMinimap, + aiConnection, + setAiConnection, }), [ projects, @@ -263,6 +315,8 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) { restoreProject, showMinimap, setShowMinimap, + aiConnection, + setAiConnection, ] ) diff --git a/frontend/src/components/nodes/AgentNode.tsx b/frontend/src/components/nodes/AgentNode.tsx new file mode 100644 index 0000000..4fd0a4a --- /dev/null +++ b/frontend/src/components/nodes/AgentNode.tsx @@ -0,0 +1,203 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { + AbstractNodeProps, + createAbstractNodeComponent, + useAbstractNode, +} from '@/lib/abstractNode' +import { getConfigContent } from '@/lib/configTypes' +import { + BaseNode, + BaseNodeContent, + BaseNodeFooter, + BaseNodeHeaderRow, +} from '@/components/base/BaseNode' +import { NodeMenubar } from '@/components/base/NodeMenubar' +import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators' +import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle' +import { InputHandle, OutputHandle } from '@/components/base/NodeHandles' +import { Button } from '@/components/ui/button' +import { usePlatform } from '@/app/platform/platformContext' +import { Bot, Play, Loader2 } from 'lucide-react' + +export type AgentNodeData = { + context?: string + outputMarkdown?: string + error?: string + loading?: boolean +} + +type Props = AbstractNodeProps + +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 | 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[] | 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 { nodes, sourceIds, updateData } = useAbstractNode(id, data ?? {}) + const { aiConnection } = usePlatform() + const [running, setRunning] = useState(false) + + const dimensions = + width != null && height != null && width > 0 && height > 0 + ? { width, height } + : undefined + + const contextText = data?.context ?? '' + const outputMarkdown = data?.outputMarkdown + const error = data?.error + const loading = data?.loading ?? false + + const connectedSources = useMemo(() => { + return sourceIds.map((sid) => { + const node = nodes.find((n: { id: string }) => n.id === sid) + return { id: sid, type: (node as { type?: string } | undefined)?.type ?? 'unknown' } + }) + }, [sourceIds, nodes]) + + const 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 | 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 }) + } catch (err: unknown) { + updateData({ + loading: false, + error: err instanceof Error ? err.message : 'Agent request failed', + outputMarkdown: undefined, + }) + } finally { + setRunning(false) + } + }, [sourceIds, nodes, contextText, updateData, aiConnection]) + + const onContextChange = useCallback( + (e: React.ChangeEvent) => updateData({ context: e.target.value }), + [updateData] + ) + + return ( + + + + + } + > + } + title={} + /> + +
+ +
+
+
+ +