feat: agent node

This commit is contained in:
2026-03-11 14:39:38 +01:00
parent 94da42f9fc
commit f201fe92f4
15 changed files with 772 additions and 24 deletions

View File

@@ -95,5 +95,38 @@ For self-hosting (e.g. Tailscale/HTTPS): set `CORS_ORIGIN` to your frontend URL;
| PUT | `/api/todos/:id` | Update. | | PUT | `/api/todos/:id` | Update. |
| DELETE | `/api/todos/:id` | Delete. | | DELETE | `/api/todos/:id` | Delete. |
| GET | `/health` | Health check (e.g. for Docker). | | 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. 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. |

14
backend/.env.example Normal file
View File

@@ -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

View File

@@ -8,6 +8,8 @@
"name": "zui-backend", "name": "zui-backend",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^1.0.0",
"ai": "^4.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.0" "express": "^4.21.0"
}, },
@@ -15,6 +17,107 @@
"node": ">=20" "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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -28,6 +131,32 @@
"node": ">= 0.6" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -96,6 +225,18 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -167,6 +308,15 @@
"node": ">= 0.8" "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": { "node_modules/destroy": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -177,6 +327,12 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -462,6 +618,29 @@
"node": ">= 0.10" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -537,6 +716,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "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": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -646,6 +843,16 @@
"node": ">= 0.8" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -672,6 +879,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/send": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
@@ -804,6 +1017,31 @@
"node": ">= 0.8" "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": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -835,6 +1073,15 @@
"node": ">= 0.8" "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": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -852,6 +1099,25 @@
"engines": { "engines": {
"node": ">= 0.8" "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"
}
} }
} }
} }

View File

@@ -12,6 +12,8 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"ai": "^4.0.0",
"@ai-sdk/openai": "^1.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.21.0" "express": "^4.21.0"
} }

View File

