feat: Discord guild/channel pickers, apply flow, agent identity

- Replace raw guild_id/channel_id text inputs with dropdown pickers
  showing human-readable names from openclaw config
- Add persistent file-level cache for Discord channel data with
  dedicated Channels tab and refresh button
- Read agent name/emoji from IDENTITY.md in workspace directories
- Rename Install→Cook throughout UI
- Add step-by-step apply flow: apply config → restart gateway → done
- Add global loading overlay for blocking operations
- Use react-diff-viewer-continued for config diff preview
- Fix validation bugs (Option<usize> null handling, discord type bypass)
- Fix serde camelCase on PreviewResult/ApplyResult structs
- Make slow commands async (refresh_discord, restart_gateway)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zhixian
2026-02-17 18:37:28 +09:00
parent 1fbf266034
commit 3286ae3af1
21 changed files with 1153 additions and 200 deletions

490
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"lucide-react": "^0.564.0",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-diff-viewer-continued": "^4.1.2",
"react-dom": "^18.3.1",
"tailwind-merge": "^3.4.1"
},
@@ -34,7 +35,6 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
@@ -91,7 +91,6 @@
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
@@ -125,7 +124,6 @@
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -135,7 +133,6 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.28.6",
@@ -177,7 +174,6 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -187,7 +183,6 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -221,7 +216,6 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
@@ -265,11 +259,19 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.28.6",
@@ -284,7 +286,6 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.0",
@@ -303,7 +304,6 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@@ -313,6 +313,139 @@
"node": ">=6.9.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.16.7",
"@babel/runtime": "^7.18.3",
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
"@emotion/serialize": "^1.3.3",
"babel-plugin-macros": "^3.1.0",
"convert-source-map": "^1.5.0",
"escape-string-regexp": "^4.0.0",
"find-root": "^1.1.0",
"source-map": "^0.5.7",
"stylis": "4.2.0"
}
},
"node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
},
"node_modules/@emotion/cache": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
"license": "MIT",
"dependencies": {
"@emotion/memoize": "^0.9.0",
"@emotion/sheet": "^1.4.0",
"@emotion/utils": "^1.4.2",
"@emotion/weak-memoize": "^0.4.0",
"stylis": "4.2.0"
}
},
"node_modules/@emotion/css": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz",
"integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==",
"license": "MIT",
"dependencies": {
"@emotion/babel-plugin": "^11.13.5",
"@emotion/cache": "^11.13.5",
"@emotion/serialize": "^1.3.3",
"@emotion/sheet": "^1.4.0",
"@emotion/utils": "^1.4.2"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT"
},
"node_modules/@emotion/react": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/cache": "^11.14.0",
"@emotion/serialize": "^1.3.3",
"@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
"@emotion/utils": "^1.4.2",
"@emotion/weak-memoize": "^0.4.0",
"hoist-non-react-statics": "^3.3.1"
},
"peerDependencies": {
"react": ">=16.8.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@emotion/serialize": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
"license": "MIT",
"dependencies": {
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
"@emotion/unitless": "^0.10.0",
"@emotion/utils": "^1.4.2",
"csstype": "^3.0.2"
}
},
"node_modules/@emotion/sheet": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
"license": "MIT"
},
"node_modules/@emotion/unitless": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
"license": "MIT"
},
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/utils": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT"
},
"node_modules/@emotion/weak-memoize": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -746,7 +879,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -768,7 +900,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -778,14 +909,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -2992,6 +3121,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -3043,6 +3178,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -3055,6 +3196,21 @@
"node": ">=10"
}
},
"node_modules/babel-plugin-macros": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5",
"cosmiconfig": "^7.0.0",
"resolve": "^1.19.0"
},
"engines": {
"node": ">=10",
"npm": ">=6"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -3100,6 +3256,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001770",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz",
@@ -3133,6 +3298,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -3165,18 +3336,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"license": "MIT",
"dependencies": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
"parse-json": "^5.0.0",
"path-type": "^4.0.0",
"yaml": "^1.10.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3206,6 +3391,15 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT"
},
"node_modules/diff": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.286",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
@@ -3227,6 +3421,15 @@
"node": ">=10.13.0"
}
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
}
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -3276,6 +3479,24 @@
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/find-root": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3291,6 +3512,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3317,6 +3547,64 @@
"dev": true,
"license": "ISC"
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT"
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -3333,11 +3621,22 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
@@ -3346,6 +3645,12 @@
"node": ">=6"
}
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -3619,6 +3924,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -3660,11 +3971,16 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -3693,11 +4009,55 @@
"dev": true,
"license": "MIT"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/postcss": {
@@ -3819,6 +4179,27 @@
"node": ">=0.10.0"
}
},
"node_modules/react-diff-viewer-continued": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/react-diff-viewer-continued/-/react-diff-viewer-continued-4.1.2.tgz",
"integrity": "sha512-k+zm+9IEmJh0dHWV8QjvrnmYztoedR/6uvAMOwfFEO1QVUjYxa5Y7iyIH6cwupYonmcFlDt6NfA8ACWHOKYI2A==",
"license": "MIT",
"dependencies": {
"@emotion/css": "^11.13.5",
"@emotion/react": "^11.14.0",
"classnames": "^2.5.1",
"diff": "^8.0.3",
"js-yaml": "^4.1.1",
"memoize-one": "^6.0.0"
},
"engines": {
"node": ">= 16"
},
"peerDependencies": {
"react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -3833,6 +4214,12 @@
"react": "^18.3.1"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3912,6 +4299,35 @@
}
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
"integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
@@ -3976,6 +4392,15 @@
"semver": "bin/semver.js"
}
},
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3986,6 +4411,24 @@
"node": ">=0.10.0"
}
},
"node_modules/stylis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.1.tgz",
@@ -4194,6 +4637,15 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"license": "ISC",
"engines": {
"node": ">= 6"
}
}
}
}

