undercity 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -0
- package/README.md +58 -0
- package/actions/AGENTS.md +41 -0
- package/actions/_shared/container.js +16 -0
- package/actions/auth/ask-login/action.json +15 -0
- package/actions/auth/ask-signup/action.json +14 -0
- package/actions/display/clear/action.json +18 -0
- package/actions/display/clear/action.test.js +32 -0
- package/actions/display/markdown/action.json +12 -0
- package/actions/display/markdown/action.test.js +32 -0
- package/actions/display/rawHtml/action.json +24 -0
- package/actions/display/rawHtml/action.test.js +32 -0
- package/actions/display/safeHtml/action.json +24 -0
- package/actions/display/safeHtml/action.test.js +32 -0
- package/actions/display/text/action.js +9 -0
- package/actions/display/text/action.json +12 -0
- package/actions/display/text/action.test.js +40 -0
- package/actions/display/value/action.json +24 -0
- package/actions/display/value/action.test.js +32 -0
- package/actions/dom/addClass/action.json +23 -0
- package/actions/dom/addClass/action.test.js +32 -0
- package/actions/dom/focus/action.json +17 -0
- package/actions/dom/focus/action.test.js +32 -0
- package/actions/dom/hide/action.json +18 -0
- package/actions/dom/hide/action.test.js +32 -0
- package/actions/dom/removeClass/action.json +22 -0
- package/actions/dom/removeClass/action.test.js +32 -0
- package/actions/dom/scroll/action.json +29 -0
- package/actions/dom/scroll/action.test.js +32 -0
- package/actions/dom/setAttr/action.json +29 -0
- package/actions/dom/setAttr/action.test.js +32 -0
- package/actions/dom/setHtml/action.json +22 -0
- package/actions/dom/setHtml/action.test.js +32 -0
- package/actions/dom/setStyle/action.json +29 -0
- package/actions/dom/setStyle/action.test.js +32 -0
- package/actions/dom/setText/action.json +24 -0
- package/actions/dom/setText/action.test.js +32 -0
- package/actions/dom/show/action.json +18 -0
- package/actions/dom/show/action.test.js +32 -0
- package/actions/dom/toggle/action.json +17 -0
- package/actions/dom/toggle/action.test.js +32 -0
- package/actions/dom/toggleClass/action.json +22 -0
- package/actions/dom/toggleClass/action.test.js +32 -0
- package/actions/event/emit/action.json +24 -0
- package/actions/event/emit/action.test.js +32 -0
- package/actions/event/on/action.json +23 -0
- package/actions/event/on/action.test.js +32 -0
- package/actions/event/waitFor/action.json +28 -0
- package/actions/event/waitFor/action.test.js +32 -0
- package/actions/forms/bindField/action.js +27 -0
- package/actions/forms/bindField/action.json +24 -0
- package/actions/forms/bindField/action.test.js +20 -0
- package/actions/forms/check/action.json +22 -0
- package/actions/forms/check/action.test.js +32 -0
- package/actions/forms/clearErrors/action.json +11 -0
- package/actions/forms/clearErrors/action.test.js +35 -0
- package/actions/forms/clearField/action.json +17 -0
- package/actions/forms/clearField/action.test.js +32 -0
- package/actions/forms/getField/action.json +24 -0
- package/actions/forms/getField/action.test.js +32 -0
- package/actions/forms/getRange/action.json +22 -0
- package/actions/forms/getRange/action.test.js +32 -0
- package/actions/forms/getSelect/action.json +22 -0
- package/actions/forms/getSelect/action.test.js +32 -0
- package/actions/forms/index.js +140 -0
- package/actions/forms/serialize/action.json +24 -0
- package/actions/forms/serialize/action.test.js +32 -0
- package/actions/forms/setCheck/action.json +23 -0
- package/actions/forms/setCheck/action.test.js +32 -0
- package/actions/forms/setError/action.json +22 -0
- package/actions/forms/setError/action.test.js +32 -0
- package/actions/forms/setField/action.js +14 -0
- package/actions/forms/setField/action.json +24 -0
- package/actions/forms/setField/action.test.js +32 -0
- package/actions/forms/submit/action.json +18 -0
- package/actions/forms/submit/action.test.js +32 -0
- package/actions/forms/validate/action.json +24 -0
- package/actions/forms/validate/action.test.js +32 -0
- package/actions/http/delete/action.json +22 -0
- package/actions/http/delete/action.test.js +32 -0
- package/actions/http/get/action.json +24 -0
- package/actions/http/get/action.test.js +32 -0
- package/actions/http/post/action.json +30 -0
- package/actions/http/post/action.test.js +32 -0
- package/actions/http/put/action.json +27 -0
- package/actions/http/put/action.test.js +32 -0
- package/actions/http/upload/action.json +35 -0
- package/actions/http/upload/action.test.js +32 -0
- package/actions/index.js +306 -0
- package/actions/index.json +5 -0
- package/actions/input/askChoice/action.json +29 -0
- package/actions/input/askChoice/action.test.js +32 -0
- package/actions/input/askConfirm/action.json +24 -0
- package/actions/input/askConfirm/action.test.js +32 -0
- package/actions/input/askDate/action.json +24 -0
- package/actions/input/askDate/action.test.js +32 -0
- package/actions/input/askEmail/action.json +24 -0
- package/actions/input/askEmail/action.test.js +32 -0
- package/actions/input/askNumber/action.json +35 -0
- package/actions/input/askNumber/action.test.js +32 -0
- package/actions/input/askPassword/action.json +24 -0
- package/actions/input/askPassword/action.test.js +32 -0
- package/actions/input/askText/action.json +36 -0
- package/actions/input/askText/action.test.js +32 -0
- package/actions/logic/delay/action.json +18 -0
- package/actions/logic/delay/action.test.js +32 -0
- package/actions/logic/if/action.json +28 -0
- package/actions/logic/if/action.test.js +32 -0
- package/actions/logic/log/action.json +18 -0
- package/actions/logic/log/action.test.js +32 -0
- package/actions/logic/random/action.json +36 -0
- package/actions/logic/random/action.test.js +32 -0
- package/actions/logic/transform/action.json +24 -0
- package/actions/logic/transform/action.test.js +32 -0
- package/actions/media/askAudioUpload/action.json +30 -0
- package/actions/media/askAudioUpload/action.test.js +32 -0
- package/actions/media/askFileUpload/action.json +36 -0
- package/actions/media/askFileUpload/action.test.js +32 -0
- package/actions/media/askImageUpload/action.json +37 -0
- package/actions/media/askImageUpload/action.test.js +32 -0
- package/actions/media/askVideoUpload/action.js +74 -0
- package/actions/media/askVideoUpload/action.json +44 -0
- package/actions/media/askVideoUpload/action.test.js +51 -0
- package/actions/media/captureWebcam/action.json +24 -0
- package/actions/media/captureWebcam/action.test.js +32 -0
- package/actions/nav/back/action.json +11 -0
- package/actions/nav/back/action.test.js +35 -0
- package/actions/nav/goto/action.json +18 -0
- package/actions/nav/goto/action.test.js +32 -0
- package/actions/nav/redirect/action.json +28 -0
- package/actions/nav/redirect/action.test.js +32 -0
- package/actions/nav/reload/action.json +11 -0
- package/actions/nav/reload/action.test.js +35 -0
- package/actions/nav/reset/action.json +11 -0
- package/actions/nav/reset/action.test.js +35 -0
- package/actions/render/alert/action.js +12 -0
- package/actions/render/alert/action.json +36 -0
- package/actions/render/alert/action.test.js +32 -0
- package/actions/render/button/action.js +15 -0
- package/actions/render/button/action.json +44 -0
- package/actions/render/button/action.test.js +37 -0
- package/actions/render/clear/action.js +7 -0
- package/actions/render/clear/action.json +11 -0
- package/actions/render/clear/action.test.js +35 -0
- package/actions/render/divider/action.js +8 -0
- package/actions/render/divider/action.json +11 -0
- package/actions/render/divider/action.test.js +35 -0
- package/actions/render/field/action.js +17 -0
- package/actions/render/field/action.json +59 -0
- package/actions/render/field/action.test.js +57 -0
- package/actions/render/link/action.js +20 -0
- package/actions/render/link/action.json +30 -0
- package/actions/render/link/action.test.js +32 -0
- package/actions/render/markdown/action.json +18 -0
- package/actions/render/markdown/action.test.js +32 -0
- package/actions/render/paragraph/action.js +9 -0
- package/actions/render/paragraph/action.json +32 -0
- package/actions/render/paragraph/action.test.js +32 -0
- package/actions/render/section/action.js +10 -0
- package/actions/render/section/action.json +18 -0
- package/actions/render/section/action.test.js +32 -0
- package/actions/render/subtitle/action.js +9 -0
- package/actions/render/subtitle/action.json +18 -0
- package/actions/render/subtitle/action.test.js +32 -0
- package/actions/render/title/action.js +9 -0
- package/actions/render/title/action.json +30 -0
- package/actions/render/title/action.test.js +44 -0
- package/actions/session/clear/action.json +11 -0
- package/actions/session/clear/action.test.js +35 -0
- package/actions/session/load/action.json +22 -0
- package/actions/session/load/action.test.js +32 -0
- package/actions/session/local/action.json +22 -0
- package/actions/session/local/action.test.js +32 -0
- package/actions/session/save/action.json +22 -0
- package/actions/session/save/action.test.js +32 -0
- package/actions/ui/accordion/action.json +23 -0
- package/actions/ui/accordion/action.test.js +32 -0
- package/actions/ui/badge/action.json +22 -0
- package/actions/ui/badge/action.test.js +32 -0
- package/actions/ui/collapse/action.json +23 -0
- package/actions/ui/collapse/action.test.js +32 -0
- package/actions/ui/hideModal/action.json +17 -0
- package/actions/ui/hideModal/action.test.js +32 -0
- package/actions/ui/loading/action.json +18 -0
- package/actions/ui/loading/action.test.js +32 -0
- package/actions/ui/modal/action.json +18 -0
- package/actions/ui/modal/action.test.js +32 -0
- package/actions/ui/progress/action.json +24 -0
- package/actions/ui/progress/action.test.js +32 -0
- package/actions/ui/toast/action.json +29 -0
- package/actions/ui/toast/action.test.js +32 -0
- package/actions/ui/tooltip/action.json +17 -0
- package/actions/ui/tooltip/action.test.js +32 -0
- package/actions/user/carry/action.json +25 -0
- package/actions/user/carry/action.test.js +32 -0
- package/actions/user/check/action.json +24 -0
- package/actions/user/check/action.test.js +32 -0
- package/actions/user/clear/action.json +11 -0
- package/actions/user/clear/action.test.js +35 -0
- package/actions/user/delete/action.json +17 -0
- package/actions/user/delete/action.test.js +32 -0
- package/actions/user/dump/action.json +11 -0
- package/actions/user/dump/action.test.js +35 -0
- package/actions/user/get/action.json +24 -0
- package/actions/user/get/action.test.js +32 -0
- package/actions/user/merge/action.json +18 -0
- package/actions/user/merge/action.test.js +32 -0
- package/actions/user/set/action.json +24 -0
- package/actions/user/set/action.test.js +32 -0
- package/generator/base/css/bootstrap.min.css +6 -0
- package/generator/base/icons/app-indicator.svg +4 -0
- package/generator/base/icons/backpack.svg +4 -0
- package/generator/base/icons/broadcast.svg +3 -0
- package/generator/base/icons/bullseye.svg +6 -0
- package/generator/base/icons/chat-dots.svg +4 -0
- package/generator/base/icons/check-circle.svg +4 -0
- package/generator/base/icons/clipboard-check.svg +5 -0
- package/generator/base/icons/clipboard.svg +4 -0
- package/generator/base/icons/copy.svg +3 -0
- package/generator/base/icons/cursor.svg +3 -0
- package/generator/base/icons/diamond.svg +3 -0
- package/generator/base/icons/exclamation-triangle.svg +4 -0
- package/generator/base/icons/film.svg +3 -0
- package/generator/base/icons/floppy.svg +4 -0
- package/generator/base/icons/gear-wide-connected.svg +3 -0
- package/generator/base/icons/gear.svg +4 -0
- package/generator/base/icons/globe.svg +3 -0
- package/generator/base/icons/image.svg +4 -0
- package/generator/base/icons/layout-text-window.svg +4 -0
- package/generator/base/icons/lightning-charge.svg +3 -0
- package/generator/base/icons/magic.svg +3 -0
- package/generator/base/icons/pencil-square.svg +4 -0
- package/generator/base/icons/record-circle.svg +4 -0
- package/generator/base/icons/robot.svg +4 -0
- package/generator/base/icons/shield-check.svg +4 -0
- package/generator/base/icons/shield-lock.svg +4 -0
- package/generator/base/icons/signpost.svg +3 -0
- package/generator/base/icons/stars.svg +3 -0
- package/generator/base/icons/type.svg +3 -0
- package/generator/base/js/bootstrap.bundle.min.js +7 -0
- package/package.json +14 -0
- package/packages/undercity-http-server/index.js +249 -0
- package/packages/undercity-http-server/package.json +10 -0
- package/packages/undercity-parser/index.js +323 -0
- package/packages/undercity-parser/lexer.js +128 -0
- package/packages/undercity-parser/package.json +11 -0
- package/plugins/forms.js +397 -0
- package/plugins/index.js +83 -0
- package/plugins/multipage.js +165 -0
- package/plugins/wizard.js +239 -0
- package/projects/asd/project.json +1031 -0
- package/projects/test-1/project.json +335 -0
- package/projects/test-a/project.json +456 -0
- package/public/icons/arrows-angle-expand.svg +3 -0
- package/public/icons/arrows-fullscreen.svg +3 -0
- package/public/icons/bezier2.svg +3 -0
- package/public/icons/bootstrap/app-indicator.svg +4 -0
- package/public/icons/bootstrap/arrow-clockwise.svg +4 -0
- package/public/icons/bootstrap/arrow-counterclockwise.svg +4 -0
- package/public/icons/bootstrap/arrow-left.svg +3 -0
- package/public/icons/bootstrap/arrows-angle-expand.svg +3 -0
- package/public/icons/bootstrap/arrows-fullscreen.svg +3 -0
- package/public/icons/bootstrap/backpack.svg +4 -0
- package/public/icons/bootstrap/bezier2.svg +3 -0
- package/public/icons/bootstrap/bookmark-check.svg +4 -0
- package/public/icons/bootstrap/bookmark-plus.svg +4 -0
- package/public/icons/bootstrap/box-arrow-right.svg +4 -0
- package/public/icons/bootstrap/box-arrow-up.svg +4 -0
- package/public/icons/bootstrap/broadcast.svg +3 -0
- package/public/icons/bootstrap/bullseye.svg +6 -0
- package/public/icons/bootstrap/chat-dots.svg +4 -0
- package/public/icons/bootstrap/check-circle.svg +4 -0
- package/public/icons/bootstrap/check2.svg +3 -0
- package/public/icons/bootstrap/clipboard-check.svg +5 -0
- package/public/icons/bootstrap/clipboard.svg +4 -0
- package/public/icons/bootstrap/clock-history.svg +5 -0
- package/public/icons/bootstrap/command.svg +3 -0
- package/public/icons/bootstrap/copy.svg +3 -0
- package/public/icons/bootstrap/cursor.svg +3 -0
- package/public/icons/bootstrap/diagram-3.svg +3 -0
- package/public/icons/bootstrap/diamond.svg +3 -0
- package/public/icons/bootstrap/exclamation-triangle.svg +4 -0
- package/public/icons/bootstrap/eye.svg +4 -0
- package/public/icons/bootstrap/file-earmark-plus.svg +4 -0
- package/public/icons/bootstrap/film.svg +3 -0
- package/public/icons/bootstrap/floppy.svg +4 -0
- package/public/icons/bootstrap/folder2-open.svg +3 -0
- package/public/icons/bootstrap/gear-wide-connected.svg +3 -0
- package/public/icons/bootstrap/gear.svg +4 -0
- package/public/icons/bootstrap/globe.svg +3 -0
- package/public/icons/bootstrap/grid-3x3-gap.svg +3 -0
- package/public/icons/bootstrap/house-door.svg +3 -0
- package/public/icons/bootstrap/image.svg +4 -0
- package/public/icons/bootstrap/layout-text-window.svg +4 -0
- package/public/icons/bootstrap/lightning-charge.svg +3 -0
- package/public/icons/bootstrap/magic.svg +3 -0
- package/public/icons/bootstrap/pencil-square.svg +4 -0
- package/public/icons/bootstrap/pencil.svg +3 -0
- package/public/icons/bootstrap/play.svg +3 -0
- package/public/icons/bootstrap/plus-circle.svg +4 -0
- package/public/icons/bootstrap/plus-lg.svg +3 -0
- package/public/icons/bootstrap/record-circle.svg +4 -0
- package/public/icons/bootstrap/robot.svg +4 -0
- package/public/icons/bootstrap/save.svg +3 -0
- package/public/icons/bootstrap/scissors.svg +3 -0
- package/public/icons/bootstrap/shield-check.svg +4 -0
- package/public/icons/bootstrap/shield-lock.svg +4 -0
- package/public/icons/bootstrap/signpost.svg +3 -0
- package/public/icons/bootstrap/stars.svg +3 -0
- package/public/icons/bootstrap/stop-circle.svg +4 -0
- package/public/icons/bootstrap/terminal.svg +4 -0
- package/public/icons/bootstrap/trash3.svg +3 -0
- package/public/icons/bootstrap/type.svg +3 -0
- package/public/icons/bootstrap/x-circle.svg +4 -0
- package/public/icons/bootstrap/x-lg.svg +3 -0
- package/public/icons/bootstrap/zoom-in.svg +5 -0
- package/public/icons/bootstrap/zoom-out.svg +5 -0
- package/public/icons/bullseye.svg +6 -0
- package/public/icons/check2.svg +3 -0
- package/public/icons/cursor.svg +3 -0
- package/public/icons/diamond.svg +3 -0
- package/public/icons/eye.svg +4 -0
- package/public/icons/file-earmark-plus.svg +4 -0
- package/public/icons/floppy.svg +4 -0
- package/public/icons/gear.svg +4 -0
- package/public/icons/lightning-charge.svg +3 -0
- package/public/icons/pencil.svg +3 -0
- package/public/icons/play.svg +3 -0
- package/public/icons/plus-circle.svg +4 -0
- package/public/icons/record-circle.svg +4 -0
- package/public/icons/robot.svg +4 -0
- package/public/icons/save.svg +3 -0
- package/public/icons/stop-circle.svg +4 -0
- package/public/icons/terminal.svg +4 -0
- package/public/icons/trash3.svg +3 -0
- package/public/icons/x-circle.svg +4 -0
- package/public/icons/zoom-in.svg +5 -0
- package/public/icons/zoom-out.svg +5 -0
- package/public/index.html +424 -0
- package/public/testbench.html +899 -0
- package/scripts/extract-actions.js +128 -0
- package/server.js +11 -0
- package/src/emitter.js +48 -0
- package/src/generator/css.js +135 -0
- package/src/generator/index.js +122 -0
- package/src/generator/md-renderer-src.js +77 -0
- package/src/generator/page.js +300 -0
- package/src/generator/runtime.js +1632 -0
- package/src/generator/templates.js +508 -0
- package/src/ide/action-library.js +856 -0
- package/src/ide/af-icons.js +127 -0
- package/src/ide/app.js +1375 -0
- package/src/ide/command-line/commands.js +242 -0
- package/src/ide/command-line/index.js +329 -0
- package/src/ide/command-line/parser.js +21 -0
- package/src/ide/css/ide.css +1501 -0
- package/src/ide/graph.js +282 -0
- package/src/ide/history.js +46 -0
- package/src/ide/map-builder.js +583 -0
- package/src/ide/project-api.js +39 -0
- package/src/ide/savant-chat.js +513 -0
- package/src/ide/savant.js +1287 -0
- package/src/ide/thing-library.js +89 -0
- package/src/ide/undercity-map.js +978 -0
- package/src/lib/icons.js +72 -0
- package/src/lib/scope.js +88 -0
- package/src/lib/signal.js +155 -0
- package/src/lib/state-machine.js +113 -0
- package/src/server/index.js +96 -0
- package/src/server/routes/actions.js +144 -0
- package/src/server/routes/ai.js +176 -0
- package/src/server/routes/generate.js +54 -0
- package/src/server/routes/projects.js +106 -0
- package/src/server/routes/reset.js +30 -0
- package/src/server/routes/submit.js +30 -0
- package/src/server/routes/templates.js +139 -0
- package/src/server/routes/things.js +33 -0
- package/templates/auth-flow.json +335 -0
- package/templates/blank.json +39 -0
- package/things/auth-server/thing.json +17 -0
- package/things/persona-live/thing.json +20 -0
- package/things/workflow/thing.json +15 -0
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* savant.js — Bottom-pane Savant UI.
|
|
3
|
+
*
|
|
4
|
+
* Layout: [Event Tabs] | Categories | Actions | Workflow
|
|
5
|
+
*
|
|
6
|
+
* Workflow shows the step list for the selected node's current event.
|
|
7
|
+
* Clicking an action card appends it to the workflow.
|
|
8
|
+
* Steps support inline param editing and drag-to-reorder.
|
|
9
|
+
*
|
|
10
|
+
* ACTION_LIBRARY starts empty. Categories are registered exclusively via
|
|
11
|
+
* App.use(plugin) → app.registerActions(catId, def) → savant.registerCategory().
|
|
12
|
+
* If actions/index.js has nothing registered, no categories appear.
|
|
13
|
+
*
|
|
14
|
+
* The AI section at the bottom of categories lets the user describe
|
|
15
|
+
* a new action in plain language — the server calls localhost:8191 and
|
|
16
|
+
* returns a definition placed in the correct category by ID prefix,
|
|
17
|
+
* marked with a ✦ badge so the user knows it is AI-generated.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Signal, Emitter } from '/src/lib/signal.js';
|
|
21
|
+
import { Scope } from '/src/lib/scope.js';
|
|
22
|
+
import { SavantChat } from '/src/ide/savant-chat.js';
|
|
23
|
+
|
|
24
|
+
function escH(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
25
|
+
import { renderAfIcon } from '/src/lib/icons.js';
|
|
26
|
+
|
|
27
|
+
/** Copy MCP JSON commands to clipboard and show a transient toast. */
|
|
28
|
+
function _copyMcpJson(cmds) {
|
|
29
|
+
const json = JSON.stringify(cmds, null, 2);
|
|
30
|
+
navigator.clipboard.writeText(json).then(() => {
|
|
31
|
+
const t = document.createElement('div');
|
|
32
|
+
t.textContent = `Copied ${cmds.length} command${cmds.length !== 1 ? 's' : ''} to clipboard`;
|
|
33
|
+
t.style.cssText = 'position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--sol-cyan,#2aa198);color:#002b36;padding:6px 16px;border-radius:6px;font-size:12px;z-index:9999;pointer-events:none';
|
|
34
|
+
document.body.appendChild(t);
|
|
35
|
+
setTimeout(() => t.remove(), 2200);
|
|
36
|
+
}).catch(() => {
|
|
37
|
+
// Fallback: open in a modal-style textarea
|
|
38
|
+
const win = window.open('', '_blank', 'width=640,height=480');
|
|
39
|
+
if (win) { win.document.write(`<pre style="font:13px monospace;padding:16px">${json.replace(/</g,'<')}</pre>`); }
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
import { THING_LIBRARY, getThingEvents } from '/src/ide/thing-library.js';
|
|
43
|
+
import { API } from '/src/ide/project-api.js';
|
|
44
|
+
|
|
45
|
+
// ── Action Library (populated exclusively via registerCategory) ───────────────
|
|
46
|
+
// No static import from action-library.js. All categories arrive via App.use().
|
|
47
|
+
const ACTION_LIBRARY = {};
|
|
48
|
+
|
|
49
|
+
function findAction(actionId) {
|
|
50
|
+
for (const cat of Object.values(ACTION_LIBRARY)) {
|
|
51
|
+
if (cat.actions?.[actionId]) return cat.actions[actionId];
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Savant ───────────────────────────────────────────────────────────────────
|
|
57
|
+
export class Savant extends Emitter {
|
|
58
|
+
#scope = new Scope();
|
|
59
|
+
#nodeScope = new Scope(); // reset when node changes
|
|
60
|
+
#node = null;
|
|
61
|
+
#thingCtx = null; // { thingDef, parentNode } — set when editing a Thing
|
|
62
|
+
#event = 'onEnter';
|
|
63
|
+
#category = null;
|
|
64
|
+
#customActions = {}; // id → def (AI-generated or project-level)
|
|
65
|
+
#chat = null;
|
|
66
|
+
_projectId = '';
|
|
67
|
+
// Step UI mode stored in memory, never persisted to project.json
|
|
68
|
+
// Key format: "${nodeId}:${event}:${stepIndex}"
|
|
69
|
+
#stepModes = new Map();
|
|
70
|
+
#collapsedSteps = new Set();
|
|
71
|
+
|
|
72
|
+
// DOM refs
|
|
73
|
+
#catPane; #actPane; #actList; #actPreview; #wfPane; #wfTitle; #wfSteps;
|
|
74
|
+
#eventTabs; #breadcrumb;
|
|
75
|
+
#aiInput; #aiBtn;
|
|
76
|
+
// Currently previewed action
|
|
77
|
+
#previewedAction = null; // { actionId, def }
|
|
78
|
+
|
|
79
|
+
constructor(containerEl, { customActions = {} } = {}) {
|
|
80
|
+
super();
|
|
81
|
+
this.#catPane = containerEl.querySelector('#cat-pane');
|
|
82
|
+
this.#actPane = containerEl.querySelector('#act-pane');
|
|
83
|
+
this.#actList = containerEl.querySelector('#act-list');
|
|
84
|
+
this.#actPreview = containerEl.querySelector('#act-preview');
|
|
85
|
+
this.#wfPane = containerEl.querySelector('#wf-pane');
|
|
86
|
+
this.#wfTitle = containerEl.querySelector('#wf-title');
|
|
87
|
+
this.#wfSteps = containerEl.querySelector('#wf-steps');
|
|
88
|
+
this.#eventTabs = containerEl.querySelector('#event-tabs');
|
|
89
|
+
this.#breadcrumb = containerEl.querySelector('#savant-breadcrumb');
|
|
90
|
+
this.#customActions = customActions;
|
|
91
|
+
|
|
92
|
+
// Wire workflow help toggle
|
|
93
|
+
const wfHelpBtn = document.getElementById('wf-help-btn');
|
|
94
|
+
const wfHelpPanel = document.getElementById('wf-help-panel');
|
|
95
|
+
wfHelpBtn?.addEventListener('click', () => {
|
|
96
|
+
const isHidden = wfHelpPanel.hidden;
|
|
97
|
+
wfHelpPanel.hidden = !isHidden;
|
|
98
|
+
wfHelpBtn.setAttribute('aria-expanded', String(isHidden));
|
|
99
|
+
wfHelpBtn.classList.toggle('active', isHidden);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Wire workspace export button — exports current node's workflow as MCP addStep JSON
|
|
103
|
+
document.getElementById('wf-workspace-btn')?.addEventListener('click', () => {
|
|
104
|
+
if (!this.#node) { return; }
|
|
105
|
+
const nodeName = this.#node.label?.value ?? this.#node.label ?? 'Room';
|
|
106
|
+
const payload = this.#node.payload?.peek() ?? {};
|
|
107
|
+
const eventKeys = Object.keys(payload).filter(k => Array.isArray(payload[k]) && payload[k].length);
|
|
108
|
+
if (!eventKeys.length) { return; }
|
|
109
|
+
const cmds = eventKeys.flatMap(event =>
|
|
110
|
+
payload[event].map(step => ({ cmd: 'addStep', node: nodeName, event, step }))
|
|
111
|
+
);
|
|
112
|
+
_copyMcpJson(cmds);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Wire preview "Add" button
|
|
116
|
+
containerEl.querySelector('#act-preview-add')?.addEventListener('click', () => {
|
|
117
|
+
if (this.#previewedAction) {
|
|
118
|
+
this.#addStep(this.#previewedAction.actionId, this.#previewedAction.def);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
this.#buildEventTabs();
|
|
123
|
+
this.#buildCategories();
|
|
124
|
+
this.#selectCategory(Object.keys(ACTION_LIBRARY)[0]);
|
|
125
|
+
this.#renderWorkflow();
|
|
126
|
+
|
|
127
|
+
const chatEl = containerEl.querySelector('#chat-pane');
|
|
128
|
+
if (chatEl) {
|
|
129
|
+
this.#chat = new SavantChat(chatEl);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
134
|
+
setNode(node) {
|
|
135
|
+
this.#nodeScope.dispose();
|
|
136
|
+
this.#thingCtx = null;
|
|
137
|
+
this.#node = node;
|
|
138
|
+
this.#event = 'onEnter';
|
|
139
|
+
this.#updateBreadcrumb();
|
|
140
|
+
if (node) {
|
|
141
|
+
this.#nodeScope.add(
|
|
142
|
+
node.label.subscribe(() => this.#updateBreadcrumb(), false)
|
|
143
|
+
);
|
|
144
|
+
this.#nodeScope.add(
|
|
145
|
+
node.payload.subscribe(() => {
|
|
146
|
+
this.#buildEventTabs();
|
|
147
|
+
this.#renderWorkflow();
|
|
148
|
+
}, false)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
this.#buildEventTabs();
|
|
152
|
+
this.#renderWorkflow();
|
|
153
|
+
this.#updateChatContext();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
setEvent(event) {
|
|
157
|
+
this.#event = event;
|
|
158
|
+
this.#updateEventTabsUI();
|
|
159
|
+
this.#renderWorkflow();
|
|
160
|
+
this.#updateChatContext();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Switch the Savant into Thing-editing mode.
|
|
165
|
+
* thingDef = { id, type, config, events } (plain object from node.things)
|
|
166
|
+
* parentNode = the GraphNode that owns this thing
|
|
167
|
+
*/
|
|
168
|
+
setThing(thingDef, parentNode) {
|
|
169
|
+
this.#thingCtx = { thingDef, parentNode };
|
|
170
|
+
this.#nodeScope.dispose();
|
|
171
|
+
this.#node = this.#makeThingProxy(thingDef, parentNode);
|
|
172
|
+
this.#event = 'onEnter';
|
|
173
|
+
this.#updateBreadcrumb();
|
|
174
|
+
// Push: re-render whenever the thing's payload signal changes
|
|
175
|
+
this.#nodeScope.add(
|
|
176
|
+
this.#node.payload.subscribe(() => {
|
|
177
|
+
this.#buildEventTabs();
|
|
178
|
+
this.#renderWorkflow();
|
|
179
|
+
}, false)
|
|
180
|
+
);
|
|
181
|
+
this.#buildEventTabs();
|
|
182
|
+
this.#renderWorkflow();
|
|
183
|
+
this.#updateChatContext();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#updateBreadcrumb() {
|
|
187
|
+
if (!this.#breadcrumb) return;
|
|
188
|
+
if (!this.#node) {
|
|
189
|
+
this.#breadcrumb.innerHTML = '<span class="bc-item bc-idle">No node selected</span>';
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (this.#node._isThing && this.#thingCtx) {
|
|
193
|
+
const { thingDef, parentNode } = this.#thingCtx;
|
|
194
|
+
const roomLabel = parentNode.label?.value ?? parentNode.label ?? 'Room';
|
|
195
|
+
const thingLabel = THING_LIBRARY[thingDef.type]?.label ?? thingDef.type;
|
|
196
|
+
this.#breadcrumb.innerHTML =
|
|
197
|
+
`<span class="bc-item bc-room">${escH(roomLabel)}</span>` +
|
|
198
|
+
`<span class="bc-sep">/</span>` +
|
|
199
|
+
`<span class="bc-item bc-thing">${escH(thingLabel)}</span>`;
|
|
200
|
+
} else {
|
|
201
|
+
const label = this.#node.label?.value ?? this.#node.label ?? 'Room';
|
|
202
|
+
const type = this.#node.type ?? '';
|
|
203
|
+
this.#breadcrumb.innerHTML =
|
|
204
|
+
`<span class="bc-item bc-room">${escH(label)}</span>` +
|
|
205
|
+
`<span class="bc-type">${type}</span>`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Wrap a thingDef so it looks like a GraphNode to the Savant. */
|
|
210
|
+
#makeThingProxy(thingDef, parentNode) {
|
|
211
|
+
// Build a payload Signal from the thing's events object
|
|
212
|
+
const payloadSig = new Signal({ ...thingDef.events });
|
|
213
|
+
// Build event tabs from the thing type's defaultEvents + any custom keys
|
|
214
|
+
const lib = THING_LIBRARY[thingDef.type] ?? {};
|
|
215
|
+
const canAdd = lib.canAddEvents ?? false;
|
|
216
|
+
|
|
217
|
+
const proxy = {
|
|
218
|
+
id: thingDef.id,
|
|
219
|
+
type: 'room', // so workflow renders normally
|
|
220
|
+
label: { value: thingDef.id, subscribe: (_cb, _i) => () => {} },
|
|
221
|
+
payload: payloadSig,
|
|
222
|
+
routes: { peek: () => [] },
|
|
223
|
+
things: { peek: () => [] },
|
|
224
|
+
_isThing: true,
|
|
225
|
+
_canAddEvents: canAdd,
|
|
226
|
+
_thingType: thingDef.type,
|
|
227
|
+
|
|
228
|
+
// GraphNode-compatible step mutation methods
|
|
229
|
+
addStep(event, step) {
|
|
230
|
+
const p = { ...payloadSig.peek() };
|
|
231
|
+
p[event] = [...(p[event] ?? []), step];
|
|
232
|
+
payloadSig.value = p;
|
|
233
|
+
thingDef.events = p;
|
|
234
|
+
parentNode.updateThing(thingDef.id, { events: p });
|
|
235
|
+
},
|
|
236
|
+
insertStep(event, index, step) {
|
|
237
|
+
const p = { ...payloadSig.peek() };
|
|
238
|
+
const steps = [...(p[event] ?? [])];
|
|
239
|
+
steps.splice(index, 0, step);
|
|
240
|
+
p[event] = steps;
|
|
241
|
+
payloadSig.value = p;
|
|
242
|
+
thingDef.events = p;
|
|
243
|
+
parentNode.updateThing(thingDef.id, { events: p });
|
|
244
|
+
},
|
|
245
|
+
removeStep(event, index) {
|
|
246
|
+
const p = { ...payloadSig.peek() };
|
|
247
|
+
p[event] = (p[event] ?? []).filter((_, i) => i !== index);
|
|
248
|
+
payloadSig.value = p;
|
|
249
|
+
thingDef.events = p;
|
|
250
|
+
parentNode.updateThing(thingDef.id, { events: p });
|
|
251
|
+
},
|
|
252
|
+
updateStep(event, index, step) {
|
|
253
|
+
const p = { ...payloadSig.peek() };
|
|
254
|
+
p[event] = (p[event] ?? []).map((s, i) => i === index ? { ...s, ...step } : s);
|
|
255
|
+
payloadSig.value = p;
|
|
256
|
+
thingDef.events = p;
|
|
257
|
+
parentNode.updateThing(thingDef.id, { events: p });
|
|
258
|
+
},
|
|
259
|
+
moveStep(event, from, to) {
|
|
260
|
+
const p = { ...payloadSig.peek() };
|
|
261
|
+
const steps = [...(p[event] ?? [])];
|
|
262
|
+
const [item] = steps.splice(from, 1);
|
|
263
|
+
steps.splice(to, 0, item);
|
|
264
|
+
p[event] = steps;
|
|
265
|
+
payloadSig.value = p;
|
|
266
|
+
thingDef.events = p;
|
|
267
|
+
parentNode.updateThing(thingDef.id, { events: p });
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
return proxy;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
addCustomAction(id, def) {
|
|
275
|
+
this.#customActions[id] = def;
|
|
276
|
+
// Route to the correct category by ID prefix (e.g. "room.myAction" → room category).
|
|
277
|
+
// Falls back to the first registered category if no prefix match.
|
|
278
|
+
const prefixCat = id.includes('.') ? id.split('.')[0] : null;
|
|
279
|
+
const targetCat = (prefixCat && ACTION_LIBRARY[prefixCat])
|
|
280
|
+
? prefixCat
|
|
281
|
+
: Object.keys(ACTION_LIBRARY)[0];
|
|
282
|
+
if (targetCat && ACTION_LIBRARY[targetCat]) {
|
|
283
|
+
ACTION_LIBRARY[targetCat].actions[id] = { ...def, _aiGenerated: true };
|
|
284
|
+
if (this.#category === targetCat) this.#renderActions(targetCat);
|
|
285
|
+
}
|
|
286
|
+
this.emit('customActionsChanged', this.#customActions);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
getCustomActions() { return { ...this.#customActions }; }
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Register (or replace) an entire action category.
|
|
293
|
+
* Called by category plugins installed via App.use().
|
|
294
|
+
*/
|
|
295
|
+
registerCategory(catId, def) {
|
|
296
|
+
ACTION_LIBRARY[catId] = { ...def };
|
|
297
|
+
this.#buildCategories();
|
|
298
|
+
this.#selectCategory(this.#category ?? Object.keys(ACTION_LIBRARY)[0]);
|
|
299
|
+
// Keep chat's action catalog in sync
|
|
300
|
+
this.#chat?.setActionLibrary(ACTION_LIBRARY);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @deprecated — server-side mergePlugins was removed in v2. Actions come exclusively
|
|
305
|
+
* from App.use(ActionsPlugin). This stub is retained to avoid hard errors if old code
|
|
306
|
+
* calls it, but it is a no-op.
|
|
307
|
+
*/
|
|
308
|
+
mergePlugins(plugins = {}) {
|
|
309
|
+
// no-op in v2 — use App.use(ActionsPlugin) instead
|
|
310
|
+
console.warn('[Savant] mergePlugins() is deprecated and has no effect in v2. Use App.use(ActionsPlugin).');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Event tabs ─────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
static #LIFECYCLE = [
|
|
316
|
+
{ key: 'onEnter', label: 'Enter' },
|
|
317
|
+
{ key: 'onExit', label: 'Exit' },
|
|
318
|
+
{ key: 'onBack', label: 'Back' },
|
|
319
|
+
{ key: 'onReset', label: 'Reset' },
|
|
320
|
+
{ key: 'onUnload', label: 'Unload' },
|
|
321
|
+
];
|
|
322
|
+
static #LIFECYCLE_KEYS = new Set(['onEnter','onExit','onBack','onReset','onUnload']);
|
|
323
|
+
|
|
324
|
+
#buildEventTabs() {
|
|
325
|
+
const container = this.#eventTabs;
|
|
326
|
+
container.innerHTML = '';
|
|
327
|
+
|
|
328
|
+
if (!this.#node) { this.#updateEventTabsUI(); return; }
|
|
329
|
+
|
|
330
|
+
if (this.#node._isThing) {
|
|
331
|
+
// Thing mode — show defaultEvents from THING_LIBRARY, plus any custom keys
|
|
332
|
+
const thingType = this.#node._thingType;
|
|
333
|
+
const defaultEvts = getThingEvents(thingType);
|
|
334
|
+
const payload = this.#node.payload.peek();
|
|
335
|
+
|
|
336
|
+
for (const { key, label, fixed } of defaultEvts) {
|
|
337
|
+
container.appendChild(this.#makeTab(key, label, !fixed));
|
|
338
|
+
}
|
|
339
|
+
// Extra event keys not in defaultEvents
|
|
340
|
+
const defaultKeys = new Set(defaultEvts.map(e => e.key));
|
|
341
|
+
for (const key of Object.keys(payload)) {
|
|
342
|
+
if (!defaultKeys.has(key)) {
|
|
343
|
+
container.appendChild(this.#makeTab(key, key, true));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (this.#node._canAddEvents) {
|
|
347
|
+
container.appendChild(this.#makeAddTabBtn());
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
// Room mode — lifecycle tabs
|
|
351
|
+
for (const { key, label } of Savant.#LIFECYCLE) {
|
|
352
|
+
container.appendChild(this.#makeTab(key, label));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Custom event-listener tabs (non-lifecycle payload keys)
|
|
356
|
+
const payload = this.#node.payload.peek();
|
|
357
|
+
for (const key of Object.keys(payload)) {
|
|
358
|
+
if (!Savant.#LIFECYCLE_KEYS.has(key)) {
|
|
359
|
+
container.appendChild(this.#makeTab(key, key, true));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// "+" button — only for room nodes (not diamonds)
|
|
364
|
+
if (this.#node.type !== 'diamond') {
|
|
365
|
+
container.appendChild(this.#makeAddTabBtn());
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
this.#updateEventTabsUI();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#makeAddTabBtn() {
|
|
373
|
+
const addBtn = document.createElement('button');
|
|
374
|
+
addBtn.type = 'button';
|
|
375
|
+
addBtn.className = 'evt-tab evt-tab-add';
|
|
376
|
+
addBtn.title = 'Add event listener';
|
|
377
|
+
addBtn.textContent = '+';
|
|
378
|
+
addBtn.addEventListener('click', () => this.#spawnTabInput(addBtn));
|
|
379
|
+
return addBtn;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#spawnTabInput(addBtn) {
|
|
383
|
+
// Only one inline input at a time
|
|
384
|
+
if (this.#eventTabs.querySelector('.evt-tab-input')) return;
|
|
385
|
+
|
|
386
|
+
const input = document.createElement('input');
|
|
387
|
+
input.type = 'text';
|
|
388
|
+
input.className = 'evt-tab evt-tab-input';
|
|
389
|
+
input.placeholder = 'eventName';
|
|
390
|
+
input.spellcheck = false;
|
|
391
|
+
|
|
392
|
+
const commit = () => {
|
|
393
|
+
const raw = input.value.trim().replace(/\s+/g, '_');
|
|
394
|
+
input.remove();
|
|
395
|
+
if (!raw) return;
|
|
396
|
+
if (!this.#node._isThing && Savant.#LIFECYCLE_KEYS.has(raw)) return;
|
|
397
|
+
const p = this.#node.payload.peek();
|
|
398
|
+
if (p[raw] !== undefined) { this.setEvent(raw); return; }
|
|
399
|
+
const np = { ...p, [raw]: [] };
|
|
400
|
+
if (this.#node._isThing && this.#thingCtx) {
|
|
401
|
+
const { thingDef, parentNode } = this.#thingCtx;
|
|
402
|
+
thingDef.events = np;
|
|
403
|
+
parentNode.updateThing(thingDef.id, { events: np });
|
|
404
|
+
}
|
|
405
|
+
this.#node.payload.value = np;
|
|
406
|
+
this.setEvent(raw);
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
input.addEventListener('keydown', e => {
|
|
410
|
+
if (e.key === 'Enter') { e.preventDefault(); commit(); }
|
|
411
|
+
if (e.key === 'Escape') { e.preventDefault(); input.remove(); }
|
|
412
|
+
});
|
|
413
|
+
// blur fires when focus leaves — but commit() removes the input which triggers another blur,
|
|
414
|
+
// so guard against double-fire with a flag
|
|
415
|
+
input.addEventListener('blur', () => { input.remove(); });
|
|
416
|
+
|
|
417
|
+
this.#eventTabs.insertBefore(input, addBtn);
|
|
418
|
+
input.focus();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
#makeTab(key, label, removable = false) {
|
|
422
|
+
const btn = document.createElement('button');
|
|
423
|
+
btn.type = 'button';
|
|
424
|
+
btn.className = 'evt-tab position-relative' + (key === this.#event ? ' active' : '');
|
|
425
|
+
btn.dataset.event = key;
|
|
426
|
+
btn.textContent = label;
|
|
427
|
+
btn.addEventListener('click', () => this.setEvent(key));
|
|
428
|
+
if (removable) {
|
|
429
|
+
const x = document.createElement('span');
|
|
430
|
+
x.className = 'evt-tab-remove';
|
|
431
|
+
x.textContent = '×';
|
|
432
|
+
x.title = 'Remove listener';
|
|
433
|
+
x.addEventListener('click', e => {
|
|
434
|
+
e.stopPropagation();
|
|
435
|
+
const p = { ...this.#node.payload.peek() };
|
|
436
|
+
delete p[key];
|
|
437
|
+
if (this.#node._isThing && this.#thingCtx) {
|
|
438
|
+
// Persist the change into the parent node's things array
|
|
439
|
+
const { thingDef, parentNode } = this.#thingCtx;
|
|
440
|
+
thingDef.events = p;
|
|
441
|
+
parentNode.updateThing(thingDef.id, { events: p });
|
|
442
|
+
}
|
|
443
|
+
// Setting .value fires the subscription → #buildEventTabs + #renderWorkflow
|
|
444
|
+
this.#node.payload.value = p;
|
|
445
|
+
this.emit('payload:changed');
|
|
446
|
+
if (this.#event === key) this.setEvent('onEnter');
|
|
447
|
+
});
|
|
448
|
+
btn.appendChild(x);
|
|
449
|
+
}
|
|
450
|
+
return btn;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
#updateEventTabsUI() {
|
|
454
|
+
const payload = this.#node?.payload?.peek() ?? {};
|
|
455
|
+
this.#eventTabs.querySelectorAll('.evt-tab').forEach(btn => {
|
|
456
|
+
const key = btn.dataset.event;
|
|
457
|
+
btn.classList.toggle('active', key === this.#event);
|
|
458
|
+
|
|
459
|
+
// Remove old badge
|
|
460
|
+
btn.querySelector('.evt-badge')?.remove();
|
|
461
|
+
|
|
462
|
+
// Count steps for this event (routes count for onEnter on diamonds)
|
|
463
|
+
let count = 0;
|
|
464
|
+
if (this.#node?.type === 'diamond' && key === 'onEnter') {
|
|
465
|
+
count = (this.#node.routes?.peek() ?? []).length;
|
|
466
|
+
} else {
|
|
467
|
+
count = (payload[key] ?? []).length;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (count > 0) {
|
|
471
|
+
const badge = document.createElement('span');
|
|
472
|
+
badge.className = 'evt-badge position-absolute top-0 start-100 translate-middle badge rounded-pill';
|
|
473
|
+
badge.textContent = count > 99 ? '99+' : count;
|
|
474
|
+
badge.setAttribute('aria-hidden', 'true');
|
|
475
|
+
btn.appendChild(badge);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
if (this.#wfTitle) {
|
|
479
|
+
const map = { onEnter:'ENTER', onExit:'EXIT', onBack:'BACK', onReset:'RESET', onUnload:'UNLOAD' };
|
|
480
|
+
this.#wfTitle.textContent = `WORKFLOW · ${map[this.#event] ?? this.#event}`;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ── Categories ─────────────────────────────────────────────────────────────
|
|
485
|
+
#buildCategories() {
|
|
486
|
+
this.#catPane.innerHTML = '';
|
|
487
|
+
|
|
488
|
+
const header = document.createElement('div');
|
|
489
|
+
header.className = 'cat-header';
|
|
490
|
+
header.textContent = 'Categories';
|
|
491
|
+
this.#catPane.appendChild(header);
|
|
492
|
+
|
|
493
|
+
for (const [catId, cat] of Object.entries(ACTION_LIBRARY)) {
|
|
494
|
+
this.#catPane.appendChild(this.#makeCatItem(catId, cat.icon, cat.label));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// AI input section
|
|
498
|
+
const aiSection = document.createElement('div');
|
|
499
|
+
aiSection.className = 'cat-ai-section';
|
|
500
|
+
aiSection.innerHTML = `
|
|
501
|
+
<textarea class="cat-ai-input" rows="2" placeholder="Describe a new action… e.g. 'Video upload with thumbnail frame selector'"></textarea>
|
|
502
|
+
<button class="cat-ai-btn">${renderAfIcon('magic')}<span>Generate Action</span></button>
|
|
503
|
+
`;
|
|
504
|
+
this.#catPane.appendChild(aiSection);
|
|
505
|
+
|
|
506
|
+
this.#aiInput = aiSection.querySelector('.cat-ai-input');
|
|
507
|
+
this.#aiBtn = aiSection.querySelector('.cat-ai-btn');
|
|
508
|
+
this.#aiBtn.addEventListener('click', () => this.#generateAIAction());
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#makeCatItem(catId, icon, label) {
|
|
512
|
+
const item = document.createElement('div');
|
|
513
|
+
item.className = 'cat-item';
|
|
514
|
+
item.dataset.cat = catId;
|
|
515
|
+
item.innerHTML = `${renderAfIcon(icon, { class: 'cat-icon' })}<span>${label}</span>`;
|
|
516
|
+
item.addEventListener('click', () => this.#selectCategory(catId));
|
|
517
|
+
return item;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#selectCategory(catId) {
|
|
521
|
+
this.#category = catId;
|
|
522
|
+
this.#catPane.querySelectorAll('.cat-item').forEach(el => {
|
|
523
|
+
el.classList.toggle('active', el.dataset.cat === catId);
|
|
524
|
+
});
|
|
525
|
+
this.#renderActions(catId);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── Actions pane ───────────────────────────────────────────────────────────
|
|
529
|
+
#renderActions(catId) {
|
|
530
|
+
this.#actList.innerHTML = '';
|
|
531
|
+
|
|
532
|
+
const actions = ACTION_LIBRARY[catId]?.actions ?? {};
|
|
533
|
+
|
|
534
|
+
for (const [actionId, def] of Object.entries(actions)) {
|
|
535
|
+
const card = document.createElement('div');
|
|
536
|
+
card.className = 'act-card';
|
|
537
|
+
if (def._aiGenerated) card.classList.add('act-card--ai');
|
|
538
|
+
card.draggable = true;
|
|
539
|
+
card.dataset.actionId = actionId;
|
|
540
|
+
const nameEl = document.createElement('div');
|
|
541
|
+
nameEl.className = 'act-card-name';
|
|
542
|
+
nameEl.textContent = def.label;
|
|
543
|
+
if (def._aiGenerated) {
|
|
544
|
+
const badge = document.createElement('span');
|
|
545
|
+
badge.className = 'act-card-ai-badge';
|
|
546
|
+
badge.title = 'AI-generated action';
|
|
547
|
+
badge.textContent = '✦';
|
|
548
|
+
nameEl.appendChild(badge);
|
|
549
|
+
}
|
|
550
|
+
const descEl = document.createElement('div');
|
|
551
|
+
descEl.className = 'act-card-desc';
|
|
552
|
+
descEl.textContent = def.desc ?? '';
|
|
553
|
+
card.append(nameEl, descEl);
|
|
554
|
+
// Click → preview (not add)
|
|
555
|
+
card.addEventListener('click', () => this.#showPreview(actionId, def, card));
|
|
556
|
+
// Drag → allow dropping onto workflow
|
|
557
|
+
card.addEventListener('dragstart', e => {
|
|
558
|
+
e.dataTransfer.setData('text/plain', `action:${actionId}`);
|
|
559
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
560
|
+
card.classList.add('dragging');
|
|
561
|
+
});
|
|
562
|
+
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
|
563
|
+
this.#actList.appendChild(card);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── Action preview ─────────────────────────────────────────────────────────
|
|
568
|
+
#showPreview(actionId, def, cardEl) {
|
|
569
|
+
// Deselect previous
|
|
570
|
+
this.#actList.querySelectorAll('.act-card.selected').forEach(c => c.classList.remove('selected'));
|
|
571
|
+
cardEl?.classList.add('selected');
|
|
572
|
+
this.#previewedAction = { actionId, def };
|
|
573
|
+
|
|
574
|
+
const previewEmpty = document.getElementById('act-preview-empty');
|
|
575
|
+
const previewBody = document.getElementById('act-preview-body');
|
|
576
|
+
const nameEl = document.getElementById('act-preview-name');
|
|
577
|
+
const descEl = document.getElementById('act-preview-desc');
|
|
578
|
+
const paramsEl = document.getElementById('act-preview-params');
|
|
579
|
+
|
|
580
|
+
previewEmpty.style.display = 'none';
|
|
581
|
+
previewBody.style.display = '';
|
|
582
|
+
|
|
583
|
+
nameEl.textContent = def.label ?? actionId;
|
|
584
|
+
descEl.textContent = def.desc ?? '';
|
|
585
|
+
|
|
586
|
+
// Render param inputs as a visual preview (read-only-ish labels + types)
|
|
587
|
+
paramsEl.innerHTML = '';
|
|
588
|
+
for (const param of (def.params ?? [])) {
|
|
589
|
+
if (param.name === 'into') continue; // meta param
|
|
590
|
+
const row = document.createElement('div');
|
|
591
|
+
row.className = 'act-preview-param';
|
|
592
|
+
const lbl = document.createElement('span');
|
|
593
|
+
lbl.className = 'act-preview-param-label';
|
|
594
|
+
lbl.textContent = param.label ?? param.name;
|
|
595
|
+
const typ = document.createElement('span');
|
|
596
|
+
typ.className = 'act-preview-param-type';
|
|
597
|
+
typ.textContent = param.type ?? 'text';
|
|
598
|
+
if (param.default !== undefined) {
|
|
599
|
+
typ.textContent += ` = ${JSON.stringify(param.default)}`;
|
|
600
|
+
} else if (param.placeholder) {
|
|
601
|
+
typ.textContent += ` — ${param.placeholder}`;
|
|
602
|
+
}
|
|
603
|
+
row.append(lbl, typ);
|
|
604
|
+
paramsEl.appendChild(row);
|
|
605
|
+
}
|
|
606
|
+
if (!(def.params?.length)) {
|
|
607
|
+
paramsEl.innerHTML = '<span style="color:var(--text-muted);font-size:11px">No parameters</span>';
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ── Workflow ───────────────────────────────────────────────────────────────
|
|
612
|
+
#getSteps() {
|
|
613
|
+
if (!this.#node) return [];
|
|
614
|
+
const p = this.#node.payload.peek();
|
|
615
|
+
return p[this.#event] ?? [];
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
#renderWorkflow() {
|
|
619
|
+
this.#updateEventTabsUI();
|
|
620
|
+
this.#wfSteps.innerHTML = '';
|
|
621
|
+
|
|
622
|
+
if (!this.#node) {
|
|
623
|
+
this.#wfSteps.innerHTML = `<div class="wf-empty">Select a room or diamond<br>on the map to edit its flow.</div>`;
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (this.#node.type === 'diamond' && this.#event === 'onEnter') {
|
|
628
|
+
// Show routes editor for diamonds
|
|
629
|
+
this.#renderRoutesEditor();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const steps = this.#getSteps();
|
|
634
|
+
if (steps.length === 0) {
|
|
635
|
+
const empty = document.createElement('div');
|
|
636
|
+
empty.className = 'wf-empty wf-drop-target';
|
|
637
|
+
empty.textContent = 'No steps yet. Drag an action here or click to add.';
|
|
638
|
+
this.#setupDropZone(empty, 0);
|
|
639
|
+
this.#wfSteps.appendChild(empty);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Render steps with drop zones between each one
|
|
644
|
+
this.#wfSteps.appendChild(this.#makeDropZone(0));
|
|
645
|
+
steps.forEach((step, i) => {
|
|
646
|
+
this.#wfSteps.appendChild(this.#makeStepCard(step, i));
|
|
647
|
+
this.#wfSteps.appendChild(this.#makeDropZone(i + 1));
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
#makeDropZone(insertIndex) {
|
|
652
|
+
const dz = document.createElement('div');
|
|
653
|
+
dz.className = 'wf-drop-zone';
|
|
654
|
+
this.#setupDropZone(dz, insertIndex);
|
|
655
|
+
return dz;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
#setupDropZone(el, insertIndex) {
|
|
659
|
+
el.addEventListener('dragover', e => {
|
|
660
|
+
const data = e.dataTransfer.types.includes('text/plain');
|
|
661
|
+
if (data) { e.preventDefault(); el.classList.add('drag-over'); }
|
|
662
|
+
});
|
|
663
|
+
el.addEventListener('dragleave', () => el.classList.remove('drag-over'));
|
|
664
|
+
el.addEventListener('drop', e => {
|
|
665
|
+
e.preventDefault();
|
|
666
|
+
el.classList.remove('drag-over');
|
|
667
|
+
const raw = e.dataTransfer.getData('text/plain');
|
|
668
|
+
if (raw.startsWith('action:')) {
|
|
669
|
+
const actionId = raw.slice(7);
|
|
670
|
+
const def = this.#findActionDef(actionId);
|
|
671
|
+
if (def) this.#addStepAt(actionId, def, insertIndex);
|
|
672
|
+
}
|
|
673
|
+
// step reorder drops are handled by the step card itself
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
#findActionDef(actionId) {
|
|
678
|
+
for (const cat of Object.values(ACTION_LIBRARY)) {
|
|
679
|
+
if (cat.actions?.[actionId]) return cat.actions[actionId];
|
|
680
|
+
}
|
|
681
|
+
return this.#customActions[actionId] ?? null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ── Step card ──────────────────────────────────────────────────────────────
|
|
685
|
+
// Each step card has three display modes (toggled via the pill switcher):
|
|
686
|
+
// Basic — plain-language form, no code fields, friendly labels
|
|
687
|
+
// Configure — all params with explicit type-matched inputs (default)
|
|
688
|
+
// JSON — raw JSON editor for power users
|
|
689
|
+
|
|
690
|
+
#stepModeKey(index) { return `${this.#node?.id}:${this.#event}:${index}`; }
|
|
691
|
+
|
|
692
|
+
#getStepMode(step, index) {
|
|
693
|
+
// Prefer memory map; fall back to any legacy _uiMode saved in step data
|
|
694
|
+
return this.#stepModes.get(this.#stepModeKey(index)) ?? step._uiMode ?? 'basic';
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
#setStepMode(index, mode) {
|
|
698
|
+
this.#stepModes.set(this.#stepModeKey(index), mode);
|
|
699
|
+
// Re-render the workflow to reflect the new mode (no project.json mutation)
|
|
700
|
+
this.#renderWorkflow();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
#makeStepCard(step, index) {
|
|
704
|
+
const def = findAction(step.action) ?? this.#customActions[step.action] ?? null;
|
|
705
|
+
|
|
706
|
+
// Unregistered action — render a degraded "not loaded" card
|
|
707
|
+
if (!def) {
|
|
708
|
+
const card = document.createElement('div');
|
|
709
|
+
card.className = 'step-card step-card-unloaded';
|
|
710
|
+
card.innerHTML = `
|
|
711
|
+
<div class="step-header">
|
|
712
|
+
<span class="step-drag-handle" draggable="true" title="Drag to reorder">⠿</span>
|
|
713
|
+
<span class="step-number">${index + 1}</span>
|
|
714
|
+
<span class="step-action-name step-unloaded-name">Action not loaded</span>
|
|
715
|
+
<code class="step-unloaded-id">${escH(step.action)}</code>
|
|
716
|
+
<div class="step-controls">
|
|
717
|
+
<button class="step-btn del" title="Delete">${renderAfIcon('x-lg')}</button>
|
|
718
|
+
</div>
|
|
719
|
+
</div>`;
|
|
720
|
+
const dragHandle = card.querySelector('.step-drag-handle');
|
|
721
|
+
dragHandle.addEventListener('dragstart', e => {
|
|
722
|
+
e.dataTransfer.setData('text/plain', String(index));
|
|
723
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
724
|
+
card.style.opacity = '0.4';
|
|
725
|
+
});
|
|
726
|
+
dragHandle.addEventListener('dragend', () => { card.style.opacity = ''; });
|
|
727
|
+
card.addEventListener('dragover', e => { e.preventDefault(); card.style.outline = '1px solid var(--accent)'; });
|
|
728
|
+
card.addEventListener('dragleave', () => { card.style.outline = ''; });
|
|
729
|
+
card.addEventListener('drop', e => {
|
|
730
|
+
e.preventDefault(); card.style.outline = '';
|
|
731
|
+
const raw = e.dataTransfer.getData('text/plain');
|
|
732
|
+
if (raw.startsWith('action:')) return;
|
|
733
|
+
const fromIdx = parseInt(raw);
|
|
734
|
+
if (!isNaN(fromIdx) && fromIdx !== index) this.#node.moveStep(this.#event, fromIdx, index);
|
|
735
|
+
});
|
|
736
|
+
card.querySelector('.step-btn.del').addEventListener('click', () => {
|
|
737
|
+
this.#node.removeStep(this.#event, index);
|
|
738
|
+
});
|
|
739
|
+
return card;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const params = def.params ?? [];
|
|
743
|
+
const card = document.createElement('div');
|
|
744
|
+
const isCollapsed = this.#collapsedSteps.has(this.#stepModeKey(index));
|
|
745
|
+
card.className = 'step-card' + (isCollapsed ? ' collapsed' : '');
|
|
746
|
+
|
|
747
|
+
const mode = this.#getStepMode(step, index);
|
|
748
|
+
|
|
749
|
+
const header = document.createElement('div');
|
|
750
|
+
header.className = 'step-header';
|
|
751
|
+
header.innerHTML = `
|
|
752
|
+
<span class="step-drag-handle" draggable="true" title="Drag to reorder">⠿</span>
|
|
753
|
+
<span class="step-number">${index + 1}</span>
|
|
754
|
+
<span class="step-action-name">${def.label ?? step.action}</span>
|
|
755
|
+
<button class="step-collapse-btn" title="${isCollapsed ? 'Expand' : 'Collapse'}">${isCollapsed ? '▸' : '▾'}</button>
|
|
756
|
+
<div class="step-mode-pills">
|
|
757
|
+
<button class="step-mode-pill${mode === 'basic' ? ' active' : ''}" data-mode="basic" title="Simple view">Basic</button>
|
|
758
|
+
<button class="step-mode-pill${mode === 'configure' ? ' active' : ''}" data-mode="configure" title="All parameters">Configure</button>
|
|
759
|
+
<button class="step-mode-pill${mode === 'json' ? ' active' : ''}" data-mode="json" title="Raw JSON">JSON</button>
|
|
760
|
+
</div>
|
|
761
|
+
<div class="step-controls">
|
|
762
|
+
<button class="step-btn" title="Move up">↑</button>
|
|
763
|
+
<button class="step-btn" title="Move down">↓</button>
|
|
764
|
+
<button class="step-btn del" title="Delete">${renderAfIcon('x-lg')}</button>
|
|
765
|
+
</div>`;
|
|
766
|
+
|
|
767
|
+
const paramsDiv = document.createElement('div');
|
|
768
|
+
paramsDiv.className = 'step-params open';
|
|
769
|
+
paramsDiv.style.display = isCollapsed ? 'none' : '';
|
|
770
|
+
|
|
771
|
+
this.#renderStepParamsInMode(paramsDiv, step, index, def, mode);
|
|
772
|
+
|
|
773
|
+
card.appendChild(header);
|
|
774
|
+
card.appendChild(paramsDiv);
|
|
775
|
+
|
|
776
|
+
// Collapse button
|
|
777
|
+
const collapseBtn = header.querySelector('.step-collapse-btn');
|
|
778
|
+
collapseBtn.addEventListener('click', e => {
|
|
779
|
+
e.stopPropagation();
|
|
780
|
+
const key = this.#stepModeKey(index);
|
|
781
|
+
if (this.#collapsedSteps.has(key)) this.#collapsedSteps.delete(key);
|
|
782
|
+
else this.#collapsedSteps.add(key);
|
|
783
|
+
this.#renderWorkflow();
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// Mode pill switching
|
|
787
|
+
header.querySelectorAll('.step-mode-pill').forEach(pill => {
|
|
788
|
+
pill.addEventListener('click', (e) => {
|
|
789
|
+
e.stopPropagation();
|
|
790
|
+
const newMode = pill.dataset.mode;
|
|
791
|
+
this.#setStepMode(index, newMode);
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
// Button handlers
|
|
796
|
+
const [upBtn, downBtn, delBtn] = header.querySelectorAll('.step-btn');
|
|
797
|
+
delBtn.addEventListener('click', () => { this.#node.removeStep(this.#event, index); });
|
|
798
|
+
upBtn.addEventListener('click', () => { if (index > 0) this.#node.moveStep(this.#event, index, index - 1); });
|
|
799
|
+
downBtn.addEventListener('click',() => {
|
|
800
|
+
const steps = this.#getSteps();
|
|
801
|
+
if (index < steps.length - 1) this.#node.moveStep(this.#event, index, index + 1);
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// Drag-to-reorder — only the handle initiates drag so inputs stay interactive
|
|
805
|
+
const dragHandle = header.querySelector('.step-drag-handle');
|
|
806
|
+
dragHandle.addEventListener('dragstart', e => {
|
|
807
|
+
e.dataTransfer.setData('text/plain', String(index));
|
|
808
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
809
|
+
card.style.opacity = '0.4';
|
|
810
|
+
});
|
|
811
|
+
dragHandle.addEventListener('dragend', () => { card.style.opacity = ''; });
|
|
812
|
+
card.addEventListener('dragover', e => { e.preventDefault(); card.style.outline = '1px solid var(--accent)'; });
|
|
813
|
+
card.addEventListener('dragleave', () => { card.style.outline = ''; });
|
|
814
|
+
card.addEventListener('drop', e => {
|
|
815
|
+
e.preventDefault(); card.style.outline = '';
|
|
816
|
+
const raw = e.dataTransfer.getData('text/plain');
|
|
817
|
+
if (raw.startsWith('action:')) return; // handled by drop zones
|
|
818
|
+
const fromIdx = parseInt(raw);
|
|
819
|
+
if (!isNaN(fromIdx) && fromIdx !== index) this.#node.moveStep(this.#event, fromIdx, index);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
return card;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
#renderStepParamsInMode(container, step, index, def, mode) {
|
|
826
|
+
container.innerHTML = '';
|
|
827
|
+
if (mode === 'json') {
|
|
828
|
+
this.#renderJsonMode(container, step, index);
|
|
829
|
+
} else if (mode === 'basic') {
|
|
830
|
+
this.#renderBasicMode(container, step, index, def);
|
|
831
|
+
} else {
|
|
832
|
+
this.#renderConfigureMode(container, step, index, def);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ── Basic mode — friendly, no code fields ──────────────────────────────────
|
|
837
|
+
#renderBasicMode(container, step, index, def) {
|
|
838
|
+
const params = (def.params ?? []).filter(p => p.type !== 'code' && p.type !== 'textarea');
|
|
839
|
+
|
|
840
|
+
if (params.length === 0 && def.desc) {
|
|
841
|
+
const note = document.createElement('div');
|
|
842
|
+
note.className = 'step-basic-desc';
|
|
843
|
+
note.textContent = def.desc;
|
|
844
|
+
container.appendChild(note);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (params.length === 0) {
|
|
849
|
+
const note = document.createElement('div');
|
|
850
|
+
note.className = 'step-basic-desc';
|
|
851
|
+
note.textContent = 'No configuration needed — this action runs automatically.';
|
|
852
|
+
container.appendChild(note);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
for (const p of params) {
|
|
857
|
+
const row = document.createElement('div');
|
|
858
|
+
row.className = 'param-row';
|
|
859
|
+
const lbl = document.createElement('label');
|
|
860
|
+
lbl.className = 'param-label';
|
|
861
|
+
lbl.textContent = p.label;
|
|
862
|
+
|
|
863
|
+
const hint = document.createElement('span');
|
|
864
|
+
hint.className = 'param-hint';
|
|
865
|
+
hint.textContent = p.placeholder ?? '';
|
|
866
|
+
|
|
867
|
+
const input = this.#makeParamInput(p, step.params?.[p.name] ?? p.default ?? '');
|
|
868
|
+
input.addEventListener('change', () => {
|
|
869
|
+
const storedVal = input.value;
|
|
870
|
+
this.#node.updateStep(this.#event, index, {
|
|
871
|
+
params: { ...(step.params ?? {}), [p.name]: storedVal }
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
row.appendChild(lbl);
|
|
875
|
+
row.appendChild(input);
|
|
876
|
+
container.appendChild(row);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Show any code params as a note prompting Configure
|
|
880
|
+
const codeParams = (def.params ?? []).filter(p => p.type === 'code' || p.type === 'textarea');
|
|
881
|
+
if (codeParams.length > 0) {
|
|
882
|
+
const note = document.createElement('div');
|
|
883
|
+
note.className = 'step-basic-code-note';
|
|
884
|
+
note.innerHTML = `${renderAfIcon('gear')} <span>${codeParams.map(p => p.label).join(', ')} — switch to <strong>Configure</strong> to edit</span>`;
|
|
885
|
+
container.appendChild(note);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// ── Configure mode — all params ────────────────────────────────────────────
|
|
890
|
+
#renderConfigureMode(container, step, index, def) {
|
|
891
|
+
const params = def.params ?? [];
|
|
892
|
+
if (params.length === 0) {
|
|
893
|
+
const note = document.createElement('div');
|
|
894
|
+
note.className = 'step-basic-desc';
|
|
895
|
+
note.textContent = def.desc ?? 'No parameters.';
|
|
896
|
+
container.appendChild(note);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
for (const p of params) {
|
|
901
|
+
const row = document.createElement('div');
|
|
902
|
+
row.className = 'param-row';
|
|
903
|
+
const lbl = document.createElement('label');
|
|
904
|
+
lbl.className = 'param-label';
|
|
905
|
+
lbl.innerHTML = `${p.label}${p.type === 'code' ? ' <span class="param-code-badge">JS</span>' : ''}`;
|
|
906
|
+
const input = this.#makeParamInput(p, step.params?.[p.name] ?? p.default ?? '');
|
|
907
|
+
input.addEventListener('change', () => {
|
|
908
|
+
const storedVal = input.value;
|
|
909
|
+
this.#node.updateStep(this.#event, index, {
|
|
910
|
+
params: { ...(step.params ?? {}), [p.name]: storedVal }
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
row.appendChild(lbl);
|
|
914
|
+
row.appendChild(input);
|
|
915
|
+
container.appendChild(row);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ── JSON mode — raw step object editor ─────────────────────────────────────
|
|
920
|
+
#renderJsonMode(container, step, index) {
|
|
921
|
+
const { _uiMode, ...cleanStep } = step;
|
|
922
|
+
const ta = document.createElement('textarea');
|
|
923
|
+
ta.className = 'param-input step-json-editor';
|
|
924
|
+
ta.rows = 6;
|
|
925
|
+
ta.value = JSON.stringify(cleanStep, null, 2);
|
|
926
|
+
ta.spellcheck = false;
|
|
927
|
+
|
|
928
|
+
let parseErr = false;
|
|
929
|
+
const errDiv = document.createElement('div');
|
|
930
|
+
errDiv.className = 'step-json-error';
|
|
931
|
+
errDiv.style.display = 'none';
|
|
932
|
+
|
|
933
|
+
ta.addEventListener('input', () => {
|
|
934
|
+
try {
|
|
935
|
+
const parsed = JSON.parse(ta.value);
|
|
936
|
+
parseErr = false;
|
|
937
|
+
errDiv.style.display = 'none';
|
|
938
|
+
ta.style.borderColor = '';
|
|
939
|
+
this.#node.updateStep(this.#event, index, parsed);
|
|
940
|
+
} catch (e) {
|
|
941
|
+
parseErr = true;
|
|
942
|
+
errDiv.textContent = e.message;
|
|
943
|
+
errDiv.style.display = 'block';
|
|
944
|
+
ta.style.borderColor = 'var(--danger)';
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
container.appendChild(ta);
|
|
949
|
+
container.appendChild(errDiv);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
#makeParamInput(paramDef, value) {
|
|
953
|
+
if (paramDef.type === 'select') {
|
|
954
|
+
const sel = document.createElement('select');
|
|
955
|
+
sel.className = 'param-input';
|
|
956
|
+
for (const opt of (paramDef.options ?? [])) {
|
|
957
|
+
const o = document.createElement('option');
|
|
958
|
+
o.value = opt; o.textContent = opt;
|
|
959
|
+
if (opt === value) o.selected = true;
|
|
960
|
+
sel.appendChild(o);
|
|
961
|
+
}
|
|
962
|
+
return sel;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (paramDef.type === 'boolean') {
|
|
966
|
+
const sel = document.createElement('select');
|
|
967
|
+
sel.className = 'param-input';
|
|
968
|
+
for (const opt of ['true','false']) {
|
|
969
|
+
const o = document.createElement('option');
|
|
970
|
+
o.value = opt; o.textContent = opt;
|
|
971
|
+
if (String(value) === opt) o.selected = true;
|
|
972
|
+
sel.appendChild(o);
|
|
973
|
+
}
|
|
974
|
+
return sel;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (paramDef.type === 'textarea') {
|
|
978
|
+
const ta = document.createElement('textarea');
|
|
979
|
+
ta.className = 'param-input';
|
|
980
|
+
ta.rows = 2;
|
|
981
|
+
ta.value = value ?? '';
|
|
982
|
+
ta.placeholder = paramDef.placeholder ?? '';
|
|
983
|
+
return ta;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (paramDef.type === 'room') {
|
|
987
|
+
// Dropdown populated by app via needNodeOptions event
|
|
988
|
+
const sel = document.createElement('select');
|
|
989
|
+
sel.className = 'param-input';
|
|
990
|
+
const placeholder = document.createElement('option');
|
|
991
|
+
placeholder.value = ''; placeholder.textContent = '— select room —';
|
|
992
|
+
sel.appendChild(placeholder);
|
|
993
|
+
sel.value = value ?? '';
|
|
994
|
+
// Defer population until app wires needNodeOptions
|
|
995
|
+
requestAnimationFrame(() => {
|
|
996
|
+
this.emit('needNodeOptions', { select: sel, currentValue: value ?? '' });
|
|
997
|
+
});
|
|
998
|
+
return sel;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (paramDef.type === 'inventory-key') {
|
|
1002
|
+
// inventory-key params are plain strings in v2 — no { $$inv } wrapper
|
|
1003
|
+
const keyName = typeof value === 'string' ? value : '';
|
|
1004
|
+
const inp = document.createElement('input');
|
|
1005
|
+
inp.type = 'text';
|
|
1006
|
+
inp.className = 'param-input param-input-inv-key';
|
|
1007
|
+
inp.value = keyName;
|
|
1008
|
+
inp.placeholder = paramDef.placeholder ?? 'inventoryKey';
|
|
1009
|
+
return inp;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const inp = document.createElement('input');
|
|
1013
|
+
inp.className = 'param-input';
|
|
1014
|
+
if (paramDef.type === 'number') inp.type = 'number';
|
|
1015
|
+
else if (paramDef.type === 'url') inp.type = 'url';
|
|
1016
|
+
else inp.type = 'text';
|
|
1017
|
+
inp.value = value ?? '';
|
|
1018
|
+
inp.placeholder = paramDef.placeholder ?? '';
|
|
1019
|
+
if (paramDef.type === 'code') inp.classList.add('param-input-code');
|
|
1020
|
+
return inp;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ── Add step from action card ──────────────────────────────────────────────
|
|
1024
|
+
#addStep(actionId, def) {
|
|
1025
|
+
if (!this.#node) return;
|
|
1026
|
+
const params = {};
|
|
1027
|
+
for (const p of (def.params ?? [])) {
|
|
1028
|
+
params[p.name] = p.default ?? '';
|
|
1029
|
+
}
|
|
1030
|
+
this.#node.addStep(this.#event, { action: actionId, params });
|
|
1031
|
+
// Expand the last card after render
|
|
1032
|
+
requestAnimationFrame(() => {
|
|
1033
|
+
const cards = this.#wfSteps.querySelectorAll('.step-card');
|
|
1034
|
+
const last = cards[cards.length - 1];
|
|
1035
|
+
last?.querySelector('.step-params')?.classList.add('open');
|
|
1036
|
+
last?.scrollIntoView({ behavior: 'smooth' });
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
#addStepAt(actionId, def, insertIndex) {
|
|
1041
|
+
if (!this.#node) return;
|
|
1042
|
+
const params = {};
|
|
1043
|
+
for (const p of (def.params ?? [])) {
|
|
1044
|
+
params[p.name] = p.default ?? '';
|
|
1045
|
+
}
|
|
1046
|
+
this.#node.insertStep(this.#event, insertIndex, { action: actionId, params });
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// ── Diamond routes editor ─────────────────────────────────────────────────
|
|
1050
|
+
#renderRoutesEditor() {
|
|
1051
|
+
this.#wfSteps.innerHTML = '';
|
|
1052
|
+
|
|
1053
|
+
const header = document.createElement('div');
|
|
1054
|
+
header.style.cssText = 'font-size:11px;color:var(--text-muted);margin-bottom:8px';
|
|
1055
|
+
header.textContent = 'Routes are evaluated top-to-bottom. First match wins. Use inventory.key syntax.';
|
|
1056
|
+
this.#wfSteps.appendChild(header);
|
|
1057
|
+
|
|
1058
|
+
const routes = this.#node.routes.peek() ?? [];
|
|
1059
|
+
routes.forEach((route, i) => {
|
|
1060
|
+
this.#wfSteps.appendChild(this.#makeRouteRow(route, i));
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
const addBtn = document.createElement('button');
|
|
1064
|
+
addBtn.className = 'add-route-btn';
|
|
1065
|
+
addBtn.textContent = '+ Add Route';
|
|
1066
|
+
addBtn.addEventListener('click', () => {
|
|
1067
|
+
const rs = [...(this.#node.routes.peek() ?? [])];
|
|
1068
|
+
rs.push({ condition: 'true', target: '', label: 'Default' });
|
|
1069
|
+
this.#node.routes.value = rs;
|
|
1070
|
+
this.#renderWorkflow();
|
|
1071
|
+
});
|
|
1072
|
+
this.#wfSteps.appendChild(addBtn);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Parse a condition like `inventory.get('key') === 'val'` or `inventory.key === 'val'` into {key, op, value} */
|
|
1076
|
+
#parseCondition(cond) {
|
|
1077
|
+
// Match: inventory.get('key') op value OR inventory.key op value
|
|
1078
|
+
const m = cond.trim().match(/^inventory\.(?:get\(['"](.+?)['"]\)|(\w+))\s*(===|!==|==|!=|>=|<=|>|<|includes)\s*(.+)$/);
|
|
1079
|
+
if (!m) return null;
|
|
1080
|
+
const key = m[1] ?? m[2];
|
|
1081
|
+
const op = m[3];
|
|
1082
|
+
let val = m[4].trim();
|
|
1083
|
+
// Strip quotes from string values
|
|
1084
|
+
if ((val.startsWith("'") && val.endsWith("'")) || (val.startsWith('"') && val.endsWith('"'))) {
|
|
1085
|
+
val = val.slice(1, -1);
|
|
1086
|
+
}
|
|
1087
|
+
return { key, op, val };
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Build a condition expression from {key, op, val} */
|
|
1091
|
+
#buildCondition(key, op, val) {
|
|
1092
|
+
if (!key) return 'true';
|
|
1093
|
+
const valExpr = isNaN(val) && val !== 'true' && val !== 'false'
|
|
1094
|
+
? `'${val.replace(/'/g, "\\'")}'`
|
|
1095
|
+
: val;
|
|
1096
|
+
return `inventory.get('${key}') ${op} ${valExpr}`;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
#makeRouteRow(route, index) {
|
|
1100
|
+
const row = document.createElement('div');
|
|
1101
|
+
row.className = 'route-row';
|
|
1102
|
+
|
|
1103
|
+
const parsed = this.#parseCondition(route.condition ?? '');
|
|
1104
|
+
|
|
1105
|
+
row.innerHTML = `
|
|
1106
|
+
<div class="route-builder">
|
|
1107
|
+
<div class="route-visual${parsed ? '' : ' hidden'}">
|
|
1108
|
+
<select class="route-key"><option value="">— inventory key —</option></select>
|
|
1109
|
+
<select class="route-op">
|
|
1110
|
+
<option value="===">=== (equals)</option>
|
|
1111
|
+
<option value="!==">!== (not equals)</option>
|
|
1112
|
+
<option value=">=">>= (≥)</option>
|
|
1113
|
+
<option value="<="><= (≤)</option>
|
|
1114
|
+
<option value=">">> (greater)</option>
|
|
1115
|
+
<option value="<">< (less)</option>
|
|
1116
|
+
<option value="includes">includes</option>
|
|
1117
|
+
</select>
|
|
1118
|
+
<input class="route-val" placeholder="value">
|
|
1119
|
+
</div>
|
|
1120
|
+
<div class="route-advanced${parsed ? ' hidden' : ''}">
|
|
1121
|
+
<input class="route-condition" placeholder="inventory.get('key') === 'value'" value="${escAttr(route.condition ?? '')}">
|
|
1122
|
+
</div>
|
|
1123
|
+
<button class="route-toggle-mode" title="${parsed ? 'Switch to expression mode' : 'Switch to visual mode'}">${parsed ? '{ }' : '◈'}</button>
|
|
1124
|
+
</div>
|
|
1125
|
+
<div class="route-footer">
|
|
1126
|
+
<select class="route-target">
|
|
1127
|
+
<option value="">— target room —</option>
|
|
1128
|
+
</select>
|
|
1129
|
+
<button class="route-del" title="Delete route">${renderAfIcon('x-lg')}</button>
|
|
1130
|
+
</div>`;
|
|
1131
|
+
|
|
1132
|
+
const visualDiv = row.querySelector('.route-visual');
|
|
1133
|
+
const advDiv = row.querySelector('.route-advanced');
|
|
1134
|
+
const keySel = row.querySelector('.route-key');
|
|
1135
|
+
const opSel = row.querySelector('.route-op');
|
|
1136
|
+
const valInput = row.querySelector('.route-val');
|
|
1137
|
+
const condInput = row.querySelector('.route-condition');
|
|
1138
|
+
const toggleBtn = row.querySelector('.route-toggle-mode');
|
|
1139
|
+
const tgtSel = row.querySelector('.route-target');
|
|
1140
|
+
const delBtn = row.querySelector('.route-del');
|
|
1141
|
+
|
|
1142
|
+
// Populate inventory keys
|
|
1143
|
+
this.emit('needInventoryKeys', { select: keySel, currentValue: parsed?.key ?? '' });
|
|
1144
|
+
|
|
1145
|
+
// Populate target options from graph
|
|
1146
|
+
this.emit('needNodeOptions', { select: tgtSel, currentValue: route.target });
|
|
1147
|
+
|
|
1148
|
+
// Set visual values if parsed
|
|
1149
|
+
if (parsed) {
|
|
1150
|
+
keySel.value = parsed.key;
|
|
1151
|
+
opSel.value = parsed.op;
|
|
1152
|
+
valInput.value = parsed.val;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Toggle between visual and advanced mode
|
|
1156
|
+
toggleBtn.addEventListener('click', () => {
|
|
1157
|
+
const isVisual = !visualDiv.classList.contains('hidden');
|
|
1158
|
+
if (isVisual) {
|
|
1159
|
+
// Switch to expression
|
|
1160
|
+
visualDiv.classList.add('hidden');
|
|
1161
|
+
advDiv.classList.remove('hidden');
|
|
1162
|
+
toggleBtn.textContent = '◈';
|
|
1163
|
+
toggleBtn.title = 'Switch to visual mode';
|
|
1164
|
+
} else {
|
|
1165
|
+
// Switch to visual — try to parse current expression
|
|
1166
|
+
const p2 = this.#parseCondition(condInput.value);
|
|
1167
|
+
if (p2) {
|
|
1168
|
+
this.emit('needInventoryKeys', { select: keySel, currentValue: p2.key });
|
|
1169
|
+
keySel.value = p2.key;
|
|
1170
|
+
opSel.value = p2.op;
|
|
1171
|
+
valInput.value = p2.val;
|
|
1172
|
+
visualDiv.classList.remove('hidden');
|
|
1173
|
+
advDiv.classList.add('hidden');
|
|
1174
|
+
toggleBtn.textContent = '{ }';
|
|
1175
|
+
toggleBtn.title = 'Switch to expression mode';
|
|
1176
|
+
} else {
|
|
1177
|
+
this.emit('toast', { msg: 'Cannot parse expression — edit manually', type: 'info' });
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
const syncFromVisual = () => {
|
|
1183
|
+
const cond = this.#buildCondition(keySel.value, opSel.value, valInput.value || 'true');
|
|
1184
|
+
condInput.value = cond;
|
|
1185
|
+
this.#updateRoute(index, { condition: cond });
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
keySel.addEventListener('change', syncFromVisual);
|
|
1189
|
+
opSel.addEventListener('change', syncFromVisual);
|
|
1190
|
+
valInput.addEventListener('input', syncFromVisual);
|
|
1191
|
+
condInput.addEventListener('change', () => this.#updateRoute(index, { condition: condInput.value }));
|
|
1192
|
+
|
|
1193
|
+
tgtSel.addEventListener('change', () => this.#updateRoute(index, { target: tgtSel.value }));
|
|
1194
|
+
delBtn.addEventListener('click', () => {
|
|
1195
|
+
const rs = (this.#node.routes.peek() ?? []).filter((_, i) => i !== index);
|
|
1196
|
+
this.#node.routes.value = rs;
|
|
1197
|
+
this.#renderWorkflow();
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
return row;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
#updateRoute(index, changes) {
|
|
1204
|
+
const routes = [...(this.#node.routes.peek() ?? [])];
|
|
1205
|
+
routes[index] = { ...routes[index], ...changes };
|
|
1206
|
+
this.#node.routes.value = routes;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// ── AI action generation ──────────────────────────────────────────────────
|
|
1210
|
+
async #generateAIAction() {
|
|
1211
|
+
const prompt = this.#aiInput.value.trim();
|
|
1212
|
+
if (!prompt) return;
|
|
1213
|
+
|
|
1214
|
+
this.#aiBtn.disabled = true;
|
|
1215
|
+
this.#aiBtn.innerHTML = `${renderAfIcon('arrow-repeat')}<span>Generating…</span>`;
|
|
1216
|
+
|
|
1217
|
+
try {
|
|
1218
|
+
const result = await API.generateAction(prompt);
|
|
1219
|
+
if (result?.id && result?.label) {
|
|
1220
|
+
this.addCustomAction(result.id, result);
|
|
1221
|
+
// Navigate to the category where the action landed
|
|
1222
|
+
const prefixCat = result.id.includes('.') ? result.id.split('.')[0] : null;
|
|
1223
|
+
const targetCat = (prefixCat && ACTION_LIBRARY[prefixCat])
|
|
1224
|
+
? prefixCat
|
|
1225
|
+
: Object.keys(ACTION_LIBRARY)[0];
|
|
1226
|
+
if (targetCat) this.#selectCategory(targetCat);
|
|
1227
|
+
this.#aiInput.value = '';
|
|
1228
|
+
const catLabel = ACTION_LIBRARY[targetCat]?.label ?? targetCat;
|
|
1229
|
+
this.emit('toast', { msg: `Action "${result.label}" added to ${catLabel}`, type: 'success' });
|
|
1230
|
+
} else {
|
|
1231
|
+
this.emit('toast', { msg: 'AI returned an unexpected response.', type: 'error' });
|
|
1232
|
+
}
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
this.emit('toast', { msg: `AI error: ${err.message}`, type: 'error' });
|
|
1235
|
+
} finally {
|
|
1236
|
+
this.#aiBtn.disabled = false;
|
|
1237
|
+
this.#aiBtn.innerHTML = `${renderAfIcon('magic')}<span>Generate Action</span>`;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// ── Chat context ──────────────────────────────────────────────────────────
|
|
1242
|
+
|
|
1243
|
+
#updateChatContext() {
|
|
1244
|
+
if (!this.#chat) return;
|
|
1245
|
+
if (this.#node?._isThing && this.#thingCtx) {
|
|
1246
|
+
const { thingDef, parentNode } = this.#thingCtx;
|
|
1247
|
+
this.#chat.setContext({
|
|
1248
|
+
projectId: this._projectId ?? '',
|
|
1249
|
+
nodeId: parentNode.id,
|
|
1250
|
+
nodeLabel: parentNode.label?.value ?? '',
|
|
1251
|
+
thingId: thingDef.id,
|
|
1252
|
+
thingLabel: THING_LIBRARY[thingDef.type]?.label ?? thingDef.type,
|
|
1253
|
+
eventKey: this.#event,
|
|
1254
|
+
nodePayload: thingDef.events,
|
|
1255
|
+
});
|
|
1256
|
+
} else if (this.#node) {
|
|
1257
|
+
this.#chat.setContext({
|
|
1258
|
+
projectId: this._projectId ?? '',
|
|
1259
|
+
nodeId: this.#node.id,
|
|
1260
|
+
nodeLabel: this.#node.label?.value ?? '',
|
|
1261
|
+
thingId: '',
|
|
1262
|
+
thingLabel: '',
|
|
1263
|
+
eventKey: this.#event,
|
|
1264
|
+
nodePayload: this.#node.payload?.peek() ?? null,
|
|
1265
|
+
});
|
|
1266
|
+
} else {
|
|
1267
|
+
this.#chat.setContext({});
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** Called by App when a project is opened so chat keys are scoped to the project. */
|
|
1272
|
+
setProjectId(id) {
|
|
1273
|
+
this._projectId = id;
|
|
1274
|
+
this.#updateChatContext();
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/** Wire the executor callback that runs undercity-commands from the AI chat. */
|
|
1278
|
+
setChatExecutor(fn) {
|
|
1279
|
+
if (this.#chat) this.#chat.onExecuteCommands = fn;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
dispose() { this.#scope.dispose(); this.#nodeScope.dispose(); }
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function escAttr(str) {
|
|
1286
|
+
return String(str ?? '').replace(/"/g, '"').replace(/'/g, ''');
|
|
1287
|
+
}
|