@monoes/monobrowse 1.0.0 → 1.0.2

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.
Files changed (103) hide show
  1. package/dist/package.json +71 -0
  2. package/dist/src/browser/action-builder/analyzer.d.ts +0 -1
  3. package/dist/src/browser/action-builder/analyzer.d.ts.map +1 -1
  4. package/dist/src/browser/action-builder/analyzer.js +43 -21
  5. package/dist/src/browser/action-builder/analyzer.js.map +1 -1
  6. package/dist/src/browser/adapters/index.d.ts.map +1 -1
  7. package/dist/src/browser/adapters/index.js +4 -0
  8. package/dist/src/browser/adapters/index.js.map +1 -1
  9. package/dist/src/browser/browser.js +2 -2
  10. package/dist/src/browser/browser.js.map +1 -1
  11. package/dist/src/browser/cdp.d.ts +6 -0
  12. package/dist/src/browser/cdp.d.ts.map +1 -1
  13. package/dist/src/browser/cdp.js +56 -1
  14. package/dist/src/browser/cdp.js.map +1 -1
  15. package/dist/src/browser/dashboard/api-types.d.ts +1 -1
  16. package/dist/src/browser/dashboard/server.d.ts +12 -1
  17. package/dist/src/browser/dashboard/server.d.ts.map +1 -1
  18. package/dist/src/browser/dashboard/server.js +23 -28
  19. package/dist/src/browser/dashboard/server.js.map +1 -1
  20. package/dist/src/browser/dashboard/ui.html +1811 -0
  21. package/dist/src/browser/playbook/browser-handlers.d.ts +3 -0
  22. package/dist/src/browser/playbook/browser-handlers.d.ts.map +1 -0
  23. package/dist/src/browser/playbook/browser-handlers.js +377 -0
  24. package/dist/src/browser/playbook/browser-handlers.js.map +1 -0
  25. package/dist/src/browser/playbook/builtin-handlers.d.ts +3 -0
  26. package/dist/src/browser/playbook/builtin-handlers.d.ts.map +1 -0
  27. package/dist/src/browser/playbook/builtin-handlers.js +364 -0
  28. package/dist/src/browser/playbook/builtin-handlers.js.map +1 -0
  29. package/dist/src/browser/playbook/index.d.ts +3 -0
  30. package/dist/src/browser/playbook/index.d.ts.map +1 -0
  31. package/dist/src/browser/playbook/index.js +3 -0
  32. package/dist/src/browser/playbook/index.js.map +1 -0
  33. package/dist/src/browser/playbook/store.d.ts +8 -0
  34. package/dist/src/browser/playbook/store.d.ts.map +1 -0
  35. package/dist/src/browser/playbook/store.js +87 -0
  36. package/dist/src/browser/playbook/store.js.map +1 -0
  37. package/dist/src/browser/screenshot.d.ts +2 -0
  38. package/dist/src/browser/screenshot.d.ts.map +1 -1
  39. package/dist/src/browser/screenshot.js +69 -2
  40. package/dist/src/browser/screenshot.js.map +1 -1
  41. package/dist/src/browser/types.d.ts.map +1 -1
  42. package/dist/src/browser/types.js +11 -0
  43. package/dist/src/browser/types.js.map +1 -1
  44. package/dist/src/cli/action.d.ts +3 -0
  45. package/dist/src/cli/action.d.ts.map +1 -0
  46. package/dist/src/cli/action.js +142 -0
  47. package/dist/src/cli/action.js.map +1 -0
  48. package/dist/src/cli/commands.d.ts +8 -0
  49. package/dist/src/cli/commands.d.ts.map +1 -0
  50. package/dist/src/cli/commands.js +3018 -0
  51. package/dist/src/cli/commands.js.map +1 -0
  52. package/dist/src/cli/output.d.ts +11 -0
  53. package/dist/src/cli/output.d.ts.map +1 -0
  54. package/dist/src/cli/output.js +33 -0
  55. package/dist/src/cli/output.js.map +1 -0
  56. package/dist/src/cli/platform.d.ts +7 -0
  57. package/dist/src/cli/platform.d.ts.map +1 -0
  58. package/dist/src/cli/platform.js +193 -0
  59. package/dist/src/cli/platform.js.map +1 -0
  60. package/dist/src/cli/playbook.d.ts +3 -0
  61. package/dist/src/cli/playbook.d.ts.map +1 -0
  62. package/dist/src/cli/playbook.js +265 -0
  63. package/dist/src/cli/playbook.js.map +1 -0
  64. package/dist/src/cli/types.d.ts +42 -0
  65. package/dist/src/cli/types.d.ts.map +1 -0
  66. package/dist/src/cli/types.js +2 -0
  67. package/dist/src/cli/types.js.map +1 -0
  68. package/dist/src/cli/workflow.d.ts +2 -0
  69. package/dist/src/cli/workflow.d.ts.map +1 -0
  70. package/dist/src/cli/workflow.js +3 -0
  71. package/dist/src/cli/workflow.js.map +1 -0
  72. package/dist/src/cli.d.ts +7 -0
  73. package/dist/src/cli.d.ts.map +1 -0
  74. package/dist/src/cli.js +187 -0
  75. package/dist/src/cli.js.map +1 -0
  76. package/dist/src/index.d.ts +0 -1
  77. package/dist/src/index.d.ts.map +1 -1
  78. package/dist/src/index.js +0 -1
  79. package/dist/src/index.js.map +1 -1
  80. package/dist/tsconfig.tsbuildinfo +1 -1
  81. package/package.json +21 -11
  82. package/src/browser/action-builder/analyzer.ts +41 -23
  83. package/src/browser/adapters/index.ts +4 -0
  84. package/src/browser/browser.ts +2 -2
  85. package/src/browser/cdp.ts +68 -1
  86. package/src/browser/dashboard/api-types.ts +1 -1
  87. package/src/browser/dashboard/server.ts +41 -30
  88. package/src/browser/dashboard/ui.html +1412 -45
  89. package/src/browser/screenshot.ts +78 -3
  90. package/src/browser/types.ts +11 -0
  91. package/src/cli/action.ts +149 -0
  92. package/src/cli/commands.ts +2892 -0
  93. package/src/cli/output.ts +37 -0
  94. package/src/cli/platform.ts +211 -0
  95. package/src/cli/types.ts +47 -0
  96. package/src/cli.ts +208 -0
  97. package/src/index.ts +0 -1
  98. package/src/browser/workflow/builtin-handlers.ts +0 -379
  99. package/src/browser/workflow/engine.ts +0 -202
  100. package/src/browser/workflow/expression.ts +0 -107
  101. package/src/browser/workflow/index.ts +0 -5
  102. package/src/browser/workflow/store.ts +0 -87
  103. package/src/browser/workflow/types.ts +0 -62