View File

@@ -20,6 +20,7 @@
"lucide-react": "^0.564.0",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-diff-viewer-continued": "^4.1.2",
"react-dom": "^18.3.1",
"tailwind-merge": "^3.4.1"
},

View File

@@ -1,3 +1,4 @@
fn main() {
println!("cargo:rerun-if-changed=recipes.json");
tauri_build::build()
}

View File

@@ -10,23 +10,15 @@
"params": [
{
"id": "guild_id",
"label": "Guild ID",
"type": "string",
"required": true,
"pattern": "^[0-9]+$",
"minLength": 17,
"maxLength": 20,
"placeholder": "Copy guild id"
"label": "Guild",
"type": "discord_guild",
"required": true
},
{
"id": "channel_id",
"label": "Channel ID",
"type": "string",
"required": true,
"pattern": "^[0-9]+$",
"minLength": 17,
"maxLength": 20,
"placeholder": "Copy channel id"
"label": "Channel",
"type": "discord_channel",
"required": true
},
{
"id": "persona",

View File

@@ -205,6 +205,15 @@ pub struct ChannelNode {
pub name_status: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscordGuildChannel {
pub guild_id: String,
pub guild_name: String,
pub channel_id: String,
pub channel_name: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelBinding {
@@ -243,6 +252,8 @@ pub struct FixResult {
#[serde(rename_all = "camelCase")]
pub struct AgentOverview {
pub id: String,
pub name: Option<String>,
pub emoji: Option<String>,
pub model: Option<String>,
pub channels: Vec<String>,
pub online: bool,
@@ -491,6 +502,91 @@ pub fn list_channels_minimal() -> Result<Vec<ChannelNode>, String> {
Ok(nodes)
}
/// Read Discord guild/channels from persistent cache. Fast, no subprocess.
#[tauri::command]
pub fn list_discord_guild_channels() -> Result<Vec<DiscordGuildChannel>, String> {
let paths = resolve_paths();
let cache_file = paths.clawpal_dir.join("discord-guild-channels.json");
if cache_file.exists() {
let text = fs::read_to_string(&cache_file).map_err(|e| e.to_string())?;
let entries: Vec<DiscordGuildChannel> = serde_json::from_str(&text).unwrap_or_default();
return Ok(entries);
}
Ok(Vec::new())
}
/// Resolve Discord guild/channel names via openclaw CLI and persist to cache.
#[tauri::command]
pub async fn refresh_discord_guild_channels() -> Result<Vec<DiscordGuildChannel>, String> {
tauri::async_runtime::spawn_blocking(move || {
let paths = resolve_paths();
ensure_dirs(&paths)?;
let cfg = read_openclaw_config(&paths)?;
let guilds = cfg
.get("channels")
.and_then(|c| c.get("discord"))
.and_then(|d| d.get("guilds"))
.and_then(Value::as_object);
let Some(guilds) = guilds else {
return Ok(Vec::new());
};
let mut entries: Vec<DiscordGuildChannel> = Vec::new();
let mut channel_ids: Vec<String> = Vec::new();
for (guild_id, guild_val) in guilds {
let guild_name = guild_val
.get("slug")
.or_else(|| guild_val.get("name"))
.and_then(Value::as_str)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| guild_id.clone());
if let Some(channels) = guild_val.get("channels").and_then(Value::as_object) {
for (channel_id, _channel_val) in channels {
channel_ids.push(channel_id.clone());
entries.push(DiscordGuildChannel {
guild_id: guild_id.clone(),
guild_name: guild_name.clone(),
channel_id: channel_id.clone(),
channel_name: channel_id.clone(),
});
}
}
}
if !channel_ids.is_empty() {
let mut args = vec![
"channels", "resolve", "--json",
"--channel", "discord",
"--kind", "auto",
];
let id_refs: Vec<&str> = channel_ids.iter().map(String::as_str).collect();
args.extend_from_slice(&id_refs);
if let Ok(output) = run_openclaw_raw(&args) {
if let Some(name_map) = parse_resolve_name_map(&output.stdout) {
for entry in &mut entries {
if let Some(name) = name_map.get(&entry.channel_id) {
entry.channel_name = name.clone();
}
}
}
}
}
// Persist to cache
let cache_file = paths.clawpal_dir.join("discord-guild-channels.json");
let json = serde_json::to_string_pretty(&entries).map_err(|e| e.to_string())?;
write_text(&cache_file, &json)?;
Ok(entries)
}).await.map_err(|e| e.to_string())?
}
#[tauri::command]
pub fn update_channel_config(
path: String,
@@ -598,10 +694,23 @@ pub fn list_agents_overview() -> Result<Vec<AgentOverview>, String> {
let cfg = read_openclaw_config(&paths)?;
let mut agents = Vec::new();
let default_workspace = cfg.pointer("/agents/defaults/workspace")
.and_then(Value::as_str)
.map(|s| expand_tilde(s));
if let Some(list) = cfg.pointer("/agents/list").and_then(Value::as_array) {
let channel_nodes = collect_channel_nodes(&cfg);
for agent in list {
let id = agent.get("id").and_then(Value::as_str).unwrap_or("agent").to_string();
// Read name/emoji from IDENTITY.md in the agent's workspace
let workspace = agent.get("workspace").and_then(Value::as_str)
.map(|s| expand_tilde(s))
.or_else(|| default_workspace.clone());
let (name, emoji) = workspace
.and_then(|ws| parse_identity_md(&ws))
.unwrap_or((None, None));
let model = agent.get("model").and_then(read_model_value)
.or_else(|| cfg.pointer("/agents/defaults/model").and_then(read_model_value))
.or_else(|| cfg.pointer("/agents/default/model").and_then(read_model_value));
@@ -611,6 +720,8 @@ pub fn list_agents_overview() -> Result<Vec<AgentOverview>, String> {
let has_sessions = paths.base_dir.join("agents").join(&id).join("sessions").exists();
agents.push(AgentOverview {
id,
name,
emoji,
model,
channels,
online: has_sessions,
@@ -620,6 +731,39 @@ pub fn list_agents_overview() -> Result<Vec<AgentOverview>, String> {
Ok(agents)
}
fn expand_tilde(path: &str) -> String {
if path.starts_with("~/") {
if let Some(home) = std::env::var("HOME").ok() {
return format!("{}{}", home, &path[1..]);
}
}
path.to_string()
}
fn parse_identity_md(workspace: &str) -> Option<(Option<String>, Option<String>)> {
let identity_path = std::path::Path::new(workspace).join("IDENTITY.md");
let content = fs::read_to_string(&identity_path).ok()?;
let mut name = None;
let mut emoji = None;
for line in content.lines() {
let trimmed = line.trim().trim_start_matches('-').trim();
// Handle both "Name: X" and "**Name:** X"
let trimmed = trimmed.replace("**", "");
if let Some(val) = trimmed.strip_prefix("Name:") {
let val = val.trim();
if !val.is_empty() {
name = Some(val.to_string());
}
} else if let Some(val) = trimmed.strip_prefix("Emoji:") {
let val = val.trim();
if !val.is_empty() {
emoji = Some(val.to_string());
}
}
}
Some((name, emoji))
}
#[tauri::command]
pub fn list_memory_files() -> Result<Vec<MemoryFile>, String> {
let paths = resolve_paths();
@@ -715,9 +859,13 @@ pub fn preview_apply(
ensure_dirs(&paths)?;
let current = read_openclaw_config(&paths)?;
let (candidate, changes) = build_candidate_config(&current, &recipe, &params)?;
let before_text = serde_json::to_string_pretty(&current).unwrap_or_else(|_| "{}".into());
let after_text = serde_json::to_string_pretty(&candidate).unwrap_or_else(|_| "{}".into());
Ok(PreviewResult {
recipe_id: recipe_id.clone(),
diff: format_diff(&current, &candidate),
config_before: before_text,
config_after: after_text,
changes,
overwrites_existing: true,
can_rollback: true,
@@ -769,6 +917,14 @@ pub fn apply_recipe(
})
}
#[tauri::command]
pub async fn restart_gateway() -> Result<bool, String> {
tauri::async_runtime::spawn_blocking(move || {
run_openclaw_raw(&["gateway", "restart"])?;
Ok(true)
}).await.map_err(|e| e.to_string())?
}
#[tauri::command]
pub fn list_history(limit: usize, offset: usize) -> Result<HistoryPage, String> {
let paths = resolve_paths();
@@ -805,9 +961,13 @@ pub fn preview_rollback(snapshot_id: String) -> Result<PreviewResult, String> {
let current = read_openclaw_config(&paths)?;
let target_text = read_snapshot(&target.config_path)?;
let target_json: Value = json5::from_str(&target_text).unwrap_or(Value::Object(Default::default()));
let before_text = serde_json::to_string_pretty(&current).unwrap_or_else(|_| "{}".into());
let after_text = serde_json::to_string_pretty(&target_json).unwrap_or_else(|_| "{}".into());
Ok(PreviewResult {
recipe_id: "rollback".into(),
diff: format_diff(&current, &target_json),
config_before: before_text,
config_after: after_text,
changes: collect_change_paths(&current, &target_json),
overwrites_existing: true,
can_rollback: true,
@@ -963,6 +1123,50 @@ fn extract_json_from_output(raw: &str) -> Option<&str> {
Some(&raw[start..])
}
/// Extract the last JSON array from CLI output that may contain ANSI codes and plugin logs.
/// Scans from the end to find the last `]`, then finds its matching `[`.
fn extract_last_json_array(raw: &str) -> Option<&str> {
let bytes = raw.as_bytes();
let end = bytes.iter().rposition(|&b| b == b']')?;
let mut depth = 0;
for i in (0..=end).rev() {
match bytes[i] {
b']' => depth += 1,
b'[' => {
depth -= 1;
if depth == 0 {
return Some(&raw[i..=end]);
}
}
_ => {}
}
}
None
}
/// Parse `openclaw channels resolve --json` output into a map of id -> name.
fn parse_resolve_name_map(stdout: &str) -> Option<HashMap<String, String>> {
let json_str = extract_last_json_array(stdout)?;
let parsed: Vec<Value> = serde_json::from_str(json_str).ok()?;
let mut map = HashMap::new();
for item in parsed {
let resolved = item.get("resolved").and_then(Value::as_bool).unwrap_or(false);
if !resolved {
continue;
}
if let (Some(input), Some(name)) = (
item.get("input").and_then(Value::as_str),
item.get("name").and_then(Value::as_str),
) {
let name = name.trim().to_string();
if !name.is_empty() {
map.insert(input.to_string(), name);
}
}
}
Some(map)
}
fn extract_version_from_text(input: &str) -> Option<String> {
let re = regex::Regex::new(r"\d+\.\d+(?:\.\d+){1,3}(?:[-+._a-zA-Z0-9]*)?").ok()?;
re.find(input).map(|mat| mat.as_str().to_string())

View File

@@ -8,6 +8,9 @@ use crate::commands::{
preview_rollback, rollback, run_doctor_command,
resolve_api_keys, read_raw_config, resolve_full_api_key, open_url, chat_via_openclaw,
backup_before_upgrade, list_backups, restore_from_backup, delete_backup,
list_discord_guild_channels,
refresh_discord_guild_channels,
restart_gateway,
};
pub mod commands;
@@ -56,6 +59,9 @@ pub fn run() {
list_backups,
restore_from_backup,
delete_backup,
list_discord_guild_channels,
refresh_discord_guild_channels,
restart_gateway,
])
.run(tauri::generate_context!())
.expect("failed to run app");

View File

@@ -20,9 +20,13 @@ pub struct RecipeParam {
#[serde(rename = "type")]
pub kind: String,
pub required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_length: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_length: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
}
@@ -50,9 +54,12 @@ pub struct ChangeItem {
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PreviewResult {
pub recipe_id: String,
pub diff: String,
pub config_before: String,
pub config_after: String,
pub changes: Vec<ChangeItem>,
pub overwrites_existing: bool,
pub can_rollback: bool,
@@ -61,6 +68,7 @@ pub struct PreviewResult {
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyResult {
pub ok: bool,
pub snapshot_id: Option<String>,

View File

@@ -1,31 +1,48 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Home } from "./pages/Home";
import { Recipes } from "./pages/Recipes";
import { Install } from "./pages/Install";
import { Cook } from "./pages/Cook";
import { History } from "./pages/History";
import { Doctor } from "./pages/Doctor";
import { Settings } from "./pages/Settings";
import { Channels } from "./pages/Channels";
import { GlobalLoading } from "./components/GlobalLoading";
import { api } from "./lib/api";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import type { DiscordGuildChannel } from "./lib/types";
type Route = "home" | "recipes" | "install" | "history" | "doctor" | "settings";
type Route = "home" | "recipes" | "cook" | "history" | "channels" | "doctor" | "settings";
export function App() {
const [route, setRoute] = useState<Route>("home");
const [recipeId, setRecipeId] = useState<string | null>(null);
const [recipeSource, setRecipeSource] = useState<string | undefined>(undefined);
const [discordGuildChannels, setDiscordGuildChannels] = useState<DiscordGuildChannel[]>([]);
const [globalLoading, setGlobalLoading] = useState<string | null>(null);
// Load Discord data from cache on startup (instant, no subprocess)
useEffect(() => {
if (!localStorage.getItem("clawpal_profiles_extracted")) {
api.extractModelProfilesFromConfig()
.then(() => localStorage.setItem("clawpal_profiles_extracted", "1"))
.catch(() => {});
}
api.listDiscordGuildChannels().then(setDiscordGuildChannels).catch(() => {});
}, []);
const refreshDiscord = useCallback(() => {
setGlobalLoading("Resolving Discord channel names...");
api.refreshDiscordGuildChannels()
.then(setDiscordGuildChannels)
.catch(() => {})
.finally(() => setGlobalLoading(null));
}, []);
return (
<>
{globalLoading && <GlobalLoading message={globalLoading} />}
<div className="flex h-screen">
<aside className="w-[200px] min-w-[200px] bg-muted border-r border-border flex flex-col py-4">
<h1 className="px-4 text-lg font-bold mb-4">ClawPal</h1>
@@ -44,12 +61,22 @@ export function App() {
variant="ghost"
className={cn(
"justify-start hover:bg-accent",
(route === "recipes" || route === "install") && "bg-accent text-accent-foreground border-l-[3px] border-primary"
(route === "recipes" || route === "cook") && "bg-accent text-accent-foreground border-l-[3px] border-primary"
)}
onClick={() => setRoute("recipes")}
>
Recipes
</Button>
<Button
variant="ghost"
className={cn(
"justify-start hover:bg-accent",
(route === "channels") && "bg-accent text-accent-foreground border-l-[3px] border-primary"
)}
onClick={() => setRoute("channels")}
>
Channels
</Button>
<Button
variant="ghost"
className={cn(
@@ -87,27 +114,34 @@ export function App() {
{route === "home" && <Home />}
{route === "recipes" && (
<Recipes
onInstall={(id, source) => {
onCook={(id, source) => {
setRecipeId(id);
setRecipeSource(source);
setRoute("install");
setRoute("cook");
}}
/>
)}
{route === "install" && recipeId && (
<Install
{route === "cook" && recipeId && (
<Cook
recipeId={recipeId}
recipeSource={recipeSource}
discordGuildChannels={discordGuildChannels}
onDone={() => {
setRoute("recipes");
}}
/>
)}
{route === "install" && !recipeId && <p>No recipe selected.</p>}
{route === "cook" && !recipeId && <p>No recipe selected.</p>}
{route === "channels" && (
<Channels
discordGuildChannels={discordGuildChannels}
onRefresh={refreshDiscord}
/>
)}
{route === "history" && <History />}
{route === "doctor" && <Doctor />}
{route === "settings" && <Settings />}
{route === "install" && (
{route === "cook" && (
<Button
variant="ghost"
className="mt-3 hover:bg-accent"
@@ -118,5 +152,6 @@ export function App() {
)}
</main>
</div>
</>
);
}

View File

@@ -1,9 +1,31 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import ReactDiffViewer from "react-diff-viewer-continued";
export function DiffViewer({ value }: { value: string }) {
/** Strip trailing commas from JSON lines so adding a new last property
* doesn't cause a spurious diff on the previous line. */
function normalizeJsonForDiff(text: string): string {
return text
.split("\n")
.map((line) => line.replace(/,(\s*)$/, "$1"))
.join("\n");
}
export function DiffViewer({
oldValue,
newValue,
}: {
oldValue: string;
newValue: string;
}) {
return (
<ScrollArea className="max-h-[260px] rounded-lg bg-muted p-3">
<pre className="text-sm whitespace-pre-wrap">{value}</pre>
</ScrollArea>
<div className="max-h-[400px] overflow-auto rounded-lg border">
<ReactDiffViewer
oldValue={normalizeJsonForDiff(oldValue)}
newValue={normalizeJsonForDiff(newValue)}
splitView={false}
hideLineNumbers={false}
showDiffOnly={true}
extraLinesSurroundingDiff={3}
/>
</div>
);
}

View File

@@ -0,0 +1,10 @@
export function GlobalLoading({ message }: { message: string }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3 rounded-lg border bg-card p-6 shadow-lg">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">{message}</p>
</div>
</div>
);
}

View File

@@ -1,19 +1,30 @@
import { useMemo, useState } from "react";
import type { Recipe, RecipeParam } from "../lib/types";
import type { DiscordGuildChannel, Recipe, RecipeParam } from "../lib/types";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
function validateField(param: RecipeParam, value: string): string | null {
const trim = value.trim();
if (param.required && trim.length === 0) {
return `${param.label} is required`;
}
if (param.minLength !== undefined && trim.length < param.minLength) {
// Select-based types only need required check
if (param.type === "discord_guild" || param.type === "discord_channel") {
return null;
}
if (param.minLength != null && trim.length < param.minLength) {
return `${param.label} is too short`;
}
if (param.maxLength !== undefined && trim.length > param.maxLength) {
if (param.maxLength != null && trim.length > param.maxLength) {
return `${param.label} is too long`;
}
if (param.pattern && trim.length > 0) {
@@ -33,13 +44,32 @@ export function ParamForm({
values,
onChange,
onSubmit,
discordGuildChannels = [],
}: {
recipe: Recipe;
values: Record<string, string>;
onChange: (id: string, value: string) => void;
onSubmit: () => void;
discordGuildChannels?: DiscordGuildChannel[];
}) {
const [touched, setTouched] = useState<Record<string, boolean>>({});
const uniqueGuilds = useMemo(() => {
const seen = new Map<string, string>();
for (const gc of discordGuildChannels) {
if (!seen.has(gc.guildId)) {
seen.set(gc.guildId, gc.guildName);
}
}
return Array.from(seen, ([id, name]) => ({ id, name }));
}, [discordGuildChannels]);
const filteredChannels = useMemo(() => {
const guildId = values["guild_id"];
if (!guildId) return [];
return discordGuildChannels.filter((gc) => gc.guildId === guildId);
}, [discordGuildChannels, values]);
const errors = useMemo(() => {
const next: Record<string, string> = {};
for (const param of recipe.params) {
@@ -52,6 +82,92 @@ export function ParamForm({
}, [recipe.params, values]);
const hasError = Object.keys(errors).length > 0;
function renderParam(param: RecipeParam) {
if (param.type === "discord_guild") {
return (
<Select
value={values[param.id] || undefined}
onValueChange={(val) => {
onChange(param.id, val);
setTouched((prev) => ({ ...prev, [param.id]: true }));
// Clear channel selection when guild changes
const channelParam = recipe.params.find((p) => p.type === "discord_channel");
if (channelParam && values[channelParam.id]) {
onChange(channelParam.id, "");
}
}}
>
<SelectTrigger id={param.id} className="w-full">
<SelectValue placeholder="Select a guild" />
</SelectTrigger>
<SelectContent>
{uniqueGuilds.map((g) => (
<SelectItem key={g.id} value={g.id}>
{g.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (param.type === "discord_channel") {
const guildSelected = !!values["guild_id"];
return (
<Select
value={values[param.id] || undefined}
onValueChange={(val) => {
onChange(param.id, val);
setTouched((prev) => ({ ...prev, [param.id]: true }));
}}
disabled={!guildSelected}
>
<SelectTrigger id={param.id} className="w-full">
<SelectValue
placeholder={guildSelected ? "Select a channel" : "Select a guild first"}
/>
</SelectTrigger>
<SelectContent>
{filteredChannels.map((c) => (
<SelectItem key={c.channelId} value={c.channelId}>
{c.channelName}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
if (param.type === "textarea") {
return (
<Textarea
id={param.id}
value={values[param.id] || ""}
placeholder={param.placeholder}
onBlur={() => setTouched((prev) => ({ ...prev, [param.id]: true }))}
onChange={(e) => {
onChange(param.id, e.target.value);
setTouched((prev) => ({ ...prev, [param.id]: true }));
}}
/>
);
}
return (
<Input
id={param.id}
value={values[param.id] || ""}
placeholder={param.placeholder}
required={param.required}
onBlur={() => setTouched((prev) => ({ ...prev, [param.id]: true }))}
onChange={(e) => {
onChange(param.id, e.target.value);
setTouched((prev) => ({ ...prev, [param.id]: true }));
}}
/>
);
}
return (
<form className="space-y-4" onSubmit={(e) => {
e.preventDefault();
@@ -63,30 +179,7 @@ export function ParamForm({
{recipe.params.map((param: RecipeParam) => (
<div key={param.id} className="space-y-1.5">
<Label htmlFor={param.id}>{param.label}</Label>
{param.type === "textarea" ? (
<Textarea
id={param.id}
value={values[param.id] || ""}
placeholder={param.placeholder}
onBlur={() => setTouched((prev) => ({ ...prev, [param.id]: true }))}
onChange={(e) => {
onChange(param.id, e.target.value);
setTouched((prev) => ({ ...prev, [param.id]: true }));
}}
/>
) : (
<Input
id={param.id}
value={values[param.id] || ""}
placeholder={param.placeholder}
required={param.required}
onBlur={() => setTouched((prev) => ({ ...prev, [param.id]: true }))}
onChange={(e) => {
onChange(param.id, e.target.value);
setTouched((prev) => ({ ...prev, [param.id]: true }));
}}
/>
)}
{renderParam(param)}
{touched[param.id] && errors[param.id] ? (
<p className="text-sm text-destructive">{errors[param.id]}</p>
) : null}

View File

@@ -3,7 +3,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
export function RecipeCard({ recipe, onInstall }: { recipe: Recipe; onInstall: (id: string) => void }) {
export function RecipeCard({ recipe, onCook }: { recipe: Recipe; onCook: (id: string) => void }) {
return (
<Card>
<CardHeader>
@@ -21,8 +21,8 @@ export function RecipeCard({ recipe, onInstall }: { recipe: Recipe; onInstall: (
<p className="text-sm text-muted-foreground">Impact: {recipe.impactCategory}</p>
</CardContent>
<CardFooter>
<Button onClick={() => onInstall(recipe.id)}>
Install
<Button onClick={() => onCook(recipe.id)}>
Cook
</Button>
</CardFooter>
</Card>

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { AgentOverview, ApplyResult, BackupInfo, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, Recipe, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile } from "./types";
import type { AgentOverview, ApplyResult, BackupInfo, DiscordGuildChannel, HistoryItem, ModelCatalogProvider, ModelProfile, PreviewResult, Recipe, ResolvedApiKey, StatusLight, SystemStatus, DoctorReport, MemoryFile, SessionFile } from "./types";
export const api = {
getSystemStatus: (): Promise<SystemStatus> =>
@@ -72,4 +72,10 @@ export const api = {
invoke("restore_from_backup", { backupName }),
deleteBackup: (backupName: string): Promise<boolean> =>
invoke("delete_backup", { backupName }),
listDiscordGuildChannels: (): Promise<DiscordGuildChannel[]> =>
invoke("list_discord_guild_channels", {}),
refreshDiscordGuildChannels: (): Promise<DiscordGuildChannel[]> =>
invoke("refresh_discord_guild_channels", {}),
restartGateway: (): Promise<boolean> =>
invoke("restart_gateway", {}),
};

View File

@@ -9,23 +9,15 @@ export const builtinRecipes = [
params: [
{
id: "guild_id",
label: "Guild ID",
type: "string",
label: "Guild",
type: "discord_guild",
required: true,
pattern: "^[0-9]+$",
minLength: 17,
maxLength: 20,
placeholder: "Copy guild id",
},
{
id: "channel_id",
label: "Channel ID",
type: "string",
label: "Channel",
type: "discord_channel",
required: true,
pattern: "^[0-9]+$",
minLength: 17,
maxLength: 20,
placeholder: "Copy channel id",
},
{
id: "persona",

View File

@@ -1,9 +1,16 @@
export type Severity = "low" | "medium" | "high";
export interface DiscordGuildChannel {
guildId: string;
guildName: string;
channelId: string;
channelName: string;
}
export interface RecipeParam {
id: string;
label: string;
type: "string" | "number" | "boolean" | "textarea";
type: "string" | "number" | "boolean" | "textarea" | "discord_guild" | "discord_channel";
required: boolean;
pattern?: string;
minLength?: number;
@@ -34,6 +41,8 @@ export interface ChangeItem {
export interface PreviewResult {
recipeId: string;
diff: string;
configBefore: string;
configAfter: string;
changes: ChangeItem[];
overwritesExisting: boolean;
canRollback: boolean;
@@ -157,6 +166,8 @@ export interface DoctorReport {
export interface AgentOverview {
id: string;
name?: string;
emoji?: string;
model: string | null;
channels: string[];
online: boolean;

65
src/pages/Channels.tsx Normal file
View File

@@ -0,0 +1,65 @@
import type { DiscordGuildChannel } from "../lib/types";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useMemo } from "react";
export function Channels({
discordGuildChannels,
onRefresh,
}: {
discordGuildChannels: DiscordGuildChannel[];
onRefresh: () => void;
}) {
const grouped = useMemo(() => {
const map = new Map<string, { guildName: string; channels: DiscordGuildChannel[] }>();
for (const gc of discordGuildChannels) {
if (!map.has(gc.guildId)) {
map.set(gc.guildId, { guildName: gc.guildName, channels: [] });
}
map.get(gc.guildId)!.channels.push(gc);
}
return Array.from(map.entries());
}, [discordGuildChannels]);
return (
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Channels</h2>
<Button onClick={onRefresh}>
Refresh Discord channels
</Button>
</div>
{grouped.length === 0 ? (
<p className="text-muted-foreground">
No Discord channels cached. Click "Refresh Discord channels" to load.
</p>
) : (
<div className="space-y-4">
{grouped.map(([guildId, { guildName, channels }]) => (
<Card key={guildId}>
<CardContent>
<div className="flex items-center gap-2 mb-3">
<strong className="text-lg">{guildName}</strong>
<Badge variant="secondary">{guildId}</Badge>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-2">
{channels.map((ch) => (
<div
key={ch.channelId}
className="rounded-md border px-3 py-2"
>
<div className="text-sm font-medium">{ch.channelName}</div>
<div className="text-xs text-muted-foreground mt-0.5">{ch.channelId}</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
</section>
);
}

147
src/pages/Cook.tsx Normal file
View File

@@ -0,0 +1,147 @@
import { useEffect, useReducer, useState } from "react";
import { api } from "../lib/api";
import { ParamForm } from "../components/ParamForm";
import { DiffViewer } from "../components/DiffViewer";
import { initialState, reducer } from "../lib/state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import type { DiscordGuildChannel } from "../lib/types";
type ApplyPhase =
| { step: "idle" }
| { step: "applying" }
| { step: "applied"; snapshotId?: string }
| { step: "restarting" }
| { step: "done" }
| { step: "error"; message: string };
export function Cook({
recipeId,
onDone,
recipeSource,
discordGuildChannels,
}: {
recipeId: string;
onDone?: () => void;
recipeSource?: string;
discordGuildChannels: DiscordGuildChannel[];
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const [params, setParams] = useState<Record<string, string>>({});
const [phase, setPhase] = useState<ApplyPhase>({ step: "idle" });
const [isPreviewing, setIsPreviewing] = useState(false);
useEffect(() => {
api.listRecipes(recipeSource).then((recipes) => {
const recipe = recipes.find((it) => it.id === recipeId);
dispatch({ type: "setRecipes", recipes });
if (!recipe) return;
const defaults: Record<string, string> = {};
for (const p of recipe.params) {
defaults[p.id] = "";
}
setParams(defaults);
});
}, [recipeId, recipeSource]);
const recipe = state.recipes.find((r) => r.id === recipeId);
if (!recipe) return <div>Recipe not found</div>;
const handleApply = async () => {
setPhase({ step: "applying" });
try {
const result = await api.applyRecipe(recipe.id, params, recipeSource);
if (!result.ok) {
const errors = result.errors.length ? result.errors.join(", ") : "failed";
setPhase({ step: "error", message: `Apply failed: ${errors}` });
return;
}
setPhase({ step: "applied", snapshotId: result.snapshotId });
setPhase({ step: "restarting" });
try {
await api.restartGateway();
} catch {
// Gateway restart failed — config is still applied, just warn
}
setPhase({ step: "done" });
} catch (err) {
setPhase({ step: "error", message: String(err) });
}
};
const isBusy = phase.step === "applying" || phase.step === "restarting";
return (
<section>
<h2 className="text-2xl font-bold mb-4">
Cook {recipe.name}
</h2>
{phase.step === "done" ? (
<Card>
<CardContent className="py-8 text-center">
<div className="text-2xl mb-2">&#10003;</div>
<p className="text-lg font-medium">Recipe applied successfully</p>
<p className="text-sm text-muted-foreground mt-1">
Gateway has been restarted. The changes are now in effect.
</p>
<Button className="mt-4" onClick={onDone}>
Back to Recipes
</Button>
</CardContent>
</Card>
) : (
<>
<ParamForm
recipe={recipe}
values={params}
onChange={(id, value) => setParams((prev) => ({ ...prev, [id]: value }))}
onSubmit={() => {
setIsPreviewing(true);
api.previewApply(recipe.id, params, recipeSource)
.then((preview) => dispatch({ type: "setPreview", preview }))
.catch((err) => dispatch({ type: "setMessage", message: String(err) }))
.finally(() => setIsPreviewing(false));
}}
discordGuildChannels={discordGuildChannels}
/>
{state.lastPreview && (
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-lg font-semibold mb-2">
Preview
</CardTitle>
</CardHeader>
<CardContent>
<DiffViewer
oldValue={state.lastPreview.configBefore}
newValue={state.lastPreview.configAfter}
/>
<div className="flex items-center gap-3 mt-3">
<Button disabled={isBusy} onClick={handleApply}>
Apply
</Button>
{phase.step === "applying" && (
<span className="text-sm text-muted-foreground">Applying config...</span>
)}
{phase.step === "restarting" && (
<span className="text-sm text-muted-foreground">Restarting gateway...</span>
)}
{phase.step === "error" && (
<span className="text-sm text-destructive">{phase.message}</span>
)}
{isPreviewing && (
<span className="text-sm text-muted-foreground">Previewing...</span>
)}
</div>
</CardContent>
</Card>
)}
<p className="text-sm text-muted-foreground mt-2">{state.message}</p>
</>
)}
</section>
);
}

View File

@@ -74,7 +74,12 @@ export function History() {
</Card>
))}
</div>
{state.lastPreview && <DiffViewer value={state.lastPreview.diff} />}
{state.lastPreview && (
<DiffViewer
oldValue={state.lastPreview.configBefore}
newValue={state.lastPreview.configAfter}
/>
)}
<Button variant="outline" onClick={refreshHistory} className="mt-3">
Refresh
</Button>

View File

@@ -131,13 +131,19 @@ export function Home() {
<Card key={agent.id}>
<CardContent>
<div className="flex justify-between items-center">
<strong>{agent.id}</strong>
<strong>
{agent.emoji && <span className="mr-1">{agent.emoji}</span>}
{agent.name || agent.id}
</strong>
{agent.online ? (
<Badge className="bg-green-100 text-green-700 border-0">online</Badge>
) : (
<Badge className="bg-red-100 text-red-700 border-0">offline</Badge>
)}
</div>
{agent.name && (
<div className="text-xs text-muted-foreground mt-0.5">{agent.id}</div>
)}
<div className="text-sm text-muted-foreground mt-1.5">
Model: {agent.model || "default"}
</div>

View File

@@ -1,103 +0,0 @@
import { useEffect, useReducer, useState } from "react";
import { api } from "../lib/api";
import { ParamForm } from "../components/ParamForm";
import { DiffViewer } from "../components/DiffViewer";
import { initialState, reducer } from "../lib/state";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export function Install({
recipeId,
onDone,
recipeSource,
}: {
recipeId: string;
onDone?: () => void;
recipeSource?: string;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const [params, setParams] = useState<Record<string, string>>({});
const [isApplying, setIsApplying] = useState(false);
const [isPreviewing, setIsPreviewing] = useState(false);
useEffect(() => {
api.listRecipes(recipeSource).then((recipes) => {
const recipe = recipes.find((it) => it.id === recipeId);
dispatch({ type: "setRecipes", recipes });
if (!recipe) return;
const defaults: Record<string, string> = {};
for (const p of recipe.params) {
defaults[p.id] = "";
}
setParams(defaults);
});
}, [recipeId, recipeSource]);
const recipe = state.recipes.find((r) => r.id === recipeId);
if (!recipe) return <div>Recipe not found</div>;
return (
<section>
<h2 className="text-2xl font-bold mb-4">
Install {recipe.name}
</h2>
<ParamForm
recipe={recipe}
values={params}
onChange={(id, value) => setParams((prev) => ({ ...prev, [id]: value }))}
onSubmit={() => {
setIsPreviewing(true);
api.previewApply(recipe.id, params, recipeSource)
.then((preview) => dispatch({ type: "setPreview", preview }))
.catch((err) => dispatch({ type: "setMessage", message: String(err) }))
.finally(() => setIsPreviewing(false));
}}
/>
{state.lastPreview && (
<Card className="mt-4">
<CardHeader>
<CardTitle className="text-lg font-semibold mb-2">
Preview
</CardTitle>
</CardHeader>
<CardContent>
<DiffViewer value={state.lastPreview.diff} />
<div className="flex items-center mt-3">
<Button
disabled={isApplying}
onClick={() => {
setIsApplying(true);
api.applyRecipe(recipe.id, params, recipeSource)
.then((result) => {
if (!result.ok) {
const errors = result.errors.length ? result.errors.join(", ") : "failed";
dispatch({ type: "setMessage", message: `Apply failed: ${errors}` });
return;
}
dispatch({
type: "setMessage",
message: result.snapshotId
? `Applied successfully. Snapshot: ${result.snapshotId}`
: "Applied successfully",
});
if (onDone) {
onDone();
}
})
.catch((err) => dispatch({ type: "setMessage", message: String(err) }))
.finally(() => setIsApplying(false));
}}
>
Apply
</Button>
{isPreviewing ? <span className="text-sm text-muted-foreground ml-2">...previewing</span> : null}
{isApplying ? <span className="text-sm text-muted-foreground ml-2">...applying</span> : null}
</div>
</CardContent>
</Card>
)}
<p className="text-sm text-muted-foreground mt-2">{state.message}</p>
</section>
);
}

View File

@@ -9,9 +9,9 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
export function Recipes({
onInstall,
onCook,
}: {
onInstall: (id: string, source?: string) => void;
onCook: (id: string, source?: string) => void;
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const [source, setSource] = useState("");
@@ -63,7 +63,7 @@ export function Recipes({
<RecipeCard
key={recipe.id}
recipe={recipe}
onInstall={() => onInstall(recipe.id, loadedSource)}
onCook={() => onCook(recipe.id, loadedSource)}
/>
))}
</div>