Compare commits
6 Commits
deb1be0d66
...
f201fe92f4
| Author | SHA1 | Date | |
|---|---|---|---|
| f201fe92f4 | |||
| 94da42f9fc | |||
| 581cfca506 | |||
| 9c0161b21d | |||
| 95e1c13dcd | |||
| 1cc6b98973 |
33
README.md
33
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. |
|
||||
|
||||
14
backend/.env.example
Normal file
14
backend/.env.example
Normal 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
|
||||
266
backend/package-lock.json
generated
266
backend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Theme).
|
||||
* Menubar for the canvas page: Project (Import/Export), Edit (Undo/Redo, Duplicate/Copy/Paste, Rename), View (Fit View, Minimap).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -17,9 +17,8 @@ import {
|
||||
MenubarCheckboxItem,
|
||||
} from '@/components/ui/menubar'
|
||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { usePlatform } from '@/app/platform/platformContext'
|
||||
import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Moon, Pencil, Redo2, Sun, Undo2 } from 'lucide-react'
|
||||
import { ArrowLeft, ClipboardPaste, Copy, CopyPlus, Download, FolderOpen, Pencil, Redo2, Undo2 } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export type CanvasMenubarProps = {
|
||||
@@ -59,7 +58,6 @@ export function CanvasMenubar({
|
||||
canCopy = false,
|
||||
onFitView,
|
||||
}: CanvasMenubarProps) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const { projects, renameProject } = usePlatform()
|
||||
const projectName = useMemo(
|
||||
@@ -231,28 +229,6 @@ export function CanvasMenubar({
|
||||
</span>
|
||||
</MenubarItem>
|
||||
)}
|
||||
{onFitView && <MenubarSeparator />}
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger>Theme</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
<MenubarCheckboxItem
|
||||
checked={theme === 'light'}
|
||||
onCheckedChange={() => setTheme('light')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Sun className="h-4 w-4" />
|
||||
Light
|
||||
</MenubarCheckboxItem>
|
||||
<MenubarCheckboxItem
|
||||
checked={theme === 'dark'}
|
||||
onCheckedChange={() => setTheme('dark')}
|
||||
className="gap-2"
|
||||
>
|
||||
<Moon className="h-4 w-4" />
|
||||
Dark
|
||||
</MenubarCheckboxItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</Menubar>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { AnimatedEdge } from '@/components/base/AnimatedEdge'
|
||||
import FlowContext from '@/lib/flowContext'
|
||||
import { useTheme } from '@/lib/themeContext'
|
||||
import { usePlatform } from '@/app/platform/platformContext'
|
||||
import { useGraphStateWithHistory } from '@/hooks/useGraphStateWithHistory'
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -148,6 +149,7 @@ function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edg
|
||||
|
||||
export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const { theme } = useTheme()
|
||||
const { showMinimap } = usePlatform()
|
||||
const initialGraph = useMemo(() => getInitialGraph(projectId), [projectId])
|
||||
const {
|
||||
nodes,
|
||||
@@ -662,9 +664,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
<div role="group" aria-label="Canvas controls: zoom and fit view">
|
||||
<Controls />
|
||||
</div>
|
||||
{showMinimap && (
|
||||
<div role="region" aria-label="Minimap: overview of the graph">
|
||||
<MiniMap />
|
||||
</div>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
|
||||
@@ -17,12 +17,13 @@ import {
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Plus, ListTodo } from 'lucide-react'
|
||||
import { Plus, ListTodo, Settings } from 'lucide-react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { usePlatform } from './platformContext'
|
||||
import { getProjectIcon } from '../../lib/iconMap'
|
||||
import { NewProjectDialog } from './NewProjectDialog'
|
||||
import { SettingsDialog } from './SettingsDialog'
|
||||
import type { Project } from './types'
|
||||
|
||||
export function AppSidebar() {
|
||||
@@ -178,7 +179,20 @@ export function AppSidebar() {
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="relative z-10" />
|
||||
<SidebarFooter className="relative z-10">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SettingsDialog
|
||||
trigger={
|
||||
<SidebarMenuButton tooltip="Settings" className="w-full">
|
||||
<Settings className="size-4" />
|
||||
<span className="group-data-[collapsible=icon]:hidden">Settings</span>
|
||||
</SidebarMenuButton>
|
||||
}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail className="relative z-10" />
|
||||
</Sidebar>
|
||||
</>
|
||||
|
||||
@@ -13,11 +13,6 @@ function PlatformLayoutInner() {
|
||||
const { recordProjectAccess } = usePlatform()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) setSidebarOpen(false)
|
||||
else setSidebarOpen(true)
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) recordProjectAccess(projectId)
|
||||
}, [projectId, recordProjectAccess])
|
||||
|
||||
194
frontend/src/app/platform/SettingsDialog.tsx
Normal file
194
frontend/src/app/platform/SettingsDialog.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Settings dialog with sidebar-style nav (Appearance, AI). Reference: shadcn sidebar-13.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
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'
|
||||
|
||||
export function SettingsDialog({
|
||||
trigger,
|
||||
}: {
|
||||
trigger: React.ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [section, setSection] = useState<SettingsSection>('appearance')
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { showMinimap, setShowMinimap, aiConnection, setAiConnection } = usePlatform()
|
||||
|
||||
const updateAiConnection = useCallback(
|
||||
(partial: Partial<AiConnection>) => {
|
||||
setAiConnection({ ...aiConnection, ...partial })
|
||||
},
|
||||
[aiConnection, setAiConnection]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent
|
||||
className="flex h-[min(85vh,28rem)] max-w-2xl p-0 gap-0 overflow-hidden"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-1 min-h-0 w-full">
|
||||
<nav
|
||||
className="flex w-44 shrink-0 flex-col gap-1 border-r bg-muted/30 p-2"
|
||||
aria-label="Settings sections"
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
Settings
|
||||
</div>
|
||||
<Button
|
||||
variant={section === 'appearance' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="justify-start gap-2"
|
||||
onClick={() => setSection('appearance')}
|
||||
>
|
||||
<Sun className="size-4 shrink-0 opacity-70" />
|
||||
Appearance
|
||||
</Button>
|
||||
<Button
|
||||
variant={section === 'ai' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="justify-start gap-2"
|
||||
onClick={() => setSection('ai')}
|
||||
>
|
||||
<Sparkles className="size-4 shrink-0 opacity-70" />
|
||||
AI
|
||||
</Button>
|
||||
</nav>
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||
{section === 'appearance' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Appearance</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<label htmlFor="settings-theme" className="text-sm font-medium">
|
||||
Theme
|
||||
</label>
|
||||
<Select value={theme} onValueChange={(v) => setTheme(v as Theme)}>
|
||||
<SelectTrigger id="settings-theme" className="w-full min-w-[8rem] sm:w-40">
|
||||
<SelectValue placeholder="Theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-3 pt-2 border-t">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Canvas
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<label htmlFor="settings-minimap" className="text-sm font-medium">
|
||||
Minimap
|
||||
</label>
|
||||
<Switch
|
||||
id="settings-minimap"
|
||||
checked={showMinimap}
|
||||
onCheckedChange={setShowMinimap}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{section === 'ai' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">AI connection</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used by the Agent node. Choose OpenAI or a local server (e.g. LM Studio).
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||
<label htmlFor="settings-ai-provider" className="text-sm font-medium">
|
||||
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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -11,8 +11,65 @@ import { PROJECT_VERSION } from './projectGraphStorage'
|
||||
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<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 {
|
||||
try {
|
||||
const raw = localStorage.getItem(CANVAS_MINIMAP_KEY)
|
||||
if (raw === 'true') return true
|
||||
if (raw === 'false') return false
|
||||
} catch {}
|
||||
return false
|
||||
}
|
||||
|
||||
function saveShowMinimap(value: boolean) {
|
||||
try {
|
||||
localStorage.setItem(CANVAS_MINIMAP_KEY, JSON.stringify(value))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function loadOrder(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(ORDER_STORAGE_KEY)
|
||||
@@ -107,6 +164,12 @@ export type PlatformContextValue = {
|
||||
updateLastEdited: (id: string) => void
|
||||
reorderProjects: (orderedIds: string[]) => void
|
||||
restoreProject: (project: Project, graphSnapshot: GraphSnapshot | null) => void
|
||||
/** 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<PlatformContextValue | null>(null)
|
||||
@@ -115,6 +178,18 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
|
||||
const [projects, setProjects] = useState<Project[]>(loadProjects)
|
||||
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
|
||||
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
|
||||
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
|
||||
const [aiConnection, setAiConnectionState] = useState<AiConnection>(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)
|
||||
@@ -220,6 +295,10 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
|
||||
updateLastEdited,
|
||||
reorderProjects,
|
||||
restoreProject,
|
||||
showMinimap,
|
||||
setShowMinimap,
|
||||
aiConnection,
|
||||
setAiConnection,
|
||||
}),
|
||||
[
|
||||
projects,
|
||||
@@ -234,6 +313,10 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
|
||||
updateLastEdited,
|
||||
reorderProjects,
|
||||
restoreProject,
|
||||
showMinimap,
|
||||
setShowMinimap,
|
||||
aiConnection,
|
||||
setAiConnection,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
203
frontend/src/components/nodes/AgentNode.tsx
Normal file
203
frontend/src/components/nodes/AgentNode.tsx
Normal 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
|
||||
@@ -64,10 +64,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const incomingIds = sourceIds
|
||||
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
|
||||
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 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 ?? {}
|
||||
|
||||
/** 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) {
|
||||
setRenderedContent(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)
|
||||
return
|
||||
}
|
||||
@@ -205,6 +210,17 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
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 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.
|
||||
}, [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 =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
|
||||
@@ -47,9 +47,14 @@ function coerceValue(raw: string, valueType: ValueType): string | number | boole
|
||||
}
|
||||
}
|
||||
|
||||
function VariableNodeComponent({ id, data, selected }: Props) {
|
||||
function VariableNodeComponent({ id, data, width, height, selected }: Props) {
|
||||
const { updateData } = useAbstractNode<VariableNodeData>(id, data ?? {})
|
||||
|
||||
const dimensions =
|
||||
width != null && height != null && width > 0 && height > 0
|
||||
? { width, height }
|
||||
: undefined
|
||||
|
||||
const valueType: ValueType = data?.valueType ?? 'string'
|
||||
const value = data?.value ?? DEFAULT_BY_TYPE[valueType]
|
||||
const displayValue = typeof value === 'string' ? value : String(value)
|
||||
@@ -79,7 +84,7 @@ function VariableNodeComponent({ id, data, selected }: Props) {
|
||||
)
|
||||
|
||||
return (
|
||||
<BaseNode className="min-w-56 min-h-[180px]" selected={selected} handles={<OutputHandle id="out" />}>
|
||||
<BaseNode className="min-w-56 min-h-[180px]" dimensions={dimensions} selected={selected} handles={<OutputHandle id="out" />}>
|
||||
<BaseNodeHeaderRow icon={<Variable className="size-4" />} title={<NodeHeaderTitle nodeId={id} displayTitle={id} />} />
|
||||
|
||||
<BaseNodeContent>
|
||||
|
||||
@@ -71,6 +71,7 @@ export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number
|
||||
variable: { width: 224, height: 180 },
|
||||
function: { width: 288, height: 260 },
|
||||
data: { width: 360, height: 280 },
|
||||
agent: { width: 360, height: 320 },
|
||||
}
|
||||
|
||||
/** Default data for a new node. Uses nodeRegistry when type is registered. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 = {
|
||||
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 agent’s 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 {
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
import type React from 'react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
/** Node classification for UI: Psyche (Form/Config, Variable, Behavior/Function), Pneuma (Rendering), Physis (TBD). */
|
||||
export type NodeClassification = 'psyche' | 'pneuma' | 'physis'
|
||||
/** Node classification for UI: Psyche, Pneuma, Physis, Archon (AI Agent). */
|
||||
export type NodeClassification = 'psyche' | 'pneuma' | 'physis' | 'archon'
|
||||
|
||||
export const NODE_CLASSIFICATION_LABELS: Record<NodeClassification, string> = {
|
||||
psyche: 'Psyche',
|
||||
pneuma: 'Pneuma',
|
||||
physis: 'Physis',
|
||||
archon: 'Archon',
|
||||
}
|
||||
|
||||
export type NodeHelpEntry = {
|
||||
@@ -59,7 +60,7 @@ export function getNodeType(id: string): NodeTypeDescriptor | undefined {
|
||||
return registry.get(id)
|
||||
}
|
||||
|
||||
const CLASSIFICATION_ORDER: NodeClassification[] = ['psyche', 'pneuma', 'physis']
|
||||
const CLASSIFICATION_ORDER: NodeClassification[] = ['psyche', 'pneuma', 'physis', 'archon']
|
||||
|
||||
export function getRegisteredNodeTypes(): NodeTypeDescriptor[] {
|
||||
return Array.from(registry.values())
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { RenderingNodeData } from '@/components/nodes/RenderingNode'
|
||||
import type { VariableNodeData } from '@/components/nodes/VariableNode'
|
||||
import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
|
||||
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 AppEdge = Edge
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
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 { NODE_HELP } from './nodeHelp'
|
||||
import ConfigNode from '../components/nodes/ConfigNode'
|
||||
@@ -12,6 +12,7 @@ import RenderingNode from '../components/nodes/RenderingNode'
|
||||
import VariableNode from '../components/nodes/VariableNode'
|
||||
import FunctionNode from '../components/nodes/FunctionNode'
|
||||
import DataNode from '../components/nodes/DataNode'
|
||||
import AgentNode from '../components/nodes/AgentNode'
|
||||
|
||||
const ICON_CLASS = 'mr-2 h-4 w-4'
|
||||
|
||||
@@ -26,7 +27,7 @@ export function registerBuiltinNodes(): void {
|
||||
hasOutput: true,
|
||||
classification: 'psyche',
|
||||
allowedSourceTypes: ['config', 'variable', 'function', 'data'],
|
||||
allowedTargetTypes: ['config', 'render'],
|
||||
allowedTargetTypes: ['config', 'render', 'agent'],
|
||||
help: NODE_HELP.config,
|
||||
menuLabel: 'Config',
|
||||
menuIcon: <ScrollText className={ICON_CLASS} />,
|
||||
@@ -52,23 +53,40 @@ export function registerBuiltinNodes(): void {
|
||||
hasInput: true,
|
||||
hasOutput: false,
|
||||
classification: 'pneuma',
|
||||
allowedSourceTypes: ['config'],
|
||||
allowedSourceTypes: ['config', 'agent'],
|
||||
help: NODE_HELP.render,
|
||||
menuLabel: 'Renderer',
|
||||
menuIcon: <Sparkles className={ICON_CLASS} />,
|
||||
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({
|
||||
id: 'variable',
|
||||
component: VariableNode,
|
||||
defaultStyle: { width: 224, height: 180 },
|
||||
defaultStyle: { width: 224, height: 240 },
|
||||
defaultData: { value: '', valueType: 'string' },
|
||||
idPrefix: 'var_',
|
||||
hasInput: false,
|
||||
hasOutput: true,
|
||||
classification: 'psyche',
|
||||
allowedTargetTypes: ['config', 'function'],
|
||||
allowedTargetTypes: ['config', 'function', 'agent'],
|
||||
help: NODE_HELP.variable,
|
||||
menuLabel: 'Variable',
|
||||
menuIcon: <Variable className={ICON_CLASS} />,
|
||||
@@ -83,7 +101,7 @@ export function registerBuiltinNodes(): void {
|
||||
hasInput: false,
|
||||
hasOutput: true,
|
||||
classification: 'physis',
|
||||
allowedTargetTypes: ['config'],
|
||||
allowedTargetTypes: ['config', 'agent'],
|
||||
help: NODE_HELP.data,
|
||||
menuLabel: 'Data',
|
||||
menuIcon: <Database className={ICON_CLASS} />,
|
||||
|
||||
@@ -20,6 +20,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/agent': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// Kroki diagram service
|
||||
'/api/kroki': {
|
||||
target: 'https://kroki.io',
|
||||
|
||||
Reference in New Issue
Block a user