@@ -69,8 +69,8 @@ body { background: #0b0b14; color: #ccc; font-family: system-ui,-apple-system,sa
69
69
  .nav-badge.green { background: #22c55e22; color: #22c55e; }
70
70
 
71
71
  /* ── main panel ──────────────────────────────────────────── */
72
- #main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
73
- .panel { display: none; flex: 1; flex-direction: column; }
72
+ #main { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
73
+ .panel { display: none; flex: 1; flex-direction: column; overflow: hidden; }
74
74
  .panel.active { display: flex; }
75
75
  .panel-header {
76
76
  padding: 14px 18px 10px;
@@ -83,8 +83,8 @@ body { background: #0b0b14; color: #ccc; font-family: system-ui,-apple-system,sa
83
83
  .panel-title { font-size: 14px; font-weight: 600; color: #ddd; }
84
84
  .panel-subtitle { color: #555; font-size: 11px; margin-left: auto; }
85
85
 
86
- /* ── workflow cards ──────────────────────────────────────── */
87
- #wf-cards { padding: 12px; display: grid; gap: 10px; }
86
+ /* ── playbook run cards ──────────────────────────────────── */
87
+ #pb-cards { padding: 12px; display: grid; gap: 10px; overflow-y: auto; }
88
88
  .card {
89
89
  background: #131320;
90
90
  border: 1px solid #7c3aed30;
@@ -129,7 +129,7 @@ body { background: #0b0b14; color: #ccc; font-family: system-ui,-apple-system,sa
129
129
  .empty-title { color: #555; font-size: 14px; font-weight: 600; }
130
130
  .empty-sub { font-size: 11px; text-align: center; line-height: 1.6; }
131
131
 
132
- /* ── agents panel ────────────────────────────────────────── */
132
+ /* ── placeholder panels ─────────────────────────────────── */
133
133
  #agents-panel .placeholder,
134
134
  #memory-panel .placeholder,
135
135
  #sessions-panel .placeholder {
@@ -143,6 +143,276 @@ body { background: #0b0b14; color: #ccc; font-family: system-ui,-apple-system,sa
143
143
  font-size: 12px;
144
144
  }
145
145
  #agents-panel .placeholder .big { font-size: 28px; }
146
+
147
+ /* ── playbook builder ────────────────────────────────────── */
148
+ #builder-panel { display: none; flex-direction: column; overflow: hidden; }
149
+ #builder-panel.active { display: flex; }
150
+
151
+ /* Builder toolbar */
152
+ #builder-toolbar {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 8px;
156
+ padding: 8px 12px;
157
+ border-bottom: 1px solid #1a1a2a;
158
+ background: #0f0f1e;
159
+ flex-shrink: 0;
160
+ }
161
+ #pb-name-input {
162
+ background: #0b0b14;
163
+ border: 1px solid #1e1e30;
164
+ border-radius: 5px;
165
+ padding: 4px 10px;
166
+ color: #ddd;
167
+ font-size: 12px;
168
+ width: 200px;
169
+ outline: none;
170
+ }
171
+ #pb-name-input:focus { border-color: #7c3aed66; }
172
+ .tb-btn {
173
+ background: #131320;
174
+ border: 1px solid #1e1e30;
175
+ border-radius: 5px;
176
+ padding: 4px 10px;
177
+ color: #999;
178
+ font-size: 11px;
179
+ cursor: pointer;
180
+ transition: all .12s;
181
+ display: flex;
182
+ align-items: center;
183
+ gap: 5px;
184
+ white-space: nowrap;
185
+ }
186
+ .tb-btn:hover { border-color: #7c3aed55; color: #c4b5fd; background: #7c3aed12; }
187
+ .tb-btn.run-btn { border-color: #22c55e44; color: #22c55e; background: #22c55e10; }
188
+ .tb-btn.run-btn:hover { background: #22c55e22; }
189
+ .tb-btn.danger { border-color: #ef444433; color: #ef4444; background: #ef444410; }
190
+ .tb-btn.danger:hover { background: #ef444425; }
191
+ .tb-sep { width: 1px; height: 20px; background: #1e1e30; }
192
+
193
+ /* Builder body = palette + canvas + properties */
194
+ #builder-body { display: flex; flex: 1; overflow: hidden; }
195
+
196
+ /* Node palette */
197
+ #node-palette {
198
+ width: 200px;
199
+ background: #0c0c1a;
200
+ border-right: 1px solid #1a1a2a;
201
+ display: flex;
202
+ flex-direction: column;
203
+ flex-shrink: 0;
204
+ overflow-y: auto;
205
+ }
206
+ #palette-search {
207
+ margin: 8px;
208
+ background: #0b0b14;
209
+ border: 1px solid #1e1e30;
210
+ border-radius: 5px;
211
+ padding: 5px 8px;
212
+ color: #ccc;
213
+ font-size: 11px;
214
+ outline: none;
215
+ flex-shrink: 0;
216
+ }
217
+ #palette-search:focus { border-color: #7c3aed55; }
218
+ .palette-cat-label {
219
+ font-size: 9px;
220
+ font-weight: 700;
221
+ text-transform: uppercase;
222
+ letter-spacing: 1px;
223
+ color: #3a3a5a;
224
+ padding: 8px 10px 3px;
225
+ flex-shrink: 0;
226
+ }
227
+ .palette-node {
228
+ display: flex;
229
+ align-items: center;
230
+ gap: 7px;
231
+ padding: 5px 10px;
232
+ cursor: grab;
233
+ font-size: 11px;
234
+ color: #777;
235
+ border-left: 2px solid transparent;
236
+ transition: all .1s;
237
+ user-select: none;
238
+ flex-shrink: 0;
239
+ }
240
+ .palette-node:hover { color: #bbb; background: #ffffff08; }
241
+ .palette-node:active { cursor: grabbing; }
242
+ .palette-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
243
+
244
+ /* Canvas area */
245
+ #canvas-wrap {
246
+ flex: 1;
247
+ position: relative;
248
+ overflow: hidden;
249
+ background: #0b0b14;
250
+ background-image:
251
+ radial-gradient(circle, #1e1e30 1px, transparent 1px);
252
+ background-size: 24px 24px;
253
+ }
254
+ #canvas-svg {
255
+ position: absolute;
256
+ top: 0; left: 0;
257
+ width: 100%; height: 100%;
258
+ pointer-events: none;
259
+ }
260
+ #canvas-nodes {
261
+ position: absolute;
262
+ top: 0; left: 0;
263
+ width: 0; height: 0;
264
+ transform-origin: 0 0;
265
+ }
266
+
267
+ /* Canvas node */
268
+ .cn {
269
+ position: absolute;
270
+ width: 200px;
271
+ background: linear-gradient(160deg,#0d0d1e 0%,#090914 100%);
272
+ border: 1.5px solid rgba(124,58,237,0.15);
273
+ border-radius: 9px;
274
+ user-select: none;
275
+ cursor: default;
276
+ box-shadow: 0 6px 20px rgba(0,0,0,.5);
277
+ transition: border-color .14s, box-shadow .14s;
278
+ }
279
+ .cn.selected { box-shadow: 0 0 0 1.5px #7c3aed55, 0 12px 32px rgba(0,0,0,.7); }
280
+ .cn.running { animation: nodePulse 1s ease-in-out infinite; }
281
+ .cn-header {
282
+ height: 40px;
283
+ border-radius: 8px 8px 0 0;
284
+ display: flex;
285
+ align-items: center;
286
+ padding: 0 8px 0 10px;
287
+ cursor: grab;
288
+ gap: 6px;
289
+ border-bottom: 1px solid rgba(124,58,237,0.1);
290
+ }
291
+ .cn-header:active { cursor: grabbing; }
292
+ .cn-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
293
+ .cn-label { flex: 1; font-size: 11px; font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: monospace; }
294
+ .cn-del { background: none; border: none; cursor: pointer; color: rgba(148,163,184,.3); font-size: 14px; padding: 1px 3px; line-height: 1; transition: color .1s; }
295
+ .cn-del:hover { color: #ef4444; }
296
+ .cn-ports { padding: 6px 0; }
297
+ .cn-row { height: 26px; display: flex; align-items: center; justify-content: space-between; }
298
+ .cn-port-area { display: flex; align-items: center; gap: 5px; }
299
+ .cn-port {
300
+ width: 11px; height: 11px;
301
+ border-radius: 50%;
302
+ background: #1e1e30;
303
+ border: 1.5px solid rgba(124,58,237,0.4);
304
+ cursor: crosshair;
305
+ flex-shrink: 0;
306
+ transition: all .1s;
307
+ }
308
+ .cn-port:hover, .cn-port.highlight { background: #7c3aed; border-color: #7c3aed; }
309
+ .cn-port.in { margin-left: -6px; }
310
+ .cn-port.out { margin-right: -6px; }
311
+ .cn-port-label { font-size: 9px; color: #3a3a5a; font-family: monospace; }
312
+ .cn-status {
313
+ position: absolute; top: -9px; right: -5px;
314
+ font-size: 8px; font-weight: 700;
315
+ border-radius: 8px; padding: 1px 5px;
316
+ pointer-events: none;
317
+ white-space: nowrap;
318
+ }
319
+ .cn-status.running { background: #7c3aed; color: #fff; box-shadow: 0 0 8px #7c3aed66; }
320
+ .cn-status.ok { background: #10b981; color: #fff; box-shadow: 0 0 8px #10b98166; }
321
+ .cn-status.error { background: #ef4444; color: #fff; box-shadow: 0 0 8px #ef444466; }
322
+ .cn-status.skipped { background: #6b7280; color: #fff; }
323
+
324
+ /* Properties panel */
325
+ #props-panel {
326
+ width: 240px;
327
+ background: #0c0c1a;
328
+ border-left: 1px solid #1a1a2a;
329
+ display: flex;
330
+ flex-direction: column;
331
+ flex-shrink: 0;
332
+ overflow-y: auto;
333
+ }
334
+ #props-header {
335
+ padding: 10px 12px 8px;
336
+ border-bottom: 1px solid #1a1a2a;
337
+ font-size: 10px;
338
+ font-weight: 700;
339
+ text-transform: uppercase;
340
+ letter-spacing: 1px;
341
+ color: #3a3a5a;
342
+ flex-shrink: 0;
343
+ }
344
+ #props-body { padding: 10px 12px; flex: 1; }
345
+ .prop-row { margin-bottom: 10px; }
346
+ .prop-label { font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: #444; margin-bottom: 4px; }
347
+ .prop-input, .prop-select, .prop-textarea {
348
+ width: 100%;
349
+ background: #0b0b14;
350
+ border: 1px solid #1e1e30;
351
+ border-radius: 4px;
352
+ padding: 5px 8px;
353
+ color: #ccc;
354
+ font-size: 11px;
355
+ outline: none;
356
+ font-family: monospace;
357
+ }
358
+ .prop-input:focus, .prop-select:focus, .prop-textarea:focus { border-color: #7c3aed55; }
359
+ .prop-select { appearance: none; cursor: pointer; }
360
+ .prop-textarea { resize: vertical; min-height: 60px; }
361
+
362
+ /* Playbook list inside builder */
363
+ #pb-list-overlay {
364
+ display: none;
365
+ position: absolute;
366
+ inset: 0;
367
+ background: rgba(10,10,20,0.85);
368
+ z-index: 100;
369
+ align-items: center;
370
+ justify-content: center;
371
+ }
372
+ #pb-list-overlay.show { display: flex; }
373
+ #pb-list-box {
374
+ width: 480px;
375
+ max-height: 70vh;
376
+ background: #0d0d1a;
377
+ border: 1px solid #7c3aed33;
378
+ border-radius: 10px;
379
+ display: flex;
380
+ flex-direction: column;
381
+ overflow: hidden;
382
+ box-shadow: 0 24px 60px rgba(0,0,0,.8);
383
+ }
384
+ .pblo-header {
385
+ padding: 12px 14px;
386
+ border-bottom: 1px solid #1a1a2a;
387
+ display: flex;
388
+ align-items: center;
389
+ gap: 8px;
390
+ flex-shrink: 0;
391
+ }
392
+ .pblo-title { font-size: 12px; font-weight: 700; color: #ddd; flex: 1; font-family: monospace; text-transform: uppercase; letter-spacing: 1px; }
393
+ .pblo-close { background: none; border: none; cursor: pointer; color: #555; font-size: 16px; }
394
+ .pblo-close:hover { color: #aaa; }
395
+ .pblo-body { flex: 1; overflow-y: auto; }
396
+ .pblo-item {
397
+ display: flex;
398
+ align-items: center;
399
+ gap: 10px;
400
+ padding: 10px 14px;
401
+ border-bottom: 1px solid #1a1a2a10;
402
+ cursor: pointer;
403
+ transition: background .1s;
404
+ }
405
+ .pblo-item:hover { background: #7c3aed10; }
406
+ .pblo-item-name { flex: 1; font-size: 12px; color: #ccc; }
407
+ .pblo-item-meta { font-size: 10px; color: #444; }
408
+ .pblo-del { background: none; border: none; cursor: pointer; color: #444; font-size: 12px; padding: 2px 5px; }
409
+ .pblo-del:hover { color: #ef4444; }
410
+ .pblo-empty { padding: 30px; text-align: center; color: #333; font-size: 12px; }
411
+
412
+ @keyframes nodePulse {
413
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(124,58,237,0.4), 0 6px 20px rgba(0,0,0,.5); }
414
+ 50% { box-shadow: 0 0 0 4px rgba(124,58,237,0.1), 0 6px 20px rgba(0,0,0,.5); }
415
+ }
146
416
  </style>
147
417
  </head>
148
418
  <body>
@@ -165,10 +435,13 @@ body { background: #0b0b14; color: #ccc; font-family: system-ui,-apple-system,sa
165
435
  <!-- sidebar -->
166
436
  <nav id="sidebar">
167
437
  <div class="nav-section-label">Project</div>
168
- <div class="nav-item active" data-panel="workflows">
169
- <span class="nav-icon">⚡</span> Workflows
438
+ <div class="nav-item active" data-panel="playbooks">
439
+ <span class="nav-icon">⚡</span> Runs
170
440
  <span class="nav-badge" id="nb-run">0</span>
171
441
  </div>
442
+ <div class="nav-item" data-panel="builder">
443
+ <span class="nav-icon">🔧</span> Builder
444
+ </div>
172
445
 
173
446
  <div class="nav-section-label" style="margin-top:8px">System</div>
174
447
  <div class="nav-item" data-panel="agents">
@@ -185,17 +458,79 @@ body { background: #0b0b14; color: #ccc; font-family: system-ui,-apple-system,sa
185
458
  <!-- main -->
186
459
  <div id="main">
187
460
 
188
- <!-- Workflows panel -->
189
- <div class="panel active" id="wf-panel">
461
+ <!-- Playbook Runs panel -->
462
+ <div class="panel active" id="pb-panel">
190
463
  <div class="panel-header">
191
- <span class="panel-title">Workflow Runs</span>
192
- <span class="panel-subtitle" id="wf-subtitle">0 runs</span>
464
+ <span class="panel-title">Playbook Runs</span>
465
+ <span class="panel-subtitle" id="pb-subtitle">0 runs</span>
193
466
  </div>
194
- <div id="wf-cards"></div>
195
- <div class="empty" id="wf-empty">
467
+ <div id="pb-cards" style="overflow-y:auto;flex:1;padding:12px;display:grid;gap:10px;align-content:start"></div>
468
+ <div class="empty" id="pb-empty">
196
469
  <span class="empty-icon">⚡</span>
197
- <span class="empty-title">No workflow runs yet</span>
198
- <span class="empty-sub">Run a workflow with<br><code style="color:#7c3aed">npx monomind browse workflow run &lt;name&gt;</code></span>
470
+ <span class="empty-title">No playbook runs yet</span>
471
+ <span class="empty-sub">Use the Builder tab to create and run playbooks,<br>or run via CLI: <code style="color:#7c3aed">npx monomind browse playbook run &lt;name&gt;</code></span>
472
+ </div>
473
+ </div>
474
+
475
+ <!-- Builder panel -->
476
+ <div class="panel" id="builder-panel">
477
+ <!-- Toolbar -->
478
+ <div id="builder-toolbar">
479
+ <input id="pb-name-input" type="text" placeholder="Untitled Playbook" value="Untitled Playbook" />
480
+ <div class="tb-sep"></div>
481
+ <button class="tb-btn" id="btn-new" title="New playbook">+ New</button>
482
+ <button class="tb-btn" id="btn-open" title="Open saved playbook">📂 Open</button>
483
+ <button class="tb-btn" id="btn-save" title="Save playbook">💾 Save</button>
484
+ <div class="tb-sep"></div>
485
+ <button class="tb-btn run-btn" id="btn-run" title="Run playbook">▶ Run</button>
486
+ <div class="tb-sep"></div>
487
+ <button class="tb-btn" id="btn-zoom-in" title="Zoom in">+</button>
488
+ <button class="tb-btn" id="btn-zoom-out" title="Zoom out">-</button>
489
+ <button class="tb-btn" id="btn-fit" title="Fit canvas">⊡</button>
490
+ </div>
491
+
492
+ <!-- Builder body -->
493
+ <div id="builder-body">
494
+ <!-- Node palette -->
495
+ <div id="node-palette">
496
+ <input id="palette-search" type="text" placeholder="Search nodes…" />
497
+ <div id="palette-list"></div>
498
+ </div>
499
+
500
+ <!-- Canvas -->
501
+ <div id="canvas-wrap">
502
+ <svg id="canvas-svg" xmlns="http://www.w3.org/2000/svg">
503
+ <defs>
504
+ <marker id="arrowhead" markerWidth="7" markerHeight="7" refX="6" refY="3.5" orient="auto">
505
+ <polygon points="0 0, 7 3.5, 0 7" fill="#7c3aed88" />
506
+ </marker>
507
+ </defs>
508
+ <g id="edge-layer"></g>
509
+ <path id="drag-edge" d="" stroke="#a78bfa" stroke-width="1.5" fill="none" stroke-dasharray="6,3" style="display:none"/>
510
+ </svg>
511
+ <div id="canvas-nodes"></div>
512
+
513
+ <!-- Playbook list overlay -->
514
+ <div id="pb-list-overlay">
515
+ <div id="pb-list-box">
516
+ <div class="pblo-header">
517
+ <span class="pblo-title">Saved Playbooks</span>
518
+ <button class="pblo-close" id="pblo-close-btn">✕</button>
519
+ </div>
520
+ <div class="pblo-body" id="pblo-body"></div>
521
+ </div>
522
+ </div>
523
+ </div>
524
+
525
+ <!-- Properties panel -->
526
+ <div id="props-panel">
527
+ <div id="props-header">Properties</div>
528
+ <div id="props-body">
529
+ <div class="empty" style="padding:20px;color:#333;font-size:11px;text-align:center">
530
+ Select a node to edit its properties
531
+ </div>
532
+ </div>
533
+ </div>
199
534
  </div>
200
535
  </div>
201
536
 
@@ -230,19 +565,43 @@ body { background: #0b0b14; color: #ccc; font-family: system-ui,-apple-system,sa
230
565
  </div>
231
566
 
232
567
  <script>
233
- // ── nav ────────────────────────────────────────────────────
568
+ // ═══════════════════════════════════════════════════════════════════
569
+ // NAV
570
+ // ═══════════════════════════════════════════════════════════════════
571
+ function navigateToPanel(target) {
572
+ document.querySelectorAll('.nav-item').forEach(function(n) { n.classList.remove('active'); });
573
+ var navItem = document.querySelector('[data-panel="' + target + '"]');
574
+ if (navItem) navItem.classList.add('active');
575
+ document.querySelectorAll('.panel').forEach(function(p) { p.classList.remove('active'); });
576
+ var panelId = target === 'playbooks' ? 'pb-panel'
577
+ : target === 'builder' ? 'builder-panel'
578
+ : target + '-panel';
579
+ var panel = document.getElementById(panelId);
580
+ if (panel) panel.classList.add('active');
581
+ if (target === 'builder') renderEdges();
582
+ location.hash = target;
583
+ }
584
+
234
585
  document.querySelectorAll('.nav-item').forEach(function(item) {
235
586
  item.addEventListener('click', function() {
236
587
  var target = item.getAttribute('data-panel');
237
- document.querySelectorAll('.nav-item').forEach(function(n) { n.classList.remove('active'); });
238
- item.classList.add('active');
239
- document.querySelectorAll('.panel').forEach(function(p) { p.classList.remove('active'); });
240
- var panel = document.getElementById(target === 'workflows' ? 'wf-panel' : target + '-panel');
241
- if (panel) panel.classList.add('active');
588
+ navigateToPanel(target);
242
589
  });
243
590
  });
244
591
 
245
- // ── state ──────────────────────────────────────────────────
592
+ // Hash-based routing on load
593
+ (function() {
594
+ var hash = location.hash.replace('#', '');
595
+ var validPanels = ['playbooks', 'builder', 'agents', 'memory', 'sessions'];
596
+ if (hash && validPanels.indexOf(hash) !== -1) {
597
+ // defer until after renderEdges is defined
598
+ window.addEventListener('load', function() { navigateToPanel(hash); });
599
+ }
600
+ })();
601
+
602
+ // ═══════════════════════════════════════════════════════════════════
603
+ // PLAYBOOK RUN STATE (existing panel)
604
+ // ═══════════════════════════════════════════════════════════════════
246
605
  var runs = {};
247
606
  var runLogs = {};
248
607
 
@@ -267,43 +626,35 @@ function updateHeader() {
267
626
  document.getElementById('nb-run').textContent = String(running || Object.keys(runs).length);
268
627
  document.getElementById('nb-run').className = 'nav-badge' + (running ? '' : ' green');
269
628
  var total = Object.keys(runs).length;
270
- document.getElementById('wf-subtitle').textContent = total + (total === 1 ? ' run' : ' runs');
629
+ document.getElementById('pb-subtitle').textContent = total + (total === 1 ? ' run' : ' runs');
271
630
  }
272
631
 
273
632
  function progressPct(r) {
274
633
  if (!r.itemsTotal || r.itemsTotal <= 0) return r.status === 'completed' ? 100 : 0;
275
634
  return Math.min(100, Math.round((r.itemsProcessed / r.itemsTotal) * 100));
276
635
  }
277
-
278
636
  function statusLabel(r) {
279
637
  if (r.status === 'running') return ['s-running', '● running'];
280
638
  if (r.status === 'completed') return ['s-completed', '✓ done'];
281
639
  if (r.status === 'failed') return ['s-failed', '✗ failed'];
282
640
  return ['s-stopped', '⏹ stopped'];
283
641
  }
284
-
285
642
  function elapsed(r) {
286
643
  var ms = (r.endedAt || r.completedAt || Date.now()) - r.startedAt;
287
644
  if (ms < 1000) return ms + 'ms';
288
645
  if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
289
646
  return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
290
647
  }
291
-
292
648
  function renderCards() {
293
- var cards = document.getElementById('wf-cards');
294
- var empty = document.getElementById('wf-empty');
649
+ var cards = document.getElementById('pb-cards');
650
+ var empty = document.getElementById('pb-empty');
295
651
  var sorted = Object.values(runs).sort(function(a, b) { return b.startedAt - a.startedAt; });
296
- if (!sorted.length) {
297
- cards.innerHTML = '';
298
- empty.style.display = 'flex';
299
- return;
300
- }
652
+ if (!sorted.length) { cards.innerHTML = ''; empty.style.display = 'flex'; return; }
301
653
  empty.style.display = 'none';
302
- var pct = 0;
303
654
  cards.innerHTML = sorted.map(function(r) {
304
655
  var sl = statusLabel(r);
305
656
  var isRunning = r.status === 'running';
306
- pct = progressPct(r);
657
+ var pct = progressPct(r);
307
658
  var logLines = (runLogs[r.id] || []).slice(-4);
308
659
  var logHtml = logLines.length
309
660
  ? logLines.map(function(l) {
@@ -313,7 +664,7 @@ function renderCards() {
313
664
  : '<div style="color:#2a2a3a">no events yet</div>';
314
665
  return '<div class="card' + (!isRunning ? ' done' : '') + '">' +
315
666
  '<div class="card-header">' +
316
- '<span class="card-title">' + esc(r.workflowName) + '</span>' +
667
+ '<span class="card-title">' + esc(r.playbookName) + '</span>' +
317
668
  '<span class="status-badge ' + sl[0] + '">' + sl[1] + '</span>' +
318
669
  '</div>' +
319
670
  '<div class="card-meta">' +
@@ -327,17 +678,1028 @@ function renderCards() {
327
678
  '</div>';
328
679
  }).join('');
329
680
  }
681
+ function stopRun(id) { fetch('/stop/' + id, { method: 'POST' }).catch(function() {}); }
682
+
683
+ // ═══════════════════════════════════════════════════════════════════
684
+ // NODE PALETTE DEFINITIONS
685
+ // ═══════════════════════════════════════════════════════════════════
686
+ var NODE_TYPES = [
687
+ { cat: 'Triggers', type: 'trigger.manual', label: 'Manual Trigger', color: '#7c3aed',
688
+ inputs: [], outputs: [{ label: 'items' }],
689
+ defaults: { items: [] } },
690
+ { cat: 'Triggers', type: 'trigger.schedule', label: 'Schedule', color: '#7c3aed',
691
+ inputs: [], outputs: [{ label: 'items' }],
692
+ defaults: { cron: '0 * * * *' } },
693
+
694
+ { cat: 'Control', type: 'core.if', label: 'If / Branch', color: '#0891b2',
695
+ inputs: [{ label: 'in' }], outputs: [{ label: 'true' }, { label: 'false' }],
696
+ defaults: { expression: '$item.data.value > 0' } },
697
+ { cat: 'Control', type: 'core.filter', label: 'Filter', color: '#0891b2',
698
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
699
+ defaults: { expression: '$item.data.value > 0' } },
700
+
701
+ { cat: 'Data', type: 'core.set', label: 'Set Fields', color: '#d97706',
702
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
703
+ defaults: {} },
704
+ { cat: 'Data', type: 'action.log', label: 'Log', color: '#d97706',
705
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
706
+ defaults: { label: 'debug' } },
707
+
708
+ { cat: 'HTTP', type: 'action.http', label: 'HTTP Request', color: '#d97706',
709
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
710
+ defaults: { url: 'https://example.com/api', method: 'GET' } },
711
+ { cat: 'HTTP', type: 'http.request', label: 'HTTP Advanced', color: '#d97706',
712
+ inputs: [{ label: 'in' }], outputs: [{ label: 'main' }, { label: 'error' }],
713
+ defaults: { url: 'https://example.com/api', method: 'GET', response_format: 'json' } },
330
714
 
331
- function stopRun(id) {
332
- fetch('/stop/' + id, { method: 'POST' }).catch(function() {});
715
+ { cat: 'Services', type: 'service.google_drive', label: 'Google Drive', color: '#0f766e',
716
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
717
+ defaults: { operation: 'list_files', access_token: '' } },
718
+ { cat: 'Services', type: 'service.gmail', label: 'Gmail', color: '#0f766e',
719
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
720
+ defaults: { operation: 'list_messages', access_token: '' } },
721
+ { cat: 'Services', type: 'service.github', label: 'GitHub', color: '#0f766e',
722
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
723
+ defaults: { operation: 'list_repos', token: '' } },
724
+ { cat: 'Services', type: 'service.google_sheets', label: 'Google Sheets', color: '#0f766e',
725
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
726
+ defaults: { operation: 'read_rows', access_token: '', spreadsheet_id: '' } },
727
+
728
+ { cat: 'Browser', type: 'action.gemini_image', label: 'Gemini Image', color: '#9333ea',
729
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
730
+ defaults: { prompt: 'A beautiful landscape' } },
731
+ { cat: 'Browser', type: 'action.save_file', label: 'Save File', color: '#9333ea',
732
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
733
+ defaults: { path: './output.txt' } },
734
+
735
+ { cat: 'Services', type: 'service.notion', label: 'Notion', color: '#0f766e',
736
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
737
+ defaults: { operation: 'query_database', token: '' } },
738
+ { cat: 'Services', type: 'service.linear', label: 'Linear', color: '#0f766e',
739
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
740
+ defaults: { operation: 'list_issues', token: '' } },
741
+ { cat: 'Services', type: 'service.airtable', label: 'Airtable', color: '#0f766e',
742
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
743
+ defaults: { operation: 'list_records', token: '', base_id: '', table: '' } },
744
+ { cat: 'Services', type: 'service.stripe', label: 'Stripe', color: '#0f766e',
745
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
746
+ defaults: { operation: 'list_customers', api_key: '' } },
747
+
748
+ { cat: 'Data', type: 'core.aggregate', label: 'Aggregate', color: '#d97706',
749
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
750
+ defaults: { group_by: '', operations: [] } },
751
+ { cat: 'Data', type: 'core.sort', label: 'Sort', color: '#d97706',
752
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
753
+ defaults: { field: '', order: 'asc' } },
754
+ { cat: 'Data', type: 'core.limit', label: 'Limit', color: '#d97706',
755
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
756
+ defaults: { count: 10 } },
757
+ { cat: 'Data', type: 'core.merge', label: 'Merge', color: '#d97706',
758
+ inputs: [{ label: 'in1' }, { label: 'in2' }], outputs: [{ label: 'out' }],
759
+ defaults: { mode: 'append' } },
760
+ { cat: 'Data', type: 'core.remove_duplicates', label: 'Remove Duplicates', color: '#d97706',
761
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
762
+ defaults: { field: '' } },
763
+ { cat: 'Data', type: 'core.switch', label: 'Switch', color: '#0891b2',
764
+ inputs: [{ label: 'in' }], outputs: [{ label: 'case0' }, { label: 'default' }],
765
+ defaults: { field: '', cases: [] } },
766
+ { cat: 'Data', type: 'core.wait', label: 'Wait', color: '#d97706',
767
+ inputs: [{ label: 'in' }], outputs: [{ label: 'out' }],
768
+ defaults: { seconds: 5 } },
769
+ { cat: 'Data', type: 'core.split_in_batches', label: 'Split in Batches', color: '#d97706',
770
+ inputs: [{ label: 'in' }], outputs: [{ label: 'batch' }],
771
+ defaults: { batch_size: 10 } },
772
+ ];
773
+
774
+ var CAT_ORDER = ['Triggers','Control','Data','HTTP','Services','Browser'];
775
+
776
+ // Fields shown for each node type in the properties panel
777
+ var NODE_FIELDS = {
778
+ 'trigger.manual': [{ key:'items', label:'Initial Items (JSON array)', type:'textarea' }],
779
+ 'trigger.schedule': [{ key:'cron', label:'Cron expression', type:'text' }],
780
+ 'core.if': [{ key:'expression', label:'Expression', type:'text' }],
781
+ 'core.filter': [{ key:'expression', label:'Expression', type:'text' }],
782
+ 'core.set': [{ key:'__note', label:'Config (JSON)', type:'textarea', jsonKey:'_config_json' }],
783
+ 'action.log': [{ key:'label', label:'Label', type:'text' }],
784
+ 'action.http': [
785
+ { key:'url', label:'URL', type:'text' },
786
+ { key:'method', label:'Method', type:'select', options:['GET','POST','PUT','PATCH','DELETE'] },
787
+ { key:'responseField', label:'Response field', type:'text' },
788
+ ],
789
+ 'http.request': [
790
+ { key:'url', label:'URL', type:'text' },
791
+ { key:'method', label:'Method', type:'select', options:['GET','POST','PUT','PATCH','DELETE'] },
792
+ { key:'auth_type', label:'Auth type', type:'select', options:['none','bearer','basic','api_key'] },
793
+ { key:'auth_token', label:'Bearer token', type:'text' },
794
+ { key:'response_format', label:'Response format', type:'select', options:['json','text','binary'] },
795
+ { key:'timeout_seconds', label:'Timeout (s)', type:'text' },
796
+ ],
797
+ 'service.google_drive': [
798
+ { key:'access_token', label:'Access token', type:'text' },
799
+ { key:'operation', label:'Operation', type:'select', options:['list_files','get_file','upload_file','download_file','create_folder','delete_file','share_file'] },
800
+ { key:'file_id', label:'File ID', type:'text' },
801
+ { key:'file_name', label:'File name', type:'text' },
802
+ { key:'query', label:'Search query', type:'text' },
803
+ { key:'page_size', label:'Page size', type:'text' },
804
+ ],
805
+ 'service.gmail': [
806
+ { key:'access_token', label:'Access token', type:'text' },
807
+ { key:'operation', label:'Operation', type:'select', options:['list_messages','get_message','send_message','list_labels','trash_message'] },
808
+ { key:'message_id', label:'Message ID', type:'text' },
809
+ { key:'to', label:'To', type:'text' },
810
+ { key:'subject', label:'Subject', type:'text' },
811
+ { key:'body', label:'Body', type:'textarea' },
812
+ { key:'query', label:'Search query', type:'text' },
813
+ ],
814
+ 'service.github': [
815
+ { key:'token', label:'GitHub token', type:'text' },
816
+ { key:'operation', label:'Operation', type:'select', options:['list_repos','list_issues','get_issue','create_issue','update_issue','list_prs','create_pr','list_releases','create_release'] },
817
+ { key:'owner', label:'Owner', type:'text' },
818
+ { key:'repo', label:'Repo', type:'text' },
819
+ { key:'number', label:'Issue/PR number', type:'text' },
820
+ { key:'title', label:'Title', type:'text' },
821
+ { key:'state', label:'State', type:'select', options:['','open','closed','all'] },
822
+ ],
823
+ 'service.google_sheets': [
824
+ { key:'access_token', label:'Access token', type:'text' },
825
+ { key:'spreadsheet_id', label:'Spreadsheet ID', type:'text' },
826
+ { key:'operation', label:'Operation', type:'select', options:['read_rows','append_rows','update_rows','clear_range','create_spreadsheet'] },
827
+ { key:'sheet_name', label:'Sheet name', type:'text' },
828
+ { key:'range', label:'Range (e.g. A1:Z100)', type:'text' },
829
+ { key:'title', label:'Title (new sheet)', type:'text' },
830
+ ],
831
+ 'action.gemini_image': [
832
+ { key:'prompt', label:'Prompt', type:'textarea' },
833
+ { key:'outputPath', label:'Output path', type:'text' },
834
+ { key:'cdpPort', label:'CDP port', type:'text' },
835
+ ],
836
+ 'action.save_file': [
837
+ { key:'path', label:'File path', type:'text' },
838
+ { key:'field', label:'Data field', type:'text' },
839
+ { key:'encoding', label:'Encoding', type:'text' },
840
+ ],
841
+ 'service.notion': [
842
+ { key:'token', label:'Integration token', type:'text' },
843
+ { key:'operation', label:'Operation', type:'select', options:['get_page','create_page','update_page','query_database','get_database','create_database','append_blocks'] },
844
+ { key:'page_id', label:'Page ID', type:'text' },
845
+ { key:'database_id', label:'Database ID', type:'text' },
846
+ { key:'parent_id', label:'Parent ID', type:'text' },
847
+ { key:'properties', label:'Properties (JSON)', type:'textarea' },
848
+ ],
849
+ 'service.linear': [
850
+ { key:'token', label:'API token', type:'text' },
851
+ { key:'operation', label:'Operation', type:'select', options:['list_issues','get_issue','create_issue','update_issue','list_teams','list_projects'] },
852
+ { key:'team_id', label:'Team ID', type:'text' },
853
+ { key:'issue_id', label:'Issue ID', type:'text' },
854
+ { key:'title', label:'Title', type:'text' },
855
+ { key:'description', label:'Description', type:'textarea' },
856
+ { key:'priority', label:'Priority (0-4)', type:'text' },
857
+ { key:'state_id', label:'State ID', type:'text' },
858
+ ],
859
+ 'service.airtable': [
860
+ { key:'token', label:'Personal access token', type:'text' },
861
+ { key:'base_id', label:'Base ID', type:'text' },
862
+ { key:'table', label:'Table name', type:'text' },
863
+ { key:'operation', label:'Operation', type:'select', options:['list_records','get_record','create_record','update_record','delete_record'] },
864
+ { key:'record_id', label:'Record ID', type:'text' },
865
+ { key:'fields', label:'Fields (JSON)', type:'textarea' },
866
+ { key:'filter_formula', label:'Filter formula', type:'text' },
867
+ { key:'view', label:'View name', type:'text' },
868
+ ],
869
+ 'service.stripe': [
870
+ { key:'api_key', label:'Secret key', type:'text' },
871
+ { key:'operation', label:'Operation', type:'select', options:['list_customers','create_customer','get_customer','list_charges','create_charge','list_subscriptions','create_subscription','cancel_subscription','list_products','create_payment_intent'] },
872
+ { key:'customer_id', label:'Customer ID', type:'text' },
873
+ { key:'email', label:'Email', type:'text' },
874
+ { key:'name', label:'Name', type:'text' },
875
+ { key:'amount', label:'Amount (cents)', type:'text' },
876
+ { key:'currency', label:'Currency', type:'text' },
877
+ { key:'price_id', label:'Price ID', type:'text' },
878
+ { key:'subscription_id', label:'Subscription ID', type:'text' },
879
+ ],
880
+ 'core.aggregate': [
881
+ { key:'group_by', label:'Group by field', type:'text' },
882
+ { key:'operations', label:'Operations (JSON array)', type:'textarea' },
883
+ ],
884
+ 'core.sort': [
885
+ { key:'field', label:'Field to sort by', type:'text' },
886
+ { key:'order', label:'Order', type:'select', options:['asc','desc'] },
887
+ ],
888
+ 'core.limit': [
889
+ { key:'count', label:'Max items', type:'text' },
890
+ ],
891
+ 'core.merge': [
892
+ { key:'mode', label:'Mode', type:'select', options:['append','merge_by_field'] },
893
+ { key:'field', label:'Merge key field (for merge_by_field)', type:'text' },
894
+ ],
895
+ 'core.remove_duplicates': [
896
+ { key:'field', label:'Deduplicate by field (empty = all fields)', type:'text' },
897
+ ],
898
+ 'core.switch': [
899
+ { key:'field', label:'Field to switch on', type:'text' },
900
+ { key:'cases', label:'Case values (JSON array)', type:'textarea' },
901
+ ],
902
+ 'core.wait': [
903
+ { key:'seconds', label:'Seconds to wait', type:'text' },
904
+ ],
905
+ 'core.split_in_batches': [
906
+ { key:'batch_size', label:'Batch size', type:'text' },
907
+ ],
908
+ };
909
+
910
+ // ═══════════════════════════════════════════════════════════════════
911
+ // CANVAS STATE
912
+ // ═══════════════════════════════════════════════════════════════════
913
+ var canvas = {
914
+ nodes: [], // { id, type, name, config, x, y, _def, runStatus, runItems, runDuration }
915
+ edges: [], // { id, from, to, handle, fromPort, toPort }
916
+ zoom: 1,
917
+ panX: 0,
918
+ panY: 0,
919
+ selectedId: null,
920
+ playbookId: null,
921
+ };
922
+ var _nodeSeq = 1;
923
+
924
+ function uid() { return 'n' + (_nodeSeq++) + '_' + Date.now().toString(36); }
925
+
926
+ // Find node type def
927
+ function getNodeDef(type) {
928
+ return NODE_TYPES.find(function(t) { return t.type === type; }) || null;
333
929
  }
334
930
 
335
- // ── event handler ──────────────────────────────────────────
931
+ // ═══════════════════════════════════════════════════════════════════
932
+ // PALETTE
933
+ // ═══════════════════════════════════════════════════════════════════
934
+ function buildPalette(filter) {
935
+ filter = (filter || '').toLowerCase();
936
+ var list = document.getElementById('palette-list');
937
+ var html = '';
938
+ CAT_ORDER.forEach(function(cat) {
939
+ var nodes = NODE_TYPES.filter(function(n) {
940
+ return n.cat === cat && (!filter || n.label.toLowerCase().includes(filter) || n.type.toLowerCase().includes(filter));
941
+ });
942
+ if (!nodes.length) return;
943
+ html += '<div class="palette-cat-label">' + cat + '</div>';
944
+ nodes.forEach(function(n) {
945
+ html += '<div class="palette-node" draggable="true" data-type="' + n.type + '" data-label="' + esc(n.label) + '">' +
946
+ '<div class="palette-dot" style="background:' + n.color + '"></div>' +
947
+ '<span>' + esc(n.label) + '</span></div>';
948
+ });
949
+ });
950
+ list.innerHTML = html;
951
+
952
+ list.querySelectorAll('.palette-node').forEach(function(el) {
953
+ el.addEventListener('dragstart', function(e) {
954
+ e.dataTransfer.setData('text/plain', el.getAttribute('data-type'));
955
+ e.dataTransfer.effectAllowed = 'copy';
956
+ });
957
+ });
958
+ }
959
+
960
+ document.getElementById('palette-search').addEventListener('input', function(e) {
961
+ buildPalette(e.target.value);
962
+ });
963
+ buildPalette('');
964
+
965
+ // ═══════════════════════════════════════════════════════════════════
966
+ // CANVAS TRANSFORM
967
+ // ═══════════════════════════════════════════════════════════════════
968
+ function applyTransform() {
969
+ document.getElementById('canvas-nodes').style.transform =
970
+ 'translate(' + canvas.panX + 'px,' + canvas.panY + 'px) scale(' + canvas.zoom + ')';
971
+ renderEdges();
972
+ }
973
+
974
+ // Canvas pan + zoom
975
+ var _panning = false, _panStart = null;
976
+
977
+ document.getElementById('canvas-wrap').addEventListener('wheel', function(e) {
978
+ e.preventDefault();
979
+ var rect = this.getBoundingClientRect();
980
+ var mx = e.clientX - rect.left;
981
+ var my = e.clientY - rect.top;
982
+ var delta = e.deltaY < 0 ? 1.1 : 0.9;
983
+ var newZoom = Math.max(0.2, Math.min(3, canvas.zoom * delta));
984
+ // Zoom toward mouse position
985
+ canvas.panX = mx - (mx - canvas.panX) * (newZoom / canvas.zoom);
986
+ canvas.panY = my - (my - canvas.panY) * (newZoom / canvas.zoom);
987
+ canvas.zoom = newZoom;
988
+ applyTransform();
989
+ }, { passive: false });
990
+
991
+ document.getElementById('canvas-wrap').addEventListener('mousedown', function(e) {
992
+ if (e.button === 1 || (e.button === 0 && e.target === this)) {
993
+ _panning = true;
994
+ _panStart = { x: e.clientX - canvas.panX, y: e.clientY - canvas.panY };
995
+ e.preventDefault();
996
+ }
997
+ });
998
+ document.addEventListener('mousemove', function(e) {
999
+ if (_panning) {
1000
+ canvas.panX = e.clientX - _panStart.x;
1001
+ canvas.panY = e.clientY - _panStart.y;
1002
+ applyTransform();
1003
+ }
1004
+ if (_dragEdge.active) updateDragEdge(e);
1005
+ if (_draggingNode) updateNodeDrag(e);
1006
+ });
1007
+ document.addEventListener('mouseup', function(e) {
1008
+ _panning = false;
1009
+ if (_draggingNode) endNodeDrag(e);
1010
+ if (_dragEdge.active) endDragEdge(e);
1011
+ });
1012
+
1013
+ // Deselect on canvas click
1014
+ document.getElementById('canvas-wrap').addEventListener('mousedown', function(e) {
1015
+ if (e.target === this || e.target === document.getElementById('canvas-svg')) {
1016
+ canvas.selectedId = null;
1017
+ renderPropsPanel();
1018
+ renderNodes();
1019
+ }
1020
+ });
1021
+
1022
+ // Drop from palette
1023
+ document.getElementById('canvas-wrap').addEventListener('dragover', function(e) { e.preventDefault(); });
1024
+ document.getElementById('canvas-wrap').addEventListener('drop', function(e) {
1025
+ e.preventDefault();
1026
+ var type = e.dataTransfer.getData('text/plain');
1027
+ if (!type) return;
1028
+ var def = getNodeDef(type);
1029
+ if (!def) return;
1030
+ var rect = this.getBoundingClientRect();
1031
+ var wx = (e.clientX - rect.left - canvas.panX) / canvas.zoom;
1032
+ var wy = (e.clientY - rect.top - canvas.panY) / canvas.zoom;
1033
+ addNode(type, wx - 100, wy - 20);
1034
+ });
1035
+
1036
+ // Zoom buttons
1037
+ document.getElementById('btn-zoom-in').addEventListener('click', function() {
1038
+ canvas.zoom = Math.min(3, canvas.zoom * 1.2);
1039
+ applyTransform();
1040
+ });
1041
+ document.getElementById('btn-zoom-out').addEventListener('click', function() {
1042
+ canvas.zoom = Math.max(0.2, canvas.zoom / 1.2);
1043
+ applyTransform();
1044
+ });
1045
+ document.getElementById('btn-fit').addEventListener('click', function() {
1046
+ fitCanvas();
1047
+ });
1048
+
1049
+ function fitCanvas() {
1050
+ if (!canvas.nodes.length) { canvas.zoom = 1; canvas.panX = 40; canvas.panY = 40; applyTransform(); return; }
1051
+ var wrap = document.getElementById('canvas-wrap');
1052
+ var W = wrap.clientWidth, H = wrap.clientHeight;
1053
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1054
+ canvas.nodes.forEach(function(n) {
1055
+ minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
1056
+ maxX = Math.max(maxX, n.x + 200); maxY = Math.max(maxY, n.y + nodeH(n));
1057
+ });
1058
+ var pw = maxX - minX + 80, ph = maxY - minY + 80;
1059
+ var zoom = Math.min(1, Math.min(W / pw, H / ph));
1060
+ canvas.zoom = zoom;
1061
+ canvas.panX = (W - pw * zoom) / 2 - minX * zoom + 40;
1062
+ canvas.panY = (H - ph * zoom) / 2 - minY * zoom + 40;
1063
+ applyTransform();
1064
+ }
1065
+
1066
+ function nodeH(n) {
1067
+ var def = n._def || getNodeDef(n.type) || { inputs: [{}], outputs: [{}] };
1068
+ var rows = Math.max(def.inputs.length, def.outputs.length, 1);
1069
+ return 40 + 6 + rows * 26 + 6;
1070
+ }
1071
+
1072
+ // ═══════════════════════════════════════════════════════════════════
1073
+ // ADD / REMOVE NODE
1074
+ // ═══════════════════════════════════════════════════════════════════
1075
+ function addNode(type, x, y) {
1076
+ var def = getNodeDef(type);
1077
+ if (!def) return;
1078
+ var node = {
1079
+ id: uid(),
1080
+ type: type,
1081
+ name: def.label,
1082
+ config: Object.assign({}, def.defaults),
1083
+ x: x || 100,
1084
+ y: y || 100,
1085
+ _def: def,
1086
+ runStatus: null,
1087
+ runItems: 0,
1088
+ runDuration: null,
1089
+ };
1090
+ canvas.nodes.push(node);
1091
+ canvas.selectedId = node.id;
1092
+ renderNodes();
1093
+ renderEdges();
1094
+ renderPropsPanel();
1095
+ return node;
1096
+ }
1097
+
1098
+ function removeNode(id) {
1099
+ canvas.nodes = canvas.nodes.filter(function(n) { return n.id !== id; });
1100
+ canvas.edges = canvas.edges.filter(function(e) { return e.from !== id && e.to !== id; });
1101
+ if (canvas.selectedId === id) { canvas.selectedId = null; renderPropsPanel(); }
1102
+ renderNodes();
1103
+ renderEdges();
1104
+ }
1105
+
1106
+ // ═══════════════════════════════════════════════════════════════════
1107
+ // NODE DRAG
1108
+ // ═══════════════════════════════════════════════════════════════════
1109
+ var _draggingNode = null;
1110
+ var _dragNodeStart = null;
1111
+
1112
+ function startNodeDrag(e, nodeId) {
1113
+ e.stopPropagation();
1114
+ _draggingNode = nodeId;
1115
+ var n = canvas.nodes.find(function(n) { return n.id === nodeId; });
1116
+ _dragNodeStart = {
1117
+ mx: e.clientX, my: e.clientY,
1118
+ nx: n.x, ny: n.y,
1119
+ };
1120
+ canvas.selectedId = nodeId;
1121
+ renderPropsPanel();
1122
+ renderNodes();
1123
+ }
1124
+ function updateNodeDrag(e) {
1125
+ if (!_draggingNode) return;
1126
+ var dx = (e.clientX - _dragNodeStart.mx) / canvas.zoom;
1127
+ var dy = (e.clientY - _dragNodeStart.my) / canvas.zoom;
1128
+ var n = canvas.nodes.find(function(n) { return n.id === _draggingNode; });
1129
+ if (n) { n.x = _dragNodeStart.nx + dx; n.y = _dragNodeStart.ny + dy; }
1130
+ renderNodePosition(_draggingNode);
1131
+ renderEdges();
1132
+ }
1133
+ function endNodeDrag(e) {
1134
+ _draggingNode = null;
1135
+ _dragNodeStart = null;
1136
+ }
1137
+ function renderNodePosition(id) {
1138
+ var el = document.querySelector('[data-node-id="' + id + '"]');
1139
+ var n = canvas.nodes.find(function(n) { return n.id === id; });
1140
+ if (el && n) { el.style.left = n.x + 'px'; el.style.top = n.y + 'px'; }
1141
+ }
1142
+
1143
+ // ═══════════════════════════════════════════════════════════════════
1144
+ // EDGE DRAG
1145
+ // ═══════════════════════════════════════════════════════════════════
1146
+ var _dragEdge = { active: false, fromNodeId: null, fromPortIdx: null, x: 0, y: 0 };
1147
+
1148
+ function startDragEdge(e, nodeId, portIdx) {
1149
+ e.stopPropagation();
1150
+ e.preventDefault();
1151
+ _dragEdge.active = true;
1152
+ _dragEdge.fromNodeId = nodeId;
1153
+ _dragEdge.fromPortIdx = portIdx;
1154
+ var pos = getOutPortPos(nodeId, portIdx);
1155
+ _dragEdge.x = pos.x; _dragEdge.y = pos.y;
1156
+ var path = document.getElementById('drag-edge');
1157
+ path.style.display = '';
1158
+ }
1159
+ function updateDragEdge(e) {
1160
+ var wrap = document.getElementById('canvas-wrap');
1161
+ var rect = wrap.getBoundingClientRect();
1162
+ var tx = (e.clientX - rect.left - canvas.panX) / canvas.zoom;
1163
+ var ty = (e.clientY - rect.top - canvas.panY) / canvas.zoom;
1164
+ var sx = _dragEdge.x, sy = _dragEdge.y;
1165
+ var dx = Math.max(60, Math.abs(tx - sx) * 0.5);
1166
+ document.getElementById('drag-edge').setAttribute('d',
1167
+ 'M' + sx + ',' + sy + ' C' + (sx+dx) + ',' + sy + ' ' + (tx-dx) + ',' + ty + ' ' + tx + ',' + ty);
1168
+ }
1169
+ function endDragEdge(e) {
1170
+ document.getElementById('drag-edge').style.display = 'none';
1171
+ _dragEdge.active = false;
1172
+ }
1173
+ function dropOnInputPort(e, toNodeId, toPortIdx) {
1174
+ if (!_dragEdge.active) return;
1175
+ e.stopPropagation();
1176
+ if (_dragEdge.fromNodeId === toNodeId) { endDragEdge(e); return; }
1177
+ var fromNode = canvas.nodes.find(function(n) { return n.id === _dragEdge.fromNodeId; });
1178
+ var def = fromNode && (fromNode._def || getNodeDef(fromNode.type));
1179
+ var handle = def && def.outputs[_dragEdge.fromPortIdx] ? def.outputs[_dragEdge.fromPortIdx].label : undefined;
1180
+ // Remove existing edge from same out port if any
1181
+ canvas.edges = canvas.edges.filter(function(ed) {
1182
+ return !(ed.from === _dragEdge.fromNodeId && ed.fromPort === _dragEdge.fromPortIdx && ed.to === toNodeId && ed.toPort === toPortIdx);
1183
+ });
1184
+ canvas.edges.push({
1185
+ id: uid(),
1186
+ from: _dragEdge.fromNodeId,
1187
+ to: toNodeId,
1188
+ handle: handle,
1189
+ fromPort: _dragEdge.fromPortIdx,
1190
+ toPort: toPortIdx,
1191
+ });
1192
+ endDragEdge(e);
1193
+ renderEdges();
1194
+ }
1195
+
1196
+ function getOutPortPos(nodeId, portIdx) {
1197
+ var n = canvas.nodes.find(function(n) { return n.id === nodeId; });
1198
+ if (!n) return {x:0,y:0};
1199
+ return { x: n.x + 200, y: n.y + 40 + 6 + portIdx * 26 + 13 };
1200
+ }
1201
+ function getInPortPos(nodeId, portIdx) {
1202
+ var n = canvas.nodes.find(function(n) { return n.id === nodeId; });
1203
+ if (!n) return {x:0,y:0};
1204
+ return { x: n.x, y: n.y + 40 + 6 + portIdx * 26 + 13 };
1205
+ }
1206
+
1207
+ // ═══════════════════════════════════════════════════════════════════
1208
+ // RENDER NODES
1209
+ // ═══════════════════════════════════════════════════════════════════
1210
+ function renderNodes() {
1211
+ var container = document.getElementById('canvas-nodes');
1212
+ var existingIds = new Set();
1213
+ canvas.nodes.forEach(function(n) { existingIds.add(n.id); });
1214
+ // Remove nodes that no longer exist
1215
+ container.querySelectorAll('[data-node-id]').forEach(function(el) {
1216
+ if (!existingIds.has(el.getAttribute('data-node-id'))) el.remove();
1217
+ });
1218
+ // Add or update each node
1219
+ canvas.nodes.forEach(function(n) {
1220
+ var existing = container.querySelector('[data-node-id="' + n.id + '"]');
1221
+ var def = n._def || getNodeDef(n.type) || { inputs:[{label:'in'}], outputs:[{label:'out'}], color:'#7c3aed' };
1222
+ var color = def.color || '#7c3aed';
1223
+ var rows = Math.max(def.inputs.length, def.outputs.length, 1);
1224
+ var h = 40 + 6 + rows * 26 + 6;
1225
+ var isSelected = canvas.selectedId === n.id;
1226
+
1227
+ if (!existing) {
1228
+ var el = document.createElement('div');
1229
+ el.className = 'cn' + (isSelected ? ' selected' : '') + (n.runStatus === 'running' ? ' running' : '');
1230
+ el.setAttribute('data-node-id', n.id);
1231
+ el.style.left = n.x + 'px';
1232
+ el.style.top = n.y + 'px';
1233
+ el.style.height = h + 'px';
1234
+ el.style.borderColor = isSelected ? color : (n.runStatus === 'error' ? '#ef444444' : n.runStatus === 'ok' ? '#10b98133' : 'rgba(124,58,237,0.15)');
1235
+
1236
+ // Status badge
1237
+ var statusHtml = '';
1238
+ if (n.runStatus) {
1239
+ var statusText = n.runStatus === 'ok' ? '✓ ' + n.runItems + ' item' + (n.runItems !== 1 ? 's' : '') + (n.runDuration ? ' · ' + n.runDuration + 'ms' : '')
1240
+ : n.runStatus === 'running' ? '⏳ running'
1241
+ : n.runStatus === 'error' ? '✕ error'
1242
+ : '— skipped';
1243
+ statusHtml = '<div class="cn-status ' + n.runStatus + '">' + esc(statusText) + '</div>';
1244
+ }
1245
+
1246
+ // Header
1247
+ var headerHtml = '<div class="cn-header" style="background:linear-gradient(110deg,' + color + '1a 0%,' + color + '0a 100%);border-bottom:1px solid ' + color + '22">' +
1248
+ '<div class="cn-dot" style="background:' + color + '"></div>' +
1249
+ '<span class="cn-label">' + esc(n.name || def.label) + '</span>' +
1250
+ '<button class="cn-del" data-del="' + n.id + '" title="Delete">✕</button>' +
1251
+ '</div>';
1252
+
1253
+ // Ports
1254
+ var portsHtml = '<div class="cn-ports">';
1255
+ for (var i = 0; i < rows; i++) {
1256
+ var inPort = def.inputs[i];
1257
+ var outPort = def.outputs[i];
1258
+ portsHtml += '<div class="cn-row">';
1259
+ if (inPort) {
1260
+ portsHtml += '<div class="cn-port-area">' +
1261
+ '<div class="cn-port in" data-in-port="' + i + '" data-node="' + n.id + '" title="Input: ' + esc(inPort.label) + '"></div>' +
1262
+ '<span class="cn-port-label">' + esc(inPort.label) + '</span>' +
1263
+ '</div>';
1264
+ } else {
1265
+ portsHtml += '<div></div>';
1266
+ }
1267
+ if (outPort) {
1268
+ portsHtml += '<div class="cn-port-area" style="justify-content:flex-end">' +
1269
+ '<span class="cn-port-label">' + esc(outPort.label) + '</span>' +
1270
+ '<div class="cn-port out" data-out-port="' + i + '" data-node="' + n.id + '" title="Output: ' + esc(outPort.label) + '"></div>' +
1271
+ '</div>';
1272
+ } else {
1273
+ portsHtml += '<div></div>';
1274
+ }
1275
+ portsHtml += '</div>';
1276
+ }
1277
+ portsHtml += '</div>';
1278
+
1279
+ el.innerHTML = statusHtml + headerHtml + portsHtml;
1280
+
1281
+ // Events
1282
+ el.querySelector('.cn-header').addEventListener('mousedown', function(e) {
1283
+ if (e.target.closest('.cn-del')) return;
1284
+ startNodeDrag(e, n.id);
1285
+ });
1286
+ el.addEventListener('mousedown', function(e) {
1287
+ if (e.target.closest('.cn-del') || e.target.closest('.cn-port')) return;
1288
+ e.stopPropagation();
1289
+ canvas.selectedId = n.id;
1290
+ renderPropsPanel();
1291
+ renderNodes();
1292
+ });
1293
+ var delBtn = el.querySelector('.cn-del');
1294
+ if (delBtn) delBtn.addEventListener('mousedown', function(e) {
1295
+ e.stopPropagation();
1296
+ removeNode(n.id);
1297
+ });
1298
+ el.querySelectorAll('.cn-port.out').forEach(function(port) {
1299
+ port.addEventListener('mousedown', function(e) {
1300
+ e.stopPropagation();
1301
+ var portIdx = parseInt(port.getAttribute('data-out-port'), 10);
1302
+ startDragEdge(e, n.id, portIdx);
1303
+ });
1304
+ });
1305
+ el.querySelectorAll('.cn-port.in').forEach(function(port) {
1306
+ port.addEventListener('mouseup', function(e) {
1307
+ var portIdx = parseInt(port.getAttribute('data-in-port'), 10);
1308
+ dropOnInputPort(e, n.id, portIdx);
1309
+ });
1310
+ port.addEventListener('mouseenter', function() { port.classList.add('highlight'); });
1311
+ port.addEventListener('mouseleave', function() { port.classList.remove('highlight'); });
1312
+ });
1313
+
1314
+ container.appendChild(el);
1315
+ } else {
1316
+ // Update position, selection, status
1317
+ existing.style.left = n.x + 'px';
1318
+ existing.style.top = n.y + 'px';
1319
+ existing.className = 'cn' + (isSelected ? ' selected' : '') + (n.runStatus === 'running' ? ' running' : '');
1320
+ existing.style.borderColor = isSelected ? color : (n.runStatus === 'error' ? '#ef444444' : n.runStatus === 'ok' ? '#10b98133' : 'rgba(124,58,237,0.15)');
1321
+ // Update status badge
1322
+ var badge = existing.querySelector('.cn-status');
1323
+ if (n.runStatus) {
1324
+ var statusText2 = n.runStatus === 'ok' ? '✓ ' + n.runItems + ' item' + (n.runItems !== 1 ? 's' : '') + (n.runDuration ? ' · ' + n.runDuration + 'ms' : '')
1325
+ : n.runStatus === 'running' ? '⏳ running'
1326
+ : n.runStatus === 'error' ? '✕ error'
1327
+ : '— skipped';
1328
+ if (!badge) {
1329
+ var newBadge = document.createElement('div');
1330
+ newBadge.className = 'cn-status ' + n.runStatus;
1331
+ newBadge.textContent = statusText2;
1332
+ existing.insertBefore(newBadge, existing.firstChild);
1333
+ } else {
1334
+ badge.className = 'cn-status ' + n.runStatus;
1335
+ badge.textContent = statusText2;
1336
+ }
1337
+ } else if (badge) {
1338
+ badge.remove();
1339
+ }
1340
+ }
1341
+ });
1342
+ }
1343
+
1344
+ // ═══════════════════════════════════════════════════════════════════
1345
+ // RENDER EDGES (SVG)
1346
+ // ═══════════════════════════════════════════════════════════════════
1347
+ function renderEdges() {
1348
+ var layer = document.getElementById('edge-layer');
1349
+ layer.innerHTML = '';
1350
+ canvas.edges.forEach(function(edge) {
1351
+ var fromNode = canvas.nodes.find(function(n) { return n.id === edge.from; });
1352
+ var toNode = canvas.nodes.find(function(n) { return n.id === edge.to; });
1353
+ if (!fromNode || !toNode) return;
1354
+ var sx = (fromNode.x + 200) * canvas.zoom + canvas.panX;
1355
+ var sy = (fromNode.y + 40 + 6 + edge.fromPort * 26 + 13) * canvas.zoom + canvas.panY;
1356
+ var tx = toNode.x * canvas.zoom + canvas.panX;
1357
+ var ty = (toNode.y + 40 + 6 + edge.toPort * 26 + 13) * canvas.zoom + canvas.panY;
1358
+ var dx = Math.max(60, Math.abs(tx - sx) * 0.5) * canvas.zoom;
1359
+ var d = 'M' + sx + ',' + sy + ' C' + (sx+dx) + ',' + sy + ' ' + (tx-dx) + ',' + ty + ' ' + tx + ',' + ty;
1360
+ var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1361
+ path.setAttribute('d', d);
1362
+ path.setAttribute('stroke', '#7c3aed88');
1363
+ path.setAttribute('stroke-width', '1.5');
1364
+ path.setAttribute('fill', 'none');
1365
+ path.setAttribute('marker-end', 'url(#arrowhead)');
1366
+ // Add label if handle differs from default
1367
+ layer.appendChild(path);
1368
+
1369
+ // Remove edge on click
1370
+ (function(edgeId, midX, midY) {
1371
+ path.style.cursor = 'pointer';
1372
+ path.style.strokeWidth = '4';
1373
+ path.style.stroke = 'transparent';
1374
+ var hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1375
+ hit.setAttribute('d', d);
1376
+ hit.setAttribute('stroke', 'transparent');
1377
+ hit.setAttribute('stroke-width', '8');
1378
+ hit.setAttribute('fill', 'none');
1379
+ hit.style.cursor = 'pointer';
1380
+ hit.addEventListener('click', function() {
1381
+ canvas.edges = canvas.edges.filter(function(e) { return e.id !== edgeId; });
1382
+ renderEdges();
1383
+ });
1384
+ layer.appendChild(hit);
1385
+ })(edge.id, (sx + tx) / 2, (sy + ty) / 2);
1386
+ });
1387
+ }
1388
+
1389
+ // ═══════════════════════════════════════════════════════════════════
1390
+ // PROPERTIES PANEL
1391
+ // ═══════════════════════════════════════════════════════════════════
1392
+ function renderPropsPanel() {
1393
+ var body = document.getElementById('props-body');
1394
+ var node = canvas.nodes.find(function(n) { return n.id === canvas.selectedId; });
1395
+ if (!node) {
1396
+ body.innerHTML = '<div class="empty" style="padding:20px;color:#333;font-size:11px;text-align:center">Select a node to edit its properties</div>';
1397
+ return;
1398
+ }
1399
+ var fields = NODE_FIELDS[node.type] || [];
1400
+ var html = '<div class="prop-row"><div class="prop-label">Node name</div>' +
1401
+ '<input class="prop-input" id="prop-node-name" value="' + esc(node.name || '') + '" /></div>';
1402
+ html += '<div class="prop-row"><div class="prop-label">On error</div>' +
1403
+ '<select class="prop-select" id="prop-on-error">' +
1404
+ ['stop','skip'].map(function(v) {
1405
+ return '<option value="' + v + '"' + (node.onError === v ? ' selected' : '') + '>' + v + '</option>';
1406
+ }).join('') + '</select></div>';
1407
+
1408
+ fields.forEach(function(f) {
1409
+ var val = node.config[f.key] !== undefined ? String(node.config[f.key]) : '';
1410
+ html += '<div class="prop-row"><div class="prop-label">' + esc(f.label) + '</div>';
1411
+ if (f.type === 'select') {
1412
+ html += '<select class="prop-select" data-key="' + f.key + '">' +
1413
+ (f.options || []).map(function(o) {
1414
+ return '<option value="' + esc(o) + '"' + (val === o ? ' selected' : '') + '>' + esc(o) + '</option>';
1415
+ }).join('') + '</select>';
1416
+ } else if (f.type === 'textarea') {
1417
+ html += '<textarea class="prop-textarea" data-key="' + f.key + '">' + esc(val) + '</textarea>';
1418
+ } else {
1419
+ html += '<input class="prop-input" data-key="' + f.key + '" value="' + esc(val) + '" />';
1420
+ }
1421
+ html += '</div>';
1422
+ });
1423
+
1424
+ body.innerHTML = html;
1425
+
1426
+ body.querySelector('#prop-node-name').addEventListener('input', function(e) {
1427
+ node.name = e.target.value;
1428
+ var el = document.querySelector('[data-node-id="' + node.id + '"] .cn-label');
1429
+ if (el) el.textContent = node.name;
1430
+ });
1431
+ body.querySelector('#prop-on-error').addEventListener('change', function(e) {
1432
+ node.onError = e.target.value;
1433
+ });
1434
+ body.querySelectorAll('[data-key]').forEach(function(el) {
1435
+ el.addEventListener('input', function() {
1436
+ node.config[el.getAttribute('data-key')] = el.value;
1437
+ });
1438
+ el.addEventListener('change', function() {
1439
+ node.config[el.getAttribute('data-key')] = el.value;
1440
+ });
1441
+ });
1442
+ }
1443
+
1444
+ // ═══════════════════════════════════════════════════════════════════
1445
+ // PLAYBOOK SERIALIZATION
1446
+ // ═══════════════════════════════════════════════════════════════════
1447
+ function toPlaybookDef() {
1448
+ var id = canvas.playbookId || ('pb_' + Date.now().toString(36));
1449
+ canvas.playbookId = id;
1450
+ var name = document.getElementById('pb-name-input').value.trim() || 'Untitled Playbook';
1451
+ return {
1452
+ id: id,
1453
+ name: name,
1454
+ nodes: canvas.nodes.map(function(n) {
1455
+ return { id: n.id, type: n.type, name: n.name, config: n.config, onError: n.onError || 'stop' };
1456
+ }),
1457
+ connections: canvas.edges.map(function(e) {
1458
+ var res = { from: e.from, to: e.to };
1459
+ if (e.handle && e.handle !== 'main' && e.handle !== 'out' && e.handle !== 'items') res.handle = e.handle;
1460
+ return res;
1461
+ }),
1462
+ };
1463
+ }
1464
+
1465
+ function loadPlaybookDef(pb) {
1466
+ canvas.nodes = [];
1467
+ canvas.edges = [];
1468
+ canvas.playbookId = pb.id;
1469
+ canvas.selectedId = null;
1470
+ document.getElementById('pb-name-input').value = pb.name || 'Untitled';
1471
+ // Clear existing nodes from DOM
1472
+ document.getElementById('canvas-nodes').innerHTML = '';
1473
+
1474
+ // Lay out nodes in a simple column if no positions stored
1475
+ var x = 100, y = 60;
1476
+ (pb.nodes || []).forEach(function(n, i) {
1477
+ var def = getNodeDef(n.type) || { inputs:[{label:'in'}], outputs:[{label:'out'}], color:'#7c3aed', label: n.type };
1478
+ var node = {
1479
+ id: n.id,
1480
+ type: n.type,
1481
+ name: n.name || def.label,
1482
+ config: n.config || {},
1483
+ onError: n.onError,
1484
+ x: n._x || (x + (i % 3) * 260),
1485
+ y: n._y || (y + Math.floor(i / 3) * 140),
1486
+ _def: def,
1487
+ runStatus: null,
1488
+ runItems: 0,
1489
+ runDuration: null,
1490
+ };
1491
+ canvas.nodes.push(node);
1492
+ });
1493
+
1494
+ // Build edges from connections
1495
+ (pb.connections || []).forEach(function(c) {
1496
+ var fromNode = canvas.nodes.find(function(n) { return n.id === c.from; });
1497
+ var toNode = canvas.nodes.find(function(n) { return n.id === c.to; });
1498
+ if (!fromNode || !toNode) return;
1499
+ var fromDef = fromNode._def || { outputs: [{label:'main'}] };
1500
+ var handle = c.handle || (fromDef.outputs[0] ? fromDef.outputs[0].label : 'main');
1501
+ var fromPortIdx = fromDef.outputs.findIndex(function(o) { return o.label === handle; });
1502
+ if (fromPortIdx < 0) fromPortIdx = 0;
1503
+ canvas.edges.push({
1504
+ id: uid(),
1505
+ from: c.from,
1506
+ to: c.to,
1507
+ handle: c.handle,
1508
+ fromPort: fromPortIdx,
1509
+ toPort: 0,
1510
+ });
1511
+ });
1512
+
1513
+ renderNodes();
1514
+ renderEdges();
1515
+ renderPropsPanel();
1516
+ setTimeout(fitCanvas, 50);
1517
+ }
1518
+
1519
+ // ═══════════════════════════════════════════════════════════════════
1520
+ // TOOLBAR ACTIONS
1521
+ // ═══════════════════════════════════════════════════════════════════
1522
+ document.getElementById('btn-new').addEventListener('click', function() {
1523
+ canvas.nodes = [];
1524
+ canvas.edges = [];
1525
+ canvas.playbookId = null;
1526
+ canvas.selectedId = null;
1527
+ document.getElementById('canvas-nodes').innerHTML = '';
1528
+ document.getElementById('pb-name-input').value = 'Untitled Playbook';
1529
+ renderNodes();
1530
+ renderEdges();
1531
+ renderPropsPanel();
1532
+ });
1533
+
1534
+ document.getElementById('btn-save').addEventListener('click', function() {
1535
+ var pb = toPlaybookDef();
1536
+ fetch('/api/playbooks', {
1537
+ method: 'POST',
1538
+ headers: { 'Content-Type': 'application/json' },
1539
+ body: JSON.stringify(pb),
1540
+ }).then(function(r) { return r.json(); }).then(function(saved) {
1541
+ canvas.playbookId = saved.id;
1542
+ showToast('Playbook saved: ' + saved.name);
1543
+ }).catch(function(e) {
1544
+ showToast('Save failed: ' + e, true);
1545
+ });
1546
+ });
1547
+
1548
+ document.getElementById('btn-open').addEventListener('click', function() {
1549
+ openPlaybookList();
1550
+ });
1551
+
1552
+ document.getElementById('btn-run').addEventListener('click', function() {
1553
+ if (!canvas.playbookId) {
1554
+ // Auto-save first
1555
+ var pb = toPlaybookDef();
1556
+ fetch('/api/playbooks', {
1557
+ method: 'POST',
1558
+ headers: { 'Content-Type': 'application/json' },
1559
+ body: JSON.stringify(pb),
1560
+ }).then(function() {
1561
+ runCurrentPlaybook(pb.id);
1562
+ }).catch(function(e) { showToast('Error: ' + e, true); });
1563
+ } else {
1564
+ // Save then run
1565
+ var pb2 = toPlaybookDef();
1566
+ fetch('/api/playbooks', {
1567
+ method: 'POST',
1568
+ headers: { 'Content-Type': 'application/json' },
1569
+ body: JSON.stringify(pb2),
1570
+ }).then(function() {
1571
+ runCurrentPlaybook(pb2.id);
1572
+ }).catch(function(e) { showToast('Error: ' + e, true); });
1573
+ }
1574
+ });
1575
+
1576
+ function runCurrentPlaybook(playbookId) {
1577
+ // Reset node statuses
1578
+ canvas.nodes.forEach(function(n) { n.runStatus = null; n.runItems = 0; n.runDuration = null; });
1579
+ renderNodes();
1580
+ fetch('/api/playbooks/' + playbookId + '/run', { method: 'POST' })
1581
+ .then(function(r) { return r.json(); })
1582
+ .then(function(res) {
1583
+ if (res.ok) {
1584
+ showToast('Playbook started — watch the Runs tab');
1585
+ // Switch to runs panel
1586
+ document.querySelectorAll('.nav-item').forEach(function(n) { n.classList.remove('active'); });
1587
+ document.querySelector('[data-panel="playbooks"]').classList.add('active');
1588
+ document.querySelectorAll('.panel').forEach(function(p) { p.classList.remove('active'); });
1589
+ document.getElementById('pb-panel').classList.add('active');
1590
+ }
1591
+ })
1592
+ .catch(function(e) { showToast('Run failed: ' + e, true); });
1593
+ }
1594
+
1595
+ // ═══════════════════════════════════════════════════════════════════
1596
+ // PLAYBOOK LIST OVERLAY
1597
+ // ═══════════════════════════════════════════════════════════════════
1598
+ function openPlaybookList() {
1599
+ var overlay = document.getElementById('pb-list-overlay');
1600
+ overlay.classList.add('show');
1601
+ fetch('/api/playbooks')
1602
+ .then(function(r) { return r.json(); })
1603
+ .then(function(list) {
1604
+ var body = document.getElementById('pblo-body');
1605
+ if (!list.length) {
1606
+ body.innerHTML = '<div class="pblo-empty">No saved playbooks yet. Build one and click Save!</div>';
1607
+ return;
1608
+ }
1609
+ body.innerHTML = list.map(function(pb) {
1610
+ return '<div class="pblo-item" data-pb-id="' + esc(pb.id) + '">' +
1611
+ '<span class="pblo-item-name">' + esc(pb.name) + '</span>' +
1612
+ '<span class="pblo-item-meta">' + (pb.nodes ? pb.nodes.length : 0) + ' nodes</span>' +
1613
+ '<button class="pblo-del" data-del-id="' + esc(pb.id) + '" title="Delete">✕</button>' +
1614
+ '</div>';
1615
+ }).join('');
1616
+ body.querySelectorAll('.pblo-item').forEach(function(el) {
1617
+ el.addEventListener('click', function(e) {
1618
+ if (e.target.closest('.pblo-del')) return;
1619
+ var pbId = el.getAttribute('data-pb-id');
1620
+ fetch('/api/playbooks/' + pbId).then(function(r) { return r.json(); }).then(function(pb) {
1621
+ loadPlaybookDef(pb);
1622
+ overlay.classList.remove('show');
1623
+ });
1624
+ });
1625
+ });
1626
+ body.querySelectorAll('.pblo-del').forEach(function(btn) {
1627
+ btn.addEventListener('click', function(e) {
1628
+ e.stopPropagation();
1629
+ var delId = btn.getAttribute('data-del-id');
1630
+ if (!confirm('Delete this playbook?')) return;
1631
+ fetch('/api/playbooks/' + delId, { method: 'DELETE' }).then(function() {
1632
+ if (canvas.playbookId === delId) {
1633
+ canvas.playbookId = null;
1634
+ canvas.nodes = [];
1635
+ canvas.edges = [];
1636
+ canvas.selectedId = null;
1637
+ document.getElementById('canvas-nodes').innerHTML = '';
1638
+ document.getElementById('pb-name-input').value = 'Untitled Playbook';
1639
+ renderNodes();
1640
+ renderEdges();
1641
+ renderPropsPanel();
1642
+ }
1643
+ openPlaybookList(); // refresh
1644
+ });
1645
+ });
1646
+ });
1647
+ })
1648
+ .catch(function() {
1649
+ document.getElementById('pblo-body').innerHTML = '<div class="pblo-empty">Failed to load playbooks.</div>';
1650
+ });
1651
+ }
1652
+
1653
+ document.getElementById('pblo-close-btn').addEventListener('click', function() {
1654
+ document.getElementById('pb-list-overlay').classList.remove('show');
1655
+ });
1656
+
1657
+ // ═══════════════════════════════════════════════════════════════════
1658
+ // TOAST
1659
+ // ═══════════════════════════════════════════════════════════════════
1660
+ function showToast(msg, isError) {
1661
+ var t = document.createElement('div');
1662
+ t.textContent = msg;
1663
+ t.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:9999;padding:8px 14px;border-radius:6px;font-size:11px;font-family:monospace;max-width:320px;pointer-events:none;transition:opacity .3s;' +
1664
+ (isError ? 'background:#ef444420;border:1px solid #ef444466;color:#ef4444;' : 'background:#7c3aed20;border:1px solid #7c3aed66;color:#c4b5fd;');
1665
+ document.body.appendChild(t);
1666
+ setTimeout(function() { t.style.opacity = '0'; setTimeout(function() { t.remove(); }, 300); }, 2500);
1667
+ }
1668
+
1669
+ // ═══════════════════════════════════════════════════════════════════
1670
+ // LIVE EVENTS → update node statuses in builder
1671
+ // ═══════════════════════════════════════════════════════════════════
1672
+ function handleBuilderEvent(evt) {
1673
+ if (!evt.nodeId) return;
1674
+ var node = canvas.nodes.find(function(n) { return n.id === evt.nodeId; });
1675
+ if (!node) return;
1676
+ if (evt.eventType === 'step_started') {
1677
+ node.runStatus = 'running';
1678
+ } else if (evt.eventType === 'step_completed') {
1679
+ node.runStatus = 'ok';
1680
+ node.runItems = evt.itemTotal || 0;
1681
+ node.runDuration = evt.durationMs || null;
1682
+ } else if (evt.eventType === 'step_failed') {
1683
+ node.runStatus = 'error';
1684
+ }
1685
+ // Only re-render if builder is active
1686
+ var builderPanel = document.getElementById('builder-panel');
1687
+ if (builderPanel.classList.contains('active')) {
1688
+ renderNodes();
1689
+ }
1690
+ }
1691
+
1692
+ // ═══════════════════════════════════════════════════════════════════
1693
+ // EVENT HANDLER (runs + builder)
1694
+ // ═══════════════════════════════════════════════════════════════════
336
1695
  function handleEvent(evt) {
1696
+ // Route to builder status
1697
+ handleBuilderEvent(evt);
1698
+
337
1699
  var id = evt.runId;
338
1700
  if (!id) return;
339
1701
  if (!runs[id]) {
340
- runs[id] = { id: id, workflowId: evt.workflowId, workflowName: evt.workflowName || id,
1702
+ runs[id] = { id: id, playbookId: evt.playbookId, playbookName: evt.playbookName || id,
341
1703
  status: 'running', startedAt: evt.timestamp || Date.now(),
342
1704
  itemsProcessed: 0, itemsTotal: 0 };
343
1705
  }
@@ -358,19 +1720,26 @@ function handleEvent(evt) {
358
1720
  } else if (evt.eventType === 'run_completed') {
359
1721
  r.status = 'completed';
360
1722
  r.endedAt = evt.timestamp || Date.now();
1723
+ // Clear running status on nodes
1724
+ canvas.nodes.forEach(function(n) {
1725
+ if (n.runStatus === 'running') { n.runStatus = null; }
1726
+ });
1727
+ } else if (evt.eventType === 'run_failed') {
1728
+ r.status = 'failed';
1729
+ r.endedAt = evt.timestamp || Date.now();
361
1730
  } else if (evt.eventType === 'run_stopped') {
362
1731
  r.status = evt.error ? 'failed' : 'stopped';
363
1732
  r.endedAt = evt.timestamp || Date.now();
364
1733
  }
365
1734
 
366
- // Trim log
367
1735
  if (runLogs[id].length > 80) runLogs[id] = runLogs[id].slice(-80);
368
-
369
1736
  updateHeader();
370
1737
  renderCards();
371
1738
  }
372
1739
 
373
- // ── connection ─────────────────────────────────────────────
1740
+ // ═══════════════════════════════════════════════════════════════════
1741
+ // CONNECTION
1742
+ // ═══════════════════════════════════════════════════════════════════
374
1743
  var connDot = document.getElementById('conn-dot');
375
1744
  var connLabel = document.getElementById('conn-label');
376
1745
 
@@ -412,7 +1781,6 @@ function connect() {
412
1781
  if (msg.type === 'history') {
413
1782
  msg.runs.forEach(function(r) {
414
1783
  runs[r.id] = r;
415
- // Rebuild minimal log from existing run
416
1784
  if (!runLogs[r.id]) runLogs[r.id] = [];
417
1785
  });
418
1786
  updateHeader();
@@ -437,7 +1805,6 @@ function connect() {
437
1805
 
438
1806
  // ── init ───────────────────────────────────────────────────
439
1807
  connect();
440
- // Refresh elapsed times every 5s for running jobs
441
1808
  setInterval(function() { if (Object.values(runs).some(function(r){return r.status==='running';})) renderCards(); }, 5000);
442
1809
  </script>
443
1810
  </body>