@@ -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 */ /** 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 })

View File

@@ -2,7 +2,7 @@
* Settings dialog with sidebar-style nav (Appearance, AI). Reference: shadcn sidebar-13. * Settings dialog with sidebar-style nav (Appearance, AI). Reference: shadcn sidebar-13.
*/ */
import React, { useState } from 'react' import React, { useCallback, useState } from 'react'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,9 +19,11 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { useTheme } from '@/lib/themeContext' import { useTheme } from '@/lib/themeContext'
import type { Theme } from '@/lib/themeContext' import type { Theme } from '@/lib/themeContext'
import { usePlatform } from './platformContext' import { usePlatform } from './platformContext'
import type { AiConnection, AiConnectionProvider } from './platformContext'
import { Sun, Sparkles } from 'lucide-react' import { Sun, Sparkles } from 'lucide-react'
type SettingsSection = 'appearance' | 'ai' type SettingsSection = 'appearance' | 'ai'
@@ -34,7 +36,14 @@ export function SettingsDialog({
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [section, setSection] = useState<SettingsSection>('appearance') const [section, setSection] = useState<SettingsSection>('appearance')
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const { showMinimap, setShowMinimap } = usePlatform() const { showMinimap, setShowMinimap, aiConnection, setAiConnection } = usePlatform()
const updateAiConnection = useCallback(
(partial: Partial<AiConnection>) => {
setAiConnection({ ...aiConnection, ...partial })
},
[aiConnection, setAiConnection]
)
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -110,14 +119,70 @@ export function SettingsDialog({
)} )}
{section === 'ai' && ( {section === 'ai' && (
<div className="space-y-6"> <div className="space-y-6">
<h3 className="text-sm font-medium text-muted-foreground">AI</h3> <h3 className="text-sm font-medium text-muted-foreground">AI connection</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"> <p className="text-xs text-muted-foreground">
<span className="text-sm font-medium text-muted-foreground/80"> Used by the Agent node. Choose OpenAI or a local server (e.g. LM Studio).
AI settings </p>
</span> <div className="space-y-4">
<span className="text-xs text-muted-foreground sm:text-right"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
Coming soon <label htmlFor="settings-ai-provider" className="text-sm font-medium">
</span> Provider
</label>
<Select
value={aiConnection.provider}
onValueChange={(v) => updateAiConnection({ provider: v as AiConnectionProvider })}
>
<SelectTrigger id="settings-ai-provider" className="w-full min-w-[10rem] sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local (LM Studio, Ollama, etc.)</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
{aiConnection.provider === 'local' && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-baseurl" className="text-sm font-medium">
Base URL
</label>
<Input
id="settings-ai-baseurl"
type="url"
placeholder="http://localhost:1234/v1"
value={aiConnection.baseURL}
onChange={(e) => updateAiConnection({ baseURL: e.target.value })}
className="font-mono text-xs"
/>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-model" className="text-sm font-medium">
Model
</label>
<Input
id="settings-ai-model"
type="text"
placeholder={aiConnection.provider === 'local' ? 'local-model' : 'gpt-4o-mini'}
value={aiConnection.model}
onChange={(e) => updateAiConnection({ model: e.target.value })}
className="font-mono text-xs"
/>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-apikey" className="text-sm font-medium">
API key
</label>
<Input
id="settings-ai-apikey"
type="password"
autoComplete="off"
placeholder={aiConnection.provider === 'openai' ? 'sk-...' : 'Optional for local'}
value={aiConnection.apiKey}
onChange={(e) => updateAiConnection({ apiKey: e.target.value })}
className="font-mono text-xs"
/>
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -12,8 +12,49 @@ const STORAGE_KEY = 'zui_platform_projects'
const ORDER_STORAGE_KEY = 'zui_platform_project_order' const ORDER_STORAGE_KEY = 'zui_platform_project_order'
const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids' const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids'
const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap' const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap'
const AI_CONNECTION_KEY = 'zui_ai_connection'
const RECENT_MAX = 5 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<string, unknown>
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 { function loadShowMinimap(): boolean {
try { try {
const raw = localStorage.getItem(CANVAS_MINIMAP_KEY) const raw = localStorage.getItem(CANVAS_MINIMAP_KEY)
@@ -126,6 +167,9 @@ export type PlatformContextValue = {
/** Canvas: show React Flow minimap (persisted) */ /** Canvas: show React Flow minimap (persisted) */
showMinimap: boolean showMinimap: boolean
setShowMinimap: (value: boolean) => void setShowMinimap: (value: boolean) => void
/** Agent node: AI connection (persisted). Sent to backend when running agent. */
aiConnection: AiConnection
setAiConnection: (value: AiConnection) => void
} }
const PlatformContext = createContext<PlatformContextValue | null>(null) const PlatformContext = createContext<PlatformContextValue | null>(null)
@@ -135,12 +179,18 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder) const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds) const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap) const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection)
const setShowMinimap = useCallback((value: boolean) => { const setShowMinimap = useCallback((value: boolean) => {
setShowMinimapState(value) setShowMinimapState(value)
saveShowMinimap(value) saveShowMinimap(value)
}, []) }, [])
const setAiConnection = useCallback((value: AiConnection) => {
setAiConnectionState(value)
saveAiConnection(value)
}, [])
const persist = useCallback((next: Project[]) => { const persist = useCallback((next: Project[]) => {
setProjects(next) setProjects(next)
saveProjects(next) saveProjects(next)
@@ -247,6 +297,8 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
restoreProject, restoreProject,
showMinimap, showMinimap,
setShowMinimap, setShowMinimap,
aiConnection,
setAiConnection,
}), }),
[ [
projects, projects,
@@ -263,6 +315,8 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
restoreProject, restoreProject,
showMinimap, showMinimap,
setShowMinimap, setShowMinimap,
aiConnection,
setAiConnection,
] ]
) )

View File

@@ -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<AgentNodeData>
function serializeNodeForContext(nodes: { id: string; type?: string; data?: unknown }[], nodeId: string): string {
const node = nodes.find((n: { id: string }) => n.id === nodeId)
if (!node) return `${nodeId}: (not found)`
const type = node.type ?? 'unknown'
const data = node.data as Record<string, unknown> | undefined
if (type === 'config') {
const content = getConfigContent(data)
return `[config ${nodeId}]\n${content || '(empty)'}`
}
if (type === 'variable') {
const v = data?.value
return `[variable ${nodeId}]: ${v === undefined || v === null ? '' : String(v)}`
}
if (type === 'data') {
const rows = (data?.rows as Record<string, string>[] | undefined) ?? []
const columns = (data?.columns as string[] | undefined) ?? []
const preview = rows.slice(0, 20).map((r) => columns.map((c) => r[c] ?? '').join(', ')).join('\n')
return `[data ${nodeId}] ${columns.length} columns, ${rows.length} rows\n${preview}${rows.length > 20 ? '\n...' : ''}`
}
return `[${type} ${nodeId}]: ${JSON.stringify(data ?? {}).slice(0, 200)}`
}
function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
const { aiConnection } = usePlatform()
const [running, setRunning] = useState(false)
const dimensions =
width != null && height != null && width > 0 && height > 0
? { 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<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 })
} 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<HTMLTextAreaElement>) => updateData({ context: e.target.value }),
[updateData]
)
return (
<BaseNode
className="min-w-[360px] min-h-[320px]"
dimensions={dimensions}
selected={selected}
handles={
<>
<InputHandle id="in" nodeId={id} />
<OutputHandle id="out" />
</>
}
>
<BaseNodeHeaderRow
icon={<Bot className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
/>
<BaseNodeContent>
<div className="shrink-0 w-full">
<NodeMenubar nodeId={id} nodeType="agent" />
</div>
<div className="flex flex-col gap-3 p-2 min-h-0 flex-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-foreground">Context</label>
<textarea
className="nodrag nopan w-full min-h-[72px] rounded-md border border-input bg-background px-2 py-1.5 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Additional context for the agent…"
value={contextText}
onChange={onContextChange}
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">Context from connected nodes</span>
{connectedSources.length === 0 ? (
<p className="text-xs text-muted-foreground">Connect Config, Variable, or Data nodes as input.</p>
) : (
<ul className="text-xs text-muted-foreground list-disc list-inside space-y-0.5">
{connectedSources.map(({ id: sid, type }) => (
<li key={sid}>
<code className="rounded bg-muted px-1">{sid}</code> ({type})
</li>
))}
</ul>
)}
</div>
<Button
type="button"
size="sm"
className="w-fit"
onClick={runAgent}
disabled={running || loading}
>
{running || loading ? (
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Run
</Button>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{outputMarkdown != null && outputMarkdown !== '' && !error && (
<div className="text-xs text-muted-foreground border rounded p-2 max-h-24 overflow-auto">
<span className="font-medium">Output:</span> {outputMarkdown.length} chars (connect to Renderer to view)
</div>
)}
</div>
</BaseNodeContent>
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="agent">
{outputMarkdown != null ? `${outputMarkdown.length} chars` : error ? 'Error' : '—'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>
)
}
export const AgentNode = createAbstractNodeComponent<AgentNodeData>('AgentNode', AgentNodeComponent)
export default AgentNode

View File

@@ -64,10 +64,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const incomingIds = sourceIds const incomingIds = sourceIds
const srcId = incomingIds.length > 0 ? incomingIds[0] : null const srcId = incomingIds.length > 0 ? incomingIds[0] : null
const srcNode = nodes.find((n: any) => n.id === srcId) const srcNode = nodes.find((n: any) => n.id === srcId)
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : 'plantuml' const isAgentSource = srcNode?.type === 'agent'
const agentOutputMarkdown = isAgentSource ? ((srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? '') : ''
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : isAgentSource ? 'markdown' : 'plantuml'
const configType = getConfigType(configTypeId) const configType = getConfigType(configTypeId)
const outputType = configType.outputType const outputType = configType.outputType
const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : '' const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : isAgentSource ? agentOutputMarkdown : ''
const srcData = srcNode?.data ?? {} const srcData = srcNode?.data ?? {}
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */ /** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
@@ -184,7 +186,10 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
if (!sourceContent && incomingIds.length > 0) { if (!sourceContent && incomingIds.length > 0) {
setRenderedContent(null) setRenderedContent(null)
setResolvedContent(null) setResolvedContent(null)
setError({ kind: 'no-content', message: 'No content on connected configuration node' }) setError({
kind: 'no-content',
message: isAgentSource ? 'Run the Agent node to generate output.' : 'No content on connected configuration node',
})
setLoading(false) setLoading(false)
return return
} }
@@ -205,6 +210,17 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
if (srcNode?.type === 'agent') {
const md = (srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? ''
setResolvedContent(md)
const markdownType = getConfigType('markdown')
const html = await markdownType.render(md)
if (cancelled || thisRunId !== runIdRef.current) return
setRenderedContent(html)
setError(null)
setLoading(false)
return
}
const configIdsUsed = new Set<string>() const configIdsUsed = new Set<string>()
const isReachable = (startId: string, targetId: string) => { const isReachable = (startId: string, targetId: string) => {
@@ -531,7 +547,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
} }
} }
// Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry. // Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry.
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount]) }, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, isAgentSource])
const dimensions = const dimensions =
width != null && height != null && width > 0 && height > 0 width != null && height != null && width > 0 && height > 0

View File

@@ -71,6 +71,7 @@ export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number
variable: { width: 224, height: 180 }, variable: { width: 224, height: 180 },
function: { width: 288, height: 260 }, function: { width: 288, height: 260 },
data: { width: 360, height: 280 }, data: { width: 360, height: 280 },
agent: { width: 360, height: 320 },
} }
/** Default data for a new node. Uses nodeRegistry when type is registered. */ /** Default data for a new node. Uses nodeRegistry when type is registered. */

View File

@@ -1,6 +1,6 @@
import type React from 'react' import type React from 'react'
export type NodeType = 'config' | 'render' | 'variable' | 'function' | 'data' export type NodeType = 'config' | 'render' | 'variable' | 'function' | 'data' | 'agent'
export type NodeHelpEntry = { export type NodeHelpEntry = {
title: string title: string
@@ -90,6 +90,19 @@ export const NODE_HELP: Record<NodeType, NodeHelpEntry> = {
</> </>
), ),
}, },
agent: {
title: 'Agent node',
content: (
<>
<Section title="How to use">
<p>Agent nodes use AI to produce structured markdown. Connect Config nodes as the <strong>prompt</strong> (their content is sent as the main prompt). Optionally connect Variable or Data nodes; they are passed as context. Define additional context in the text area. Click Run to execute the agent; output is markdown. Connect this node to a Renderer to display the result.</p>
</Section>
<Section title="Output">
<p>When connected to a Renderer node, the agents markdown output is rendered there. Ensure the backend <Code>/api/agent</Code> is running and (if using OpenAI) <Code>OPENAI_API_KEY</Code> is set.</p>
</Section>
</>
),
},
} }
export function getNodeHelp(nodeType: NodeType): NodeHelpEntry { export function getNodeHelp(nodeType: NodeType): NodeHelpEntry {

View File

@@ -7,13 +7,14 @@
import type React from 'react' import type React from 'react'
import type { Node } from '@xyflow/react' import type { Node } from '@xyflow/react'
/** Node classification for UI: Psyche (Form/Config, Variable, Behavior/Function), Pneuma (Rendering), Physis (TBD). */ /** Node classification for UI: Psyche, Pneuma, Physis, Archon (AI Agent). */
export type NodeClassification = 'psyche' | 'pneuma' | 'physis' export type NodeClassification = 'psyche' | 'pneuma' | 'physis' | 'archon'
export const NODE_CLASSIFICATION_LABELS: Record<NodeClassification, string> = { export const NODE_CLASSIFICATION_LABELS: Record<NodeClassification, string> = {
psyche: 'Psyche', psyche: 'Psyche',
pneuma: 'Pneuma', pneuma: 'Pneuma',
physis: 'Physis', physis: 'Physis',
archon: 'Archon',
} }
export type NodeHelpEntry = { export type NodeHelpEntry = {
@@ -59,7 +60,7 @@ export function getNodeType(id: string): NodeTypeDescriptor | undefined {
return registry.get(id) return registry.get(id)
} }
const CLASSIFICATION_ORDER: NodeClassification[] = ['psyche', 'pneuma', 'physis'] const CLASSIFICATION_ORDER: NodeClassification[] = ['psyche', 'pneuma', 'physis', 'archon']
export function getRegisteredNodeTypes(): NodeTypeDescriptor[] { export function getRegisteredNodeTypes(): NodeTypeDescriptor[] {
return Array.from(registry.values()) return Array.from(registry.values())

View File

@@ -8,7 +8,8 @@ import type { RenderingNodeData } from '@/components/nodes/RenderingNode'
import type { VariableNodeData } from '@/components/nodes/VariableNode' import type { VariableNodeData } from '@/components/nodes/VariableNode'
import type { FunctionNodeData } from '@/components/nodes/FunctionNode' import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
import type { DataNodeData } from '@/components/nodes/DataNode' import type { DataNodeData } from '@/components/nodes/DataNode'
import type { AgentNodeData } from '@/components/nodes/AgentNode'
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData | DataNodeData export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData | DataNodeData | AgentNodeData
export type AppNode = Node<AppNodeData> export type AppNode = Node<AppNodeData>
export type AppEdge = Edge export type AppEdge = Edge

View File

@@ -4,7 +4,7 @@
*/ */
import React from 'react' import React from 'react'
import { ScrollText, Sparkles, Variable, Code2, Database } from 'lucide-react' import { ScrollText, Sparkles, Variable, Code2, Database, Bot } from 'lucide-react'
import { registerNodeType } from './nodeRegistry' import { registerNodeType } from './nodeRegistry'
import { NODE_HELP } from './nodeHelp' import { NODE_HELP } from './nodeHelp'
import ConfigNode from '../components/nodes/ConfigNode' import ConfigNode from '../components/nodes/ConfigNode'
@@ -12,6 +12,7 @@ import RenderingNode from '../components/nodes/RenderingNode'
import VariableNode from '../components/nodes/VariableNode' import VariableNode from '../components/nodes/VariableNode'
import FunctionNode from '../components/nodes/FunctionNode' import FunctionNode from '../components/nodes/FunctionNode'
import DataNode from '../components/nodes/DataNode' import DataNode from '../components/nodes/DataNode'
import AgentNode from '../components/nodes/AgentNode'
const ICON_CLASS = 'mr-2 h-4 w-4' const ICON_CLASS = 'mr-2 h-4 w-4'
@@ -26,7 +27,7 @@ export function registerBuiltinNodes(): void {
hasOutput: true, hasOutput: true,
classification: 'psyche', classification: 'psyche',
allowedSourceTypes: ['config', 'variable', 'function', 'data'], allowedSourceTypes: ['config', 'variable', 'function', 'data'],
allowedTargetTypes: ['config', 'render'], allowedTargetTypes: ['config', 'render', 'agent'],
help: NODE_HELP.config, help: NODE_HELP.config,
menuLabel: 'Config', menuLabel: 'Config',
menuIcon: <ScrollText className={ICON_CLASS} />, menuIcon: <ScrollText className={ICON_CLASS} />,
@@ -52,13 +53,30 @@ export function registerBuiltinNodes(): void {
hasInput: true, hasInput: true,
hasOutput: false, hasOutput: false,
classification: 'pneuma', classification: 'pneuma',
allowedSourceTypes: ['config'], allowedSourceTypes: ['config', 'agent'],
help: NODE_HELP.render, help: NODE_HELP.render,
menuLabel: 'Renderer', menuLabel: 'Renderer',
menuIcon: <Sparkles className={ICON_CLASS} />, menuIcon: <Sparkles className={ICON_CLASS} />,
connectionLabel: 'rendering', connectionLabel: 'rendering',
}) })
registerNodeType({
id: 'agent',
component: AgentNode,
defaultStyle: { width: 360, height: 320 },
defaultData: { context: '' },
idPrefix: 'agt_',
hasInput: true,
hasOutput: true,
classification: 'archon',
allowedSourceTypes: ['config', 'variable', 'data'],
allowedTargetTypes: ['render'],
help: NODE_HELP.agent,
menuLabel: 'Agent',
menuIcon: <Bot className={ICON_CLASS} />,
connectionLabel: 'prompt/context',
})
registerNodeType({ registerNodeType({
id: 'variable', id: 'variable',
component: VariableNode, component: VariableNode,
@@ -68,7 +86,7 @@ export function registerBuiltinNodes(): void {
hasInput: false, hasInput: false,
hasOutput: true, hasOutput: true,
classification: 'psyche', classification: 'psyche',
allowedTargetTypes: ['config', 'function'], allowedTargetTypes: ['config', 'function', 'agent'],
help: NODE_HELP.variable, help: NODE_HELP.variable,
menuLabel: 'Variable', menuLabel: 'Variable',
menuIcon: <Variable className={ICON_CLASS} />, menuIcon: <Variable className={ICON_CLASS} />,
@@ -83,7 +101,7 @@ export function registerBuiltinNodes(): void {
hasInput: false, hasInput: false,
hasOutput: true, hasOutput: true,
classification: 'physis', classification: 'physis',
allowedTargetTypes: ['config'], allowedTargetTypes: ['config', 'agent'],
help: NODE_HELP.data, help: NODE_HELP.data,
menuLabel: 'Data', menuLabel: 'Data',
menuIcon: <Database className={ICON_CLASS} />, menuIcon: <Database className={ICON_CLASS} />,

View File

@@ -20,6 +20,10 @@ export default defineConfig({
target: 'http://localhost:8080', target: 'http://localhost:8080',
changeOrigin: true, changeOrigin: true,
}, },
'/api/agent': {
target: 'http://localhost:8080',
changeOrigin: true,
},
// Kroki diagram service // Kroki diagram service
'/api/kroki': { '/api/kroki': {
target: 'https://kroki.io', target: 'https://kroki.io',