voyageai-cli 1.29.0 → 1.30.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.
Files changed (45) hide show
  1. package/README.md +82 -8
  2. package/package.json +1 -1
  3. package/src/commands/benchmark.js +22 -8
  4. package/src/commands/chat.js +18 -0
  5. package/src/commands/chunk.js +10 -0
  6. package/src/commands/demo.js +4 -0
  7. package/src/commands/embed.js +13 -0
  8. package/src/commands/estimate.js +3 -0
  9. package/src/commands/eval.js +6 -0
  10. package/src/commands/explain.js +2 -0
  11. package/src/commands/generate.js +2 -0
  12. package/src/commands/ingest.js +4 -0
  13. package/src/commands/init.js +2 -0
  14. package/src/commands/mcp-server.js +2 -0
  15. package/src/commands/models.js +2 -0
  16. package/src/commands/ping.js +7 -0
  17. package/src/commands/pipeline.js +15 -0
  18. package/src/commands/playground.js +52 -6
  19. package/src/commands/query.js +16 -0
  20. package/src/commands/rerank.js +12 -0
  21. package/src/commands/scaffold.js +2 -0
  22. package/src/commands/search.js +11 -0
  23. package/src/commands/similarity.js +9 -0
  24. package/src/commands/store.js +4 -0
  25. package/src/commands/workflow.js +286 -0
  26. package/src/lib/capability-report.js +134 -0
  27. package/src/lib/chat.js +32 -1
  28. package/src/lib/config.js +2 -0
  29. package/src/lib/cost-display.js +107 -0
  30. package/src/lib/explanations.js +6 -0
  31. package/src/lib/llm.js +125 -18
  32. package/src/lib/quality-audit.js +71 -0
  33. package/src/lib/security/blocked-domains.json +17 -0
  34. package/src/lib/security-audit.js +198 -0
  35. package/src/lib/telemetry.js +23 -1
  36. package/src/lib/workflow-scaffold.js +61 -0
  37. package/src/lib/workflow-test-runner.js +208 -0
  38. package/src/lib/workflow.js +128 -2
  39. package/src/playground/announcements.md +9 -0
  40. package/src/playground/assets/announcements/appstore.jpg +0 -0
  41. package/src/playground/assets/announcements/circuits.jpg +0 -0
  42. package/src/playground/assets/announcements/csvingest.jpg +0 -0
  43. package/src/playground/assets/announcements/green-wave.jpg +0 -0
  44. package/src/playground/help/workflow-nodes.js +472 -0
  45. package/src/playground/index.html +1482 -184
@@ -350,6 +350,79 @@ body {
350
350
  flex-direction: column;
351
351
  overflow: hidden;
352
352
  z-index: 100;
353
+ transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1), min-width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
354
+ }
355
+ .sidebar.collapsed {
356
+ width: 56px;
357
+ min-width: 56px;
358
+ }
359
+ .sidebar.collapsed .sidebar-title,
360
+ .sidebar.collapsed .sidebar-nav-label,
361
+ .sidebar.collapsed .sidebar-nav-divider,
362
+ .sidebar.collapsed .status-label,
363
+ .sidebar.collapsed .sidebar-bug-label,
364
+ .sidebar.collapsed .sidebar-docs-link span,
365
+ .sidebar.collapsed .sidebar-model-group,
366
+ .sidebar.collapsed .sidebar-footer > div:last-child { display: none; }
367
+ .sidebar.collapsed .sidebar-footer { overflow: hidden; padding: 8px 0; align-items: center; border-top: none; }
368
+ .sidebar.collapsed .sidebar-footer > div:first-child { justify-content: center; }
369
+ .sidebar.collapsed .sidebar-footer .theme-toggle { display: none; }
370
+ .sidebar.collapsed #appVersionLabel { display: none; }
371
+ .sidebar.collapsed .sidebar-docs-link { display: none; }
372
+ .sidebar.collapsed .tab-btn {
373
+ justify-content: center;
374
+ padding: 10px;
375
+ border-left-width: 2px;
376
+ }
377
+ .sidebar.collapsed .tab-btn span:not(.tab-btn-icon) { display: none; }
378
+ .sidebar.collapsed .sidebar-settings-btn { display: none; }
379
+ .sidebar.collapsed .sidebar-drag-region { justify-content: center; padding: 0; padding-top: 52px; }
380
+ body:not(.is-electron) .sidebar.collapsed .sidebar-drag-region { padding-top: 16px; padding-bottom: 12px; }
381
+
382
+ /* Sidebar collapse toggle */
383
+ .sidebar-collapse-btn {
384
+ -webkit-app-region: no-drag;
385
+ background: none;
386
+ border: none;
387
+ cursor: pointer;
388
+ color: var(--text-muted);
389
+ padding: 4px;
390
+ border-radius: 4px;
391
+ display: flex;
392
+ align-items: center;
393
+ justify-content: center;
394
+ transition: all 0.15s;
395
+ flex-shrink: 0;
396
+ font-size: 14px;
397
+ line-height: 1;
398
+ }
399
+ .sidebar-collapse-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
400
+ [data-theme="light"] .sidebar-collapse-btn:hover { background: rgba(0,0,0,0.04); }
401
+ .sidebar.collapsed .sidebar-collapse-btn { transform: rotate(180deg); }
402
+
403
+ /* Sidebar tooltip for collapsed mode */
404
+ .sidebar.collapsed .tab-btn {
405
+ position: relative;
406
+ }
407
+ .sidebar.collapsed .tab-btn::after {
408
+ content: attr(data-tooltip);
409
+ position: absolute;
410
+ left: calc(100% + 8px);
411
+ top: 50%;
412
+ transform: translateY(-50%);
413
+ background: #2d333b;
414
+ color: #e6edf3;
415
+ font-size: 12px;
416
+ padding: 4px 8px;
417
+ border-radius: 4px;
418
+ white-space: nowrap;
419
+ pointer-events: none;
420
+ opacity: 0;
421
+ transition: opacity 0.15s 0.15s;
422
+ z-index: 1000;
423
+ }
424
+ .sidebar.collapsed .tab-btn:hover::after {
425
+ opacity: 1;
353
426
  }
354
427
 
355
428
  .sidebar-drag-region {
@@ -484,6 +557,7 @@ body:not(.is-electron) .sidebar-drag-region {
484
557
 
485
558
  .sidebar-footer {
486
559
  padding: 12px 16px;
560
+ padding-bottom: 56px; /* Clear the fixed cost status bar */
487
561
  border-top: 1px solid var(--border);
488
562
  display: flex;
489
563
  flex-direction: column;
@@ -2568,7 +2642,10 @@ select:focus { outline: none; border-color: var(--accent); }
2568
2642
  .chat-thinking .thinking-icon {
2569
2643
  font-size: 14px;
2570
2644
  line-height: 1;
2645
+ display: flex;
2646
+ align-items: center;
2571
2647
  }
2648
+ .chat-thinking .thinking-icon svg { color: var(--accent); }
2572
2649
  .chat-thinking .thinking-label {
2573
2650
  font-weight: 500;
2574
2651
  }
@@ -2626,6 +2703,7 @@ select:focus { outline: none; border-color: var(--accent); }
2626
2703
  background: var(--bg-input, #112733);
2627
2704
  border: 1px solid var(--border);
2628
2705
  }
2706
+ .thinking-step-icon svg { color: var(--text-dim); }
2629
2707
  .thinking-step.active .thinking-step-icon {
2630
2708
  border-color: var(--accent);
2631
2709
  animation: thinkingPulse 1.2s ease-in-out infinite;
@@ -3020,10 +3098,15 @@ select:focus { outline: none; border-color: var(--accent); }
3020
3098
  min-height: 0;
3021
3099
  }
3022
3100
  .wf-library {
3023
- width: 220px; min-width: 180px; flex-shrink: 0;
3101
+ width: 220px; min-width: 0; flex-shrink: 0;
3024
3102
  border-right: 1px solid var(--border);
3025
3103
  display: flex; flex-direction: column;
3026
3104
  background: var(--bg);
3105
+ transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
3106
+ overflow: hidden;
3107
+ }
3108
+ .wf-library.collapsed {
3109
+ width: 0; border-right: none;
3027
3110
  }
3028
3111
  .wf-library-header {
3029
3112
  padding: 14px 16px 10px; font-weight: 700; font-size: 13px;
@@ -3086,6 +3169,88 @@ select:focus { outline: none; border-color: var(--accent); }
3086
3169
  font-size: 10px; font-weight: 600; color: var(--text-muted);
3087
3170
  padding: 8px 12px 2px; text-transform: uppercase; letter-spacing: 0.5px;
3088
3171
  }
3172
+ /* ── Library Search ── */
3173
+ .wf-library-search-wrap {
3174
+ padding: 8px 12px 4px; position: relative;
3175
+ }
3176
+ .wf-library-search-wrap input {
3177
+ width: 100%; padding: 6px 28px 6px 10px; border-radius: 6px;
3178
+ border: 1px solid var(--border); background: var(--bg-card);
3179
+ color: var(--text); font-size: 12px; outline: none;
3180
+ box-sizing: border-box;
3181
+ transition: border-color 0.15s;
3182
+ }
3183
+ .wf-library-search-wrap input:focus {
3184
+ border-color: var(--accent);
3185
+ }
3186
+ .wf-library-search-wrap input::placeholder {
3187
+ color: var(--text-muted);
3188
+ }
3189
+ .wf-library-search-clear {
3190
+ position: absolute; right: 18px; top: 50%; transform: translateY(-50%);
3191
+ background: none; border: none; color: var(--text-muted); cursor: pointer;
3192
+ font-size: 14px; padding: 2px 4px; line-height: 1; display: none;
3193
+ }
3194
+ .wf-library-search-clear:hover { color: var(--text); }
3195
+ /* ── Collapsible Library Sections ── */
3196
+ .wf-lib-section-header {
3197
+ display: flex; align-items: center; gap: 6px;
3198
+ width: 100%; padding: 8px 12px; border: none; background: none;
3199
+ color: var(--text-muted); font-size: 10px; font-weight: 600;
3200
+ text-transform: uppercase; letter-spacing: 1.2px; cursor: pointer;
3201
+ transition: color 0.15s;
3202
+ }
3203
+ .wf-lib-section-header:hover { color: var(--text); }
3204
+ .wf-lib-section-header .wf-chevron {
3205
+ font-size: 10px; transition: transform 0.2s; display: inline-block;
3206
+ }
3207
+ .wf-lib-section-header.expanded .wf-chevron { transform: rotate(90deg); }
3208
+ .wf-lib-section-header .wf-section-count {
3209
+ color: var(--text-muted); font-size: 10px; font-weight: 400;
3210
+ }
3211
+ .wf-lib-section-body {
3212
+ overflow: hidden; transition: max-height 0.2s ease-out;
3213
+ }
3214
+ /* ── Properties Accordion ── */
3215
+ .wf-accordion-header {
3216
+ display: flex; align-items: center; justify-content: space-between;
3217
+ padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border);
3218
+ transition: background-color 0.15s ease; user-select: none;
3219
+ }
3220
+ .wf-accordion-header.expanded {
3221
+ background: rgba(0,212,170,0.05);
3222
+ }
3223
+ .wf-accordion-header .wf-acc-left {
3224
+ display: flex; align-items: center; gap: 6px;
3225
+ font-size: 12px; font-weight: 600; letter-spacing: 0.5px;
3226
+ color: var(--text);
3227
+ }
3228
+ .wf-accordion-header .wf-acc-left .wf-chevron {
3229
+ font-size: 10px; transition: transform 0.2s; display: inline-block;
3230
+ }
3231
+ .wf-accordion-header.expanded .wf-acc-left .wf-chevron { transform: rotate(90deg); }
3232
+ .wf-accordion-header .wf-acc-summary {
3233
+ font-size: 10px; color: var(--text-muted); font-weight: 400;
3234
+ }
3235
+ .wf-accordion-body {
3236
+ overflow: hidden; transition: max-height 0.2s ease-out;
3237
+ }
3238
+ .wf-accordion-body-inner {
3239
+ padding: 12px;
3240
+ }
3241
+ .wf-step-row {
3242
+ display: flex; align-items: center; justify-content: space-between;
3243
+ padding: 4px 0; font-size: 12px;
3244
+ }
3245
+ .wf-step-dot {
3246
+ width: 8px; height: 8px; border-radius: 50%; display: inline-block;
3247
+ margin-right: 8px; flex-shrink: 0;
3248
+ }
3249
+ .wf-step-name { flex: 1; color: var(--text); }
3250
+ .wf-step-time {
3251
+ font-family: 'SF Mono', 'Fira Code', monospace; font-size: 10px;
3252
+ color: var(--text-muted); margin-left: 8px;
3253
+ }
3089
3254
  .wf-canvas-area {
3090
3255
  flex: 1; position: relative; overflow: hidden;
3091
3256
  background: var(--bg);
@@ -3093,8 +3258,8 @@ select:focus { outline: none; border-color: var(--accent); }
3093
3258
  background-size: 20px 20px;
3094
3259
  }
3095
3260
  .wf-canvas-toolbar {
3096
- position: absolute; top: 12px; right: 12px; z-index: 10;
3097
- display: flex; gap: 4px;
3261
+ position: absolute; top: 12px; left: 12px; right: 12px; z-index: 10;
3262
+ display: flex; gap: 4px; align-items: center;
3098
3263
  }
3099
3264
  .wf-canvas-toolbar button {
3100
3265
  width: 32px; height: 32px; border-radius: 8px;
@@ -3165,6 +3330,119 @@ select:focus { outline: none; border-color: var(--accent); }
3165
3330
  .wf-palette-icon { width: 20px; text-align: center; display: flex; align-items: center; justify-content: center; }
3166
3331
  .wf-palette-label { font-weight: 500; }
3167
3332
 
3333
+ /* ── Workflow Node Help ── */
3334
+ .wf-palette-item { position: relative; }
3335
+ .wf-help-trigger {
3336
+ position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
3337
+ width: 20px; height: 20px; border-radius: 50%;
3338
+ border: 1px solid var(--border); background: var(--bg);
3339
+ color: var(--text-muted); cursor: pointer;
3340
+ display: flex; align-items: center; justify-content: center;
3341
+ opacity: 0; transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
3342
+ padding: 0; z-index: 2;
3343
+ }
3344
+ .wf-palette-item:hover .wf-help-trigger { opacity: 1; }
3345
+ .wf-help-trigger:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
3346
+ .wf-help-trigger svg { width: 12px; height: 12px; }
3347
+
3348
+ .wf-inspector-help-btn {
3349
+ display: inline-flex; align-items: center; gap: 4px;
3350
+ padding: 3px 8px; border-radius: 4px;
3351
+ border: 1px solid var(--border); background: var(--bg);
3352
+ color: var(--text-muted); font-size: 11px; cursor: pointer;
3353
+ transition: background 0.15s ease, color 0.15s ease;
3354
+ margin-left: 8px; vertical-align: middle;
3355
+ }
3356
+ .wf-inspector-help-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
3357
+ .wf-inspector-help-btn svg { width: 12px; height: 12px; }
3358
+
3359
+ .wf-help-modal-backdrop {
3360
+ position: fixed; inset: 0; z-index: 10000;
3361
+ background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
3362
+ display: flex; align-items: center; justify-content: center;
3363
+ animation: wfStoreDetailFadeIn 0.12s ease-out;
3364
+ }
3365
+ .wf-help-modal {
3366
+ background: var(--bg); border: 1px solid var(--border);
3367
+ border-radius: 12px; width: 540px; max-width: 92vw;
3368
+ max-height: 82vh; display: flex; flex-direction: column;
3369
+ box-shadow: 0 16px 48px rgba(0,0,0,0.3);
3370
+ animation: wfStoreDetailFadeIn 0.12s ease-out;
3371
+ }
3372
+ .wf-help-modal-header {
3373
+ display: flex; align-items: center; gap: 10px;
3374
+ padding: 16px 20px; border-bottom: 1px solid var(--border);
3375
+ flex-shrink: 0;
3376
+ }
3377
+ .wf-help-modal-header .wf-help-icon {
3378
+ width: 32px; height: 32px; border-radius: 8px;
3379
+ display: flex; align-items: center; justify-content: center;
3380
+ flex-shrink: 0;
3381
+ }
3382
+ .wf-help-modal-header .wf-help-title { font-size: 16px; font-weight: 600; flex: 1; }
3383
+ .wf-help-modal-header .wf-help-category-badge {
3384
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
3385
+ letter-spacing: 0.5px; padding: 2px 8px; border-radius: 4px;
3386
+ background: var(--bg-card); color: var(--text-muted); flex-shrink: 0;
3387
+ }
3388
+ .wf-help-modal-header .wf-help-close-btn {
3389
+ width: 28px; height: 28px; border-radius: 6px;
3390
+ border: 1px solid var(--border); background: var(--bg);
3391
+ color: var(--text-muted); cursor: pointer;
3392
+ display: flex; align-items: center; justify-content: center;
3393
+ flex-shrink: 0; padding: 0; transition: background 0.15s ease, color 0.15s ease;
3394
+ }
3395
+ .wf-help-modal-header .wf-help-close-btn:hover { background: var(--bg-card); color: var(--text); }
3396
+
3397
+ .wf-help-modal-body {
3398
+ overflow-y: auto; padding: 20px; flex: 1;
3399
+ }
3400
+ .wf-help-section { margin-bottom: 20px; }
3401
+ .wf-help-section:last-child { margin-bottom: 0; }
3402
+ .wf-help-section-title {
3403
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
3404
+ letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 8px;
3405
+ }
3406
+ .wf-help-section p {
3407
+ font-size: 13px; line-height: 1.6; color: var(--text); margin: 0;
3408
+ }
3409
+
3410
+ .wf-help-io-table {
3411
+ width: 100%; border-collapse: collapse; font-size: 12px;
3412
+ }
3413
+ .wf-help-io-table th {
3414
+ text-align: left; padding: 6px 8px; font-weight: 600;
3415
+ color: var(--text-muted); border-bottom: 1px solid var(--border);
3416
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.3px;
3417
+ }
3418
+ .wf-help-io-table td {
3419
+ padding: 6px 8px; border-bottom: 1px solid var(--border);
3420
+ color: var(--text); vertical-align: top;
3421
+ }
3422
+ .wf-help-io-table tr:last-child td { border-bottom: none; }
3423
+ .wf-help-io-table .wf-help-key {
3424
+ font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px;
3425
+ color: var(--accent); white-space: nowrap;
3426
+ }
3427
+ .wf-help-io-table .wf-help-type {
3428
+ font-size: 10px; color: var(--text-muted); white-space: nowrap;
3429
+ }
3430
+ .wf-help-io-table .wf-help-required {
3431
+ display: inline-block; font-size: 9px; font-weight: 600;
3432
+ color: #ff6b6b; background: rgba(255,107,107,0.1);
3433
+ padding: 1px 4px; border-radius: 3px; margin-left: 4px;
3434
+ }
3435
+
3436
+ .wf-help-tip {
3437
+ display: flex; gap: 8px; margin-bottom: 8px;
3438
+ font-size: 13px; line-height: 1.5; color: var(--text);
3439
+ }
3440
+ .wf-help-tip:last-child { margin-bottom: 0; }
3441
+ .wf-help-tip-dot {
3442
+ width: 6px; height: 6px; border-radius: 50%;
3443
+ background: var(--accent); flex-shrink: 0; margin-top: 7px;
3444
+ }
3445
+
3168
3446
  /* ── Workflow Store ── */
3169
3447
  .wf-store-overlay {
3170
3448
  position: absolute; inset: 0; background: var(--bg); z-index: 50;
@@ -3263,6 +3541,30 @@ select:focus { outline: none; border-color: var(--accent); }
3263
3541
  .wf-store-card-badges { display: flex; gap: 4px; }
3264
3542
  .wf-store-badge-official { font-family: var(--mono); font-size: 9px; padding: 2px 7px; border-radius: 100px; background: rgba(0,212,170,.1); color: var(--accent); border: 1px solid rgba(0,212,170,.15); white-space: nowrap; }
3265
3543
  .wf-store-badge-installed { font-family: var(--mono); font-size: 9px; padding: 2px 7px; border-radius: 100px; background: rgba(16,185,129,.1); color: #10B981; border: 1px solid rgba(16,185,129,.15); white-space: nowrap; }
3544
+ .wf-store-badge-verified { font-family: var(--mono); font-size: 9px; padding: 2px 7px; border-radius: 100px; background: rgba(16,185,129,.1); color: #10B981; border: 1px solid rgba(16,185,129,.15); white-space: nowrap; }
3545
+ .wf-store-badge-capability { font-family: var(--mono); font-size: 9px; padding: 2px 6px; border-radius: 100px; white-space: nowrap; display: inline-flex; align-items: center; gap: 3px; }
3546
+ .wf-store-badge-cap-network { background: rgba(59,130,246,.1); color: #3B82F6; border: 1px solid rgba(59,130,246,.15); }
3547
+ .wf-store-badge-cap-writedb { background: rgba(245,158,11,.1); color: #F59E0B; border: 1px solid rgba(245,158,11,.15); }
3548
+ .wf-store-badge-cap-llm { background: rgba(139,92,246,.1); color: #8B5CF6; border: 1px solid rgba(139,92,246,.15); }
3549
+ .wf-store-badge-cap-loop { background: rgba(6,182,212,.1); color: #06B6D4; border: 1px solid rgba(6,182,212,.15); }
3550
+ .wf-store-badge-cap-readdb { background: rgba(34,197,94,.1); color: #22C55E; border: 1px solid rgba(34,197,94,.15); }
3551
+ .wf-store-detail-security-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--bg-card); border-radius: 6px; margin-bottom: 4px; font-size: 12px; color: var(--text-dim); }
3552
+ .wf-store-detail-security-sev { font-family: var(--mono); font-size: 9px; padding: 2px 7px; border-radius: 100px; font-weight: 700; text-transform: uppercase; }
3553
+ .wf-store-detail-security-sev.critical { background: rgba(239,68,68,.15); color: #EF4444; }
3554
+ .wf-store-detail-security-sev.high { background: rgba(249,115,22,.15); color: #F97316; }
3555
+ .wf-store-detail-security-sev.medium { background: rgba(234,179,8,.15); color: #EAB308; }
3556
+ .wf-store-detail-security-sev.low { background: rgba(148,163,184,.15); color: #94A3B8; }
3557
+ .wf-store-detail-security-ok { color: #10B981; font-size: 12px; padding: 8px 10px; background: rgba(16,185,129,.06); border-radius: 6px; }
3558
+ .wf-store-detail-stars { display: inline-flex; gap: 2px; cursor: pointer; }
3559
+ .wf-store-detail-stars span { font-size: 18px; color: var(--text-muted); transition: color 0.1s; }
3560
+ .wf-store-detail-stars span.filled { color: #F59E0B; }
3561
+ .wf-store-detail-stars span:hover, .wf-store-detail-stars span:hover ~ span { color: var(--text-muted); }
3562
+ .wf-store-report-form { margin-top: 8px; padding: 12px; background: var(--bg-card); border-radius: 8px; border: 1px solid var(--border); }
3563
+ .wf-store-report-form select, .wf-store-report-form textarea { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 8px; color: var(--text); font-family: var(--font); font-size: 12px; margin-bottom: 8px; }
3564
+ .wf-store-report-form textarea { resize: vertical; min-height: 60px; }
3565
+ .wf-store-report-submit { padding: 6px 14px; background: var(--error); color: #fff; border: none; border-radius: 6px; font-size: 11px; font-weight: 600; cursor: pointer; }
3566
+ .wf-store-report-submit:hover { filter: brightness(1.1); }
3567
+ .wf-store-cap-badges { display: flex; gap: 3px; flex-wrap: wrap; margin-top: 4px; }
3266
3568
  .wf-store-card-desc { font-size: 12px; color: var(--text-dim); line-height: 1.5; margin-bottom: 4px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
3267
3569
  .wf-store-card-author { font-size: 11px; color: var(--text-muted); margin-bottom: 10px; }
3268
3570
  .wf-store-card-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
@@ -3283,11 +3585,14 @@ select:focus { outline: none; border-color: var(--accent); }
3283
3585
  @keyframes wfStoreDetailSlideIn { from { transform: translateY(20px); opacity: 0; } }
3284
3586
  .wf-store-detail-panel {
3285
3587
  background: var(--bg-surface); border: 1px solid var(--border); border-radius: 18px;
3286
- width: 100%; max-width: 580px; max-height: 82vh; overflow-y: auto;
3287
- animation: wfStoreDetailSlideIn 0.18s ease-out;
3588
+ width: 100%; max-width: 580px; max-height: 82vh; overflow: hidden;
3589
+ animation: wfStoreDetailSlideIn 0.18s ease-out; display: flex; flex-direction: column;
3288
3590
  }
3289
- .wf-store-detail-panel::-webkit-scrollbar { width: 4px; }
3290
- .wf-store-detail-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
3591
+ .wf-store-detail-scroll {
3592
+ overflow-y: auto; flex: 1; min-height: 0;
3593
+ }
3594
+ .wf-store-detail-scroll::-webkit-scrollbar { width: 4px; }
3595
+ .wf-store-detail-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
3291
3596
  .wf-store-detail-hero { padding: 28px 28px 20px; border-radius: 18px 18px 0 0; position: relative; }
3292
3597
  .wf-store-detail-hero::before { content: ''; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 25%, var(--bg-surface) 100%); border-radius: 18px 18px 0 0; }
3293
3598
  .wf-store-detail-hero-inner { position: relative; z-index: 1; display: flex; align-items: center; gap: 14px; }
@@ -3633,38 +3938,76 @@ select:focus { outline: none; border-color: var(--accent); }
3633
3938
  .wf-edge--else { stroke-dasharray: 6 4; opacity: 0.4; }
3634
3939
  .wf-edge--skipped { opacity: 0.12; stroke-dasharray: 4 4; }
3635
3940
  /* Inspector */
3941
+ /* ── Library collapse chevron ── */
3942
+ .wf-library-collapse-btn {
3943
+ width: 28px; height: 28px; border: none; background: none;
3944
+ color: var(--text-muted); cursor: pointer; font-size: 14px;
3945
+ display: flex; align-items: center; justify-content: center;
3946
+ border-radius: 6px; transition: color 0.15s, background 0.15s;
3947
+ flex-shrink: 0; padding: 0;
3948
+ }
3949
+ .wf-library-collapse-btn:hover { color: var(--text); background: var(--bg-card); }
3950
+
3951
+ /* ── Canvas edge handles ── */
3952
+ .wf-edge-handle {
3953
+ position: absolute; top: 50%; transform: translateY(-50%);
3954
+ width: 28px; height: 56px; z-index: 20;
3955
+ background: var(--bg-card); border: 1px solid var(--border);
3956
+ color: var(--text-muted); cursor: pointer;
3957
+ display: none; align-items: center; justify-content: center;
3958
+ font-size: 14px; transition: background 0.15s, color 0.15s;
3959
+ }
3960
+ .wf-edge-handle:hover { background: var(--bg); color: var(--text); }
3961
+ .wf-edge-handle--left {
3962
+ left: 0; border-radius: 0 8px 8px 0; border-left: none;
3963
+ }
3964
+ .wf-edge-handle--right {
3965
+ right: 0; border-radius: 8px 0 0 8px; border-right: none;
3966
+ }
3967
+ .wf-edge-handle.visible { display: flex; }
3968
+
3969
+ /* ── Toolbar panel toggle group ── */
3970
+ .wf-toolbar-spacer { flex: 1; }
3971
+ .wf-toolbar-toggle-group {
3972
+ display: flex; background: var(--bg); border-radius: 6px; padding: 2px;
3973
+ border: 1px solid var(--border);
3974
+ }
3975
+ .wf-toolbar-toggle-group button {
3976
+ width: auto !important; height: 28px !important; padding: 0 10px !important;
3977
+ border: none !important; border-radius: 4px !important;
3978
+ background: transparent !important; color: var(--text-muted) !important;
3979
+ font-size: 11px !important; font-weight: 500 !important; cursor: pointer;
3980
+ transition: background 0.15s, color 0.15s;
3981
+ }
3982
+ .wf-toolbar-toggle-group button.active {
3983
+ background: rgba(0,212,170,0.15) !important; color: var(--accent) !important;
3984
+ }
3985
+ .wf-toolbar-toggle-group button:hover:not(.active) {
3986
+ color: var(--text) !important;
3987
+ }
3988
+
3989
+ /* ── Inspector (upgraded) ── */
3636
3990
  .wf-inspector {
3637
3991
  flex-shrink: 0; position: relative;
3638
3992
  display: flex; flex-direction: row;
3639
3993
  background: var(--bg);
3640
- transition: width 0.25s ease;
3994
+ transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
3641
3995
  width: 300px;
3642
3996
  overflow: hidden;
3643
3997
  align-self: stretch;
3998
+ border-left: 1px solid var(--border);
3644
3999
  }
3645
4000
  .wf-inspector.collapsed {
3646
- width: 28px;
4001
+ width: 0; border-left: none;
3647
4002
  }
3648
4003
  .wf-inspector-toggle {
3649
- width: 28px; flex-shrink: 0; align-self: stretch;
3650
- border: none; border-left: 1px solid var(--border);
3651
- background: var(--bg); color: var(--text-muted);
3652
- cursor: pointer; font-size: 14px; padding: 0;
3653
- display: flex; align-items: center; justify-content: center;
3654
- transition: color 0.15s, background 0.15s;
3655
- }
3656
- .wf-inspector-toggle:hover {
3657
- color: var(--text); background: var(--bg-card);
3658
- }
3659
- .wf-inspector.collapsed .wf-inspector-toggle {
3660
- border-left: 1px solid var(--border);
4004
+ display: none; /* replaced by edge handle */
3661
4005
  }
3662
4006
  .wf-inspector.collapsed .wf-inspector-content {
3663
4007
  display: none;
3664
4008
  }
3665
4009
  .wf-inspector-content {
3666
4010
  flex: 1; display: flex; flex-direction: column;
3667
- border-left: 1px solid var(--border);
3668
4011
  overflow-y: auto; min-width: 0; height: 100%;
3669
4012
  }
3670
4013
  .wf-inspector-header {
@@ -3731,11 +4074,11 @@ select:focus { outline: none; border-color: var(--accent); }
3731
4074
  .wf-inspector-result.error { border-color: #e74c3c; }
3732
4075
  /* Responsive */
3733
4076
  @media (max-width: 900px) {
3734
- .wf-library { display: none; }
4077
+ .wf-library { width: 0; border-right: none; }
3735
4078
  .wf-inspector:not(.collapsed) { width: 240px; }
3736
4079
  }
3737
4080
  @media (max-width: 600px) {
3738
- .wf-inspector { display: none; }
4081
+ .wf-inspector { width: 0; border-left: none; }
3739
4082
  }
3740
4083
  /* Workflow visualizer light mode */
3741
4084
  [data-theme="light"] .wf-node-label { fill: #001E2B; }
@@ -3913,18 +4256,25 @@ select:focus { outline: none; border-color: var(--accent); }
3913
4256
  border-radius: 16px;
3914
4257
  background: linear-gradient(135deg, var(--bg-card), var(--bg-surface));
3915
4258
  border: 1px solid var(--border);
4259
+ min-height: 180px;
3916
4260
  }
3917
4261
 
3918
4262
  .home-announcement-card {
3919
- display: none;
4263
+ position: absolute;
4264
+ top: 0;
4265
+ left: 0;
4266
+ right: 0;
3920
4267
  padding: 32px;
3921
4268
  text-align: center;
3922
- position: relative;
4269
+ opacity: 0;
4270
+ pointer-events: none;
4271
+ transition: opacity 0.5s ease-in-out;
3923
4272
  }
3924
4273
 
3925
4274
  .home-announcement-card.active {
3926
- display: block;
3927
- animation: fadeIn 0.5s ease-out;
4275
+ position: relative;
4276
+ opacity: 1;
4277
+ pointer-events: auto;
3928
4278
  }
3929
4279
 
3930
4280
  .home-announcement-card .badge {
@@ -4010,6 +4360,72 @@ select:focus { outline: none; border-color: var(--accent); }
4010
4360
  background: linear-gradient(135deg, #00ED64, #00C2FF);
4011
4361
  }
4012
4362
 
4363
+ .home-announcements-restore {
4364
+ text-align: center;
4365
+ margin-top: 12px;
4366
+ }
4367
+
4368
+ .home-announcements-restore button {
4369
+ background: var(--bg-card);
4370
+ border: 1px dashed var(--border);
4371
+ color: var(--text-dim);
4372
+ font-size: 13px;
4373
+ cursor: pointer;
4374
+ padding: 8px 16px;
4375
+ border-radius: 8px;
4376
+ transition: all 0.2s;
4377
+ }
4378
+
4379
+ .home-announcements-restore button:hover {
4380
+ color: var(--accent-text);
4381
+ border-color: var(--accent-text);
4382
+ background: var(--bg-surface);
4383
+ }
4384
+
4385
+ .home-announcement-card.has-bg-image {
4386
+ background-size: cover;
4387
+ background-position: center;
4388
+ background-repeat: no-repeat;
4389
+ min-height: 220px;
4390
+ display: flex;
4391
+ flex-direction: column;
4392
+ justify-content: center;
4393
+ align-items: center;
4394
+ }
4395
+
4396
+ .home-announcement-card.has-bg-image::before {
4397
+ content: '';
4398
+ position: absolute;
4399
+ inset: 0;
4400
+ background: rgba(0, 0, 0, 0.5);
4401
+ border-radius: inherit;
4402
+ z-index: 0;
4403
+ }
4404
+
4405
+ .home-announcement-card.has-bg-image > * {
4406
+ position: relative;
4407
+ z-index: 1;
4408
+ }
4409
+
4410
+ .home-announcement-card.has-bg-image h3,
4411
+ .home-announcement-card.has-bg-image p {
4412
+ text-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
4413
+ }
4414
+
4415
+ .home-announcement-card.has-bg-image h3 {
4416
+ color: #fff;
4417
+ }
4418
+
4419
+ .home-announcement-card.has-bg-image p {
4420
+ color: rgba(255, 255, 255, 0.9);
4421
+ }
4422
+
4423
+ .home-announcement-icon {
4424
+ font-size: 48px;
4425
+ margin-bottom: 12px;
4426
+ line-height: 1;
4427
+ }
4428
+
4013
4429
  /* Section Headers */
4014
4430
  .home-section {
4015
4431
  margin-bottom: 48px;
@@ -4520,6 +4936,42 @@ select:focus { outline: none; border-color: var(--accent); }
4520
4936
  <symbol id="lg-pulse" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4521
4937
  <path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36-3.18-19.64A2 2 0 0 0 10.12 1h-.24a2 2 0 0 0-1.94 1.55L5.18 12H2"/>
4522
4938
  </symbol>
4939
+ <!-- Brain (Thinking) -->
4940
+ <symbol id="lg-brain" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4941
+ <path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/><path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/><path d="M3.477 10.896a4 4 0 0 1 .585-.396"/><path d="M19.938 10.5a4 4 0 0 1 .585.396"/><path d="M6 18a4 4 0 0 1-1.967-.516"/><path d="M19.967 17.484A4 4 0 0 1 18 18"/>
4942
+ </symbol>
4943
+ <!-- Sparkles -->
4944
+ <symbol id="lg-sparkles" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4945
+ <path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>
4946
+ </symbol>
4947
+ <!-- Target (Similarity) -->
4948
+ <symbol id="lg-target" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4949
+ <circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>
4950
+ </symbol>
4951
+ <!-- Database (Collections) -->
4952
+ <symbol id="lg-database" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4953
+ <ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/>
4954
+ </symbol>
4955
+ <!-- Bot (Models) -->
4956
+ <symbol id="lg-bot" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4957
+ <path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>
4958
+ </symbol>
4959
+ <!-- ArrowUpDown (Rerank) -->
4960
+ <symbol id="lg-arrow-up-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4961
+ <path d="m21 16-4 4-4-4"/><path d="M17 20V4"/><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/>
4962
+ </symbol>
4963
+ <!-- Coins (Cost) -->
4964
+ <symbol id="lg-coins" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4965
+ <circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/>
4966
+ </symbol>
4967
+ <!-- Inbox (Ingest) -->
4968
+ <symbol id="lg-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4969
+ <polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
4970
+ </symbol>
4971
+ <!-- SearchCode (Vector Search) -->
4972
+ <symbol id="lg-search-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
4973
+ <path d="m13 13.5 2-2.5-2-2.5"/><path d="m9 8.5-2 2.5 2 2.5"/><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
4974
+ </symbol>
4523
4975
  </svg>
4524
4976
 
4525
4977
  <div class="app-shell">
@@ -4529,26 +4981,27 @@ select:focus { outline: none; border-color: var(--accent); }
4529
4981
  <div class="sidebar-drag-region">
4530
4982
  <img class="sidebar-logo" id="sidebarLogo" src="/icons/dark/64.png" alt="Vai">
4531
4983
  <span class="sidebar-title">Vai</span>
4984
+ <button class="sidebar-collapse-btn" id="sidebarCollapseBtn" onclick="toggleSidebarCollapse()" title="Toggle sidebar"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg></button>
4532
4985
  <button class="sidebar-settings-btn" data-tab="settings" title="Settings"><svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-config"/></svg></button>
4533
4986
  </div>
4534
4987
  <nav class="sidebar-nav">
4535
4988
  <div class="sidebar-nav-group" role="tablist" aria-label="Tools">
4536
4989
  <div class="sidebar-nav-label" id="nav-tools-label">Tools</div>
4537
- <button class="tab-btn active" data-tab="home" role="tab" aria-selected="true" aria-controls="tab-home" id="tab-btn-home"><span class="tab-btn-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></span><span>Home</span></button>
4538
- <button class="tab-btn" data-tab="embed" role="tab" aria-selected="false" aria-controls="tab-embed" id="tab-btn-embed"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-lightning"/></svg></span><span>Embed</span></button>
4539
- <button class="tab-btn" data-tab="compare" role="tab" aria-selected="false" aria-controls="tab-compare" id="tab-btn-compare"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-arrows"/></svg></span><span>Compare</span></button>
4540
- <button class="tab-btn" data-tab="search" role="tab" aria-selected="false" aria-controls="tab-search" id="tab-btn-search"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-search"/></svg></span><span>Search</span></button>
4541
- <button class="tab-btn" data-tab="multimodal" role="tab" aria-selected="false" aria-controls="tab-multimodal" id="tab-btn-multimodal"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-image"/></svg></span><span>Multimodal</span></button>
4542
- <button class="tab-btn" data-tab="generate" role="tab" aria-selected="false" aria-controls="tab-generate" id="tab-btn-generate"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-code"/></svg></span><span>Generate</span></button>
4543
- <button class="tab-btn" data-tab="chat" role="tab" aria-selected="false" aria-controls="tab-chat" id="tab-btn-chat"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-chat"/></svg></span><span>Chat</span></button>
4544
- <button class="tab-btn" data-tab="workflows" role="tab" aria-selected="false" aria-controls="tab-workflows" id="tab-btn-workflows"><span class="tab-btn-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="19" cy="18" r="3"/><line x1="7.7" y1="10.7" x2="16.3" y2="7.3"/><line x1="7.7" y1="13.3" x2="16.3" y2="16.7"/></svg></span><span>Workflows</span></button>
4990
+ <button class="tab-btn active" data-tab="home" data-tooltip="Home" role="tab" aria-selected="true" aria-controls="tab-home" id="tab-btn-home"><span class="tab-btn-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></span><span>Home</span></button>
4991
+ <button class="tab-btn" data-tab="embed" data-tooltip="Embed" role="tab" aria-selected="false" aria-controls="tab-embed" id="tab-btn-embed"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-lightning"/></svg></span><span>Embed</span></button>
4992
+ <button class="tab-btn" data-tab="compare" data-tooltip="Compare" role="tab" aria-selected="false" aria-controls="tab-compare" id="tab-btn-compare"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-arrows"/></svg></span><span>Compare</span></button>
4993
+ <button class="tab-btn" data-tab="search" data-tooltip="Search" role="tab" aria-selected="false" aria-controls="tab-search" id="tab-btn-search"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-search"/></svg></span><span>Search</span></button>
4994
+ <button class="tab-btn" data-tab="multimodal" data-tooltip="Multimodal" role="tab" aria-selected="false" aria-controls="tab-multimodal" id="tab-btn-multimodal"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-image"/></svg></span><span>Multimodal</span></button>
4995
+ <button class="tab-btn" data-tab="generate" data-tooltip="Generate" role="tab" aria-selected="false" aria-controls="tab-generate" id="tab-btn-generate"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-code"/></svg></span><span>Generate</span></button>
4996
+ <button class="tab-btn" data-tab="chat" data-tooltip="Chat" role="tab" aria-selected="false" aria-controls="tab-chat" id="tab-btn-chat"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-chat"/></svg></span><span>Chat</span></button>
4997
+ <button class="tab-btn" data-tab="workflows" data-tooltip="Workflows" role="tab" aria-selected="false" aria-controls="tab-workflows" id="tab-btn-workflows"><span class="tab-btn-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="3"/><circle cx="19" cy="6" r="3"/><circle cx="19" cy="18" r="3"/><line x1="7.7" y1="10.7" x2="16.3" y2="7.3"/><line x1="7.7" y1="13.3" x2="16.3" y2="16.7"/></svg></span><span>Workflows</span></button>
4545
4998
  </div>
4546
4999
  <div class="sidebar-nav-divider"></div>
4547
5000
  <div class="sidebar-nav-group" role="tablist" aria-label="Learn">
4548
5001
  <div class="sidebar-nav-label" id="nav-learn-label">Learn</div>
4549
- <button class="tab-btn" data-tab="benchmark" role="tab" aria-selected="false" aria-controls="tab-benchmark" id="tab-btn-benchmark"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-gauge"/></svg></span><span>Benchmark</span></button>
4550
- <button class="tab-btn" data-tab="explore" role="tab" aria-selected="false" aria-controls="tab-explore" id="tab-btn-explore"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-bulb"/></svg></span><span>Explore</span></button>
4551
- <button class="tab-btn" data-tab="about" role="tab" aria-selected="false" aria-controls="tab-about" id="tab-btn-about"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-info"/></svg></span><span>About</span></button>
5002
+ <button class="tab-btn" data-tab="benchmark" data-tooltip="Benchmark" role="tab" aria-selected="false" aria-controls="tab-benchmark" id="tab-btn-benchmark"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-gauge"/></svg></span><span>Benchmark</span></button>
5003
+ <button class="tab-btn" data-tab="explore" data-tooltip="Explore" role="tab" aria-selected="false" aria-controls="tab-explore" id="tab-btn-explore"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-bulb"/></svg></span><span>Explore</span></button>
5004
+ <button class="tab-btn" data-tab="about" data-tooltip="About" role="tab" aria-selected="false" aria-controls="tab-about" id="tab-btn-about"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-info"/></svg></span><span>About</span></button>
4552
5005
  </div>
4553
5006
  </nav>
4554
5007
  <div class="sidebar-footer">
@@ -4632,6 +5085,9 @@ select:focus { outline: none; border-color: var(--accent); }
4632
5085
  <div class="home-announcements-dots" id="announcementsDots">
4633
5086
  <!-- Dots will be inserted here -->
4634
5087
  </div>
5088
+ <div class="home-announcements-restore" id="announcementsRestore" style="display: none;">
5089
+ <button onclick="restoreAnnouncements()">Show dismissed announcements</button>
5090
+ </div>
4635
5091
  </div>
4636
5092
 
4637
5093
  <!-- What's New (Release Notes) -->
@@ -5560,7 +6016,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
5560
6016
  <!-- ========== WORKFLOWS TAB ========== -->
5561
6017
  <div class="tab-panel" id="tab-workflows" role="tabpanel" aria-labelledby="tab-btn-workflows" tabindex="0">
5562
6018
  <div class="wf-container">
5563
- <div class="wf-library">
6019
+ <div class="wf-library" id="wfLibrary">
5564
6020
  <div class="wf-library-header">
5565
6021
  <div class="wf-library-tabs" style="flex:1;">
5566
6022
  <button class="wf-lib-tab active" data-lib-tab="library" onclick="wfSwitchLibTab('library')">Library</button>
@@ -5569,6 +6025,11 @@ Reranking models rescore initial search results to improve relevance ordering.</
5569
6025
  <button class="wf-store-btn" id="wfStoreBtn" onclick="wfStoreOpen()" title="Browse Workflow Store">
5570
6026
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
5571
6027
  </button>
6028
+ <button class="wf-library-collapse-btn" onclick="wfToggleLibrary()" title="Collapse library">&#x2039;</button>
6029
+ </div>
6030
+ <div class="wf-library-search-wrap">
6031
+ <input type="text" id="wfLibrarySearch" placeholder="Search workflows..." oninput="wfOnLibrarySearch()">
6032
+ <button class="wf-library-search-clear" id="wfLibrarySearchClear" onclick="wfClearLibrarySearch()">✕</button>
5572
6033
  </div>
5573
6034
  <div class="wf-library-list" id="wfLibraryList">
5574
6035
  <div style="padding: 16px; color: var(--text-muted); font-size: 12px;">Loading...</div>
@@ -5599,7 +6060,15 @@ Reranking models rescore initial search results to improve relevance ordering.</
5599
6060
  <button onclick="wfExportJson()" id="wfExportBtn" disabled title="Export workflow JSON">
5600
6061
  <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 2h10M8 14V5M5 8l3-3 3 3"/></svg>
5601
6062
  </button>
6063
+ <span class="wf-toolbar-spacer"></span>
6064
+ <div class="wf-toolbar-toggle-group">
6065
+ <button id="wfToggleLibBtn" class="active" onclick="wfToggleLibrary()">Library</button>
6066
+ <button id="wfTogglePropsBtn" class="active" onclick="wfToggleInspector()">Props</button>
6067
+ </div>
5602
6068
  </div>
6069
+ <!-- Edge handles for collapsed panels -->
6070
+ <div class="wf-edge-handle wf-edge-handle--left" id="wfEdgeHandleLeft" onclick="wfToggleLibrary()" title="Expand library">&#x203A;</div>
6071
+ <div class="wf-edge-handle wf-edge-handle--right" id="wfEdgeHandleRight" onclick="wfToggleInspector()" title="Expand properties">&#x2039;</div>
5603
6072
  <!-- Execution status bar -->
5604
6073
  <div class="wf-exec-status" id="wfExecStatus" style="display:none;">
5605
6074
  <span class="wf-exec-status-dot"></span>
@@ -5638,7 +6107,6 @@ Reranking models rescore initial search results to improve relevance ordering.</
5638
6107
  </div>
5639
6108
  </div>
5640
6109
  <div class="wf-inspector collapsed" id="wfInspector">
5641
- <button class="wf-inspector-toggle" id="wfInspectorToggle" onclick="wfToggleInspector()" title="Toggle inspector">&lsaquo;</button>
5642
6110
  <div class="wf-inspector-content">
5643
6111
  <div class="wf-inspector-header" id="wfInspectorHeader">Inspector</div>
5644
6112
  <div class="wf-inspector-body" id="wfInspectorBody">
@@ -5666,6 +6134,14 @@ Reranking models rescore initial search results to improve relevance ordering.</
5666
6134
  </div>
5667
6135
  </div>
5668
6136
 
6137
+ <!-- ── Workflow Node Help Modal ── -->
6138
+ <div class="wf-help-modal-backdrop" id="wfHelpModalBackdrop" style="display:none;" onclick="wfCloseHelpModal()">
6139
+ <div class="wf-help-modal" onclick="event.stopPropagation()">
6140
+ <div class="wf-help-modal-header" id="wfHelpModalHeader"></div>
6141
+ <div class="wf-help-modal-body" id="wfHelpModalBody"></div>
6142
+ </div>
6143
+ </div>
6144
+
5669
6145
  <!-- ── Workflow Input Modal (pre-execution) ── -->
5670
6146
  <div class="wf-input-modal-backdrop" id="wfInputModalBackdrop" style="display:none;" onclick="wfCloseInputModal()">
5671
6147
  <div class="wf-input-modal" onclick="event.stopPropagation()">
@@ -6581,6 +7057,17 @@ function sendTelemetry(event, extra = {}) {
6581
7057
  } catch { /* telemetry should never break the app */ }
6582
7058
  }
6583
7059
 
7060
+ function telemetryTimer(event, baseFields = {}) {
7061
+ const start = performance.now();
7062
+ return (extra = {}) => {
7063
+ sendTelemetry(event, {
7064
+ ...baseFields,
7065
+ ...extra,
7066
+ durationMs: Math.round(performance.now() - start),
7067
+ });
7068
+ };
7069
+ }
7070
+
6584
7071
  function initTelemetryToggle() {
6585
7072
  const toggle = document.getElementById('settingsTelemetry');
6586
7073
  if (!toggle) return;
@@ -6613,6 +7100,7 @@ async function init() {
6613
7100
  // Telemetry
6614
7101
  initTelemetryToggle();
6615
7102
  sendTelemetry('app_launch');
7103
+ sendTelemetry('playground_open');
6616
7104
  }
6617
7105
 
6618
7106
  // ── Tabs ──
@@ -6679,6 +7167,7 @@ function switchTab(tab) {
6679
7167
  if (settingsBtn) settingsBtn.classList.toggle('active', tab === 'settings');
6680
7168
  // Track tab views
6681
7169
  sendTelemetry('tab_view', { tab });
7170
+ sendTelemetry('playground_tab', { tab });
6682
7171
 
6683
7172
  // Initialize Home page if switching to it
6684
7173
  if (tab === 'home') {
@@ -6789,6 +7278,9 @@ async function loadAnnouncements() {
6789
7278
  const res = await fetch('/api/home/announcements');
6790
7279
  const data = await res.json();
6791
7280
 
7281
+ // Store total count before filtering
7282
+ homeData.totalAnnouncements = data.announcements.length;
7283
+
6792
7284
  // Filter out dismissed announcements
6793
7285
  const dismissed = JSON.parse(localStorage.getItem('vai-dismissed-announcements') || '[]');
6794
7286
  homeData.announcements = data.announcements.filter(a => !dismissed.includes(a.id));
@@ -6796,6 +7288,7 @@ async function loadAnnouncements() {
6796
7288
  } catch (err) {
6797
7289
  console.error('Failed to load announcements:', err);
6798
7290
  homeData.announcements = [];
7291
+ homeData.totalAnnouncements = 0;
6799
7292
  }
6800
7293
  }
6801
7294
 
@@ -6855,22 +7348,41 @@ function renderAnnouncements() {
6855
7348
  const container = document.getElementById('homeAnnouncements');
6856
7349
  const carousel = document.getElementById('announcementsCarousel');
6857
7350
  const dots = document.getElementById('announcementsDots');
7351
+ const restoreBtn = document.getElementById('announcementsRestore');
7352
+
7353
+ // Show restore button if any announcements have been dismissed
7354
+ const hasDismissed = (homeData.totalAnnouncements || 0) > homeData.announcements.length;
7355
+ if (restoreBtn) restoreBtn.style.display = hasDismissed ? 'block' : 'none';
6858
7356
 
6859
7357
  if (!homeData.announcements.length) {
6860
- container.style.display = 'none';
7358
+ container.style.display = hasDismissed ? 'block' : 'none';
7359
+ carousel.innerHTML = '';
7360
+ dots.innerHTML = '';
6861
7361
  return;
6862
7362
  }
6863
7363
 
6864
7364
  // Render cards
6865
- carousel.innerHTML = homeData.announcements.map((ann, i) => `
6866
- <div class="home-announcement-card${i === 0 ? ' active' : ''}" data-id="${ann.id}">
7365
+ carousel.innerHTML = homeData.announcements.map((ann, i) => {
7366
+ const bgClasses = [
7367
+ 'home-announcement-card',
7368
+ i === 0 ? 'active' : '',
7369
+ ann.bg_image ? 'has-bg-image' : ''
7370
+ ].filter(Boolean).join(' ');
7371
+ const bgStyle = ann.bg_image
7372
+ ? `background-image: url('${ann.bg_image}');`
7373
+ : ann.bg_color
7374
+ ? `background: ${ann.bg_color};`
7375
+ : '';
7376
+ return `
7377
+ <div class="${bgClasses}" data-id="${ann.id}" style="${bgStyle}">
6867
7378
  <button class="home-announcement-dismiss" onclick="dismissAnnouncement('${ann.id}')">×</button>
7379
+ ${ann.icon ? `<div class="home-announcement-icon">${ann.icon}</div>` : ''}
6868
7380
  ${ann.badge ? `<div class="badge">${ann.badge}</div>` : ''}
6869
7381
  <h3>${ann.title}</h3>
6870
7382
  <p>${ann.description}</p>
6871
7383
  ${ann.cta ? `<button class="cta" onclick="${ann.cta.action === 'navigate' ? `switchTab('${ann.cta.target.slice(1)}')` : 'void(0)'}">${ann.cta.label}</button>` : ''}
6872
- </div>
6873
- `).join('');
7384
+ </div>`;
7385
+ }).join('');
6874
7386
 
6875
7387
  // Render dots
6876
7388
  if (homeData.announcements.length > 1) {
@@ -6890,22 +7402,63 @@ function showAnnouncement(index) {
6890
7402
 
6891
7403
  // Update cards
6892
7404
  document.querySelectorAll('.home-announcement-card').forEach((card, i) => {
6893
- card.classList.toggle('active', i === index);
7405
+ if (i === index) {
7406
+ card.classList.add('active');
7407
+ } else {
7408
+ card.classList.remove('active');
7409
+ }
6894
7410
  });
6895
7411
 
6896
7412
  // Update dots
6897
7413
  document.querySelectorAll('.home-announcement-dot').forEach((dot, i) => {
6898
- dot.classList.toggle('active', i === index);
7414
+ if (i === index) {
7415
+ dot.classList.add('active');
7416
+ } else {
7417
+ dot.classList.remove('active');
7418
+ }
6899
7419
  });
7420
+
7421
+ // Reset the rotation timer so we get a full interval after manual clicks
7422
+ resetAnnouncementRotation();
6900
7423
  }
6901
7424
 
7425
+ let announcementRotationTimer = null;
7426
+
6902
7427
  function startAnnouncementRotation() {
6903
- setInterval(() => {
6904
- if (homeData.announcements.length > 1) {
6905
- homeData.currentAnnouncementIndex = (homeData.currentAnnouncementIndex + 1) % homeData.announcements.length;
6906
- showAnnouncement(homeData.currentAnnouncementIndex);
6907
- }
6908
- }, 5000); // Rotate every 5 seconds
7428
+ stopAnnouncementRotation();
7429
+ if (homeData.announcements.length <= 1) return;
7430
+ announcementRotationTimer = setInterval(() => {
7431
+ const next = (homeData.currentAnnouncementIndex + 1) % homeData.announcements.length;
7432
+ homeData.currentAnnouncementIndex = next;
7433
+
7434
+ // Update cards directly (don't call showAnnouncement to avoid resetting timer)
7435
+ document.querySelectorAll('.home-announcement-card').forEach((card, i) => {
7436
+ if (i === next) {
7437
+ card.classList.add('active');
7438
+ } else {
7439
+ card.classList.remove('active');
7440
+ }
7441
+ });
7442
+ document.querySelectorAll('.home-announcement-dot').forEach((dot, i) => {
7443
+ if (i === next) {
7444
+ dot.classList.add('active');
7445
+ } else {
7446
+ dot.classList.remove('active');
7447
+ }
7448
+ });
7449
+ }, 6000);
7450
+ }
7451
+
7452
+ function stopAnnouncementRotation() {
7453
+ if (announcementRotationTimer) {
7454
+ clearInterval(announcementRotationTimer);
7455
+ announcementRotationTimer = null;
7456
+ }
7457
+ }
7458
+
7459
+ function resetAnnouncementRotation() {
7460
+ stopAnnouncementRotation();
7461
+ startAnnouncementRotation();
6909
7462
  }
6910
7463
 
6911
7464
  function dismissAnnouncement(id) {
@@ -6918,6 +7471,19 @@ function dismissAnnouncement(id) {
6918
7471
  renderAnnouncements();
6919
7472
  }
6920
7473
 
7474
+ async function restoreAnnouncements() {
7475
+ localStorage.removeItem('vai-dismissed-announcements');
7476
+ // Re-fetch all announcements
7477
+ try {
7478
+ const res = await fetch('/api/home/announcements');
7479
+ const data = await res.json();
7480
+ homeData.announcements = data.announcements;
7481
+ renderAnnouncements();
7482
+ } catch (err) {
7483
+ console.error('Failed to restore announcements:', err);
7484
+ }
7485
+ }
7486
+
6921
7487
  function renderReleases() {
6922
7488
  const container = document.getElementById('releasesTimeline');
6923
7489
 
@@ -7174,11 +7740,13 @@ window.doEmbed = async function() {
7174
7740
  hideError('embedError');
7175
7741
  const text = document.getElementById('embedInput').value.trim();
7176
7742
  if (!text) { showError('embedError', 'Enter some text to embed'); return; }
7177
- sendTelemetry('api_call', { endpoint: 'embed', model: document.getElementById('embedModel').value });
7743
+ const _embedModel = document.getElementById('embedModel').value;
7744
+ sendTelemetry('api_call', { endpoint: 'embed', model: _embedModel });
7745
+ const _embedDone = telemetryTimer('playground_embed', { model: _embedModel, inputType: document.getElementById('embedInputType').value || undefined });
7178
7746
 
7179
7747
  setLoading('embedBtn', true);
7180
7748
  try {
7181
- const model = document.getElementById('embedModel').value;
7749
+ const model = _embedModel;
7182
7750
  const inputType = document.getElementById('embedInputType').value || undefined;
7183
7751
  const dims = document.getElementById('embedDimensions').value;
7184
7752
  const dimensions = dims ? parseInt(dims, 10) : undefined;
@@ -7216,6 +7784,8 @@ window.doEmbed = async function() {
7216
7784
  buildHeatmap(emb, document.getElementById('embedHeatmap'));
7217
7785
 
7218
7786
  document.getElementById('embedResult').classList.add('visible');
7787
+ CostTracker.addOperation('embed', model, data.usage?.total_tokens || 0);
7788
+ _embedDone();
7219
7789
  } catch (err) {
7220
7790
  showError('embedError', err.message);
7221
7791
  } finally {
@@ -7258,6 +7828,7 @@ function buildHeatmap(vec, container) {
7258
7828
  window.doCompare = async function() {
7259
7829
  hideError('compareError');
7260
7830
  sendTelemetry('api_call', { endpoint: 'compare', model: document.getElementById('compareModel').value });
7831
+ const _compareDone = telemetryTimer('playground_similarity', { model: document.getElementById('compareModel').value });
7261
7832
  const a = document.getElementById('compareA').value.trim();
7262
7833
  const b = document.getElementById('compareB').value.trim();
7263
7834
  if (!a || !b) { showError('compareError', 'Enter text in both fields'); return; }
@@ -7340,6 +7911,8 @@ window.doCompare = async function() {
7340
7911
  `;
7341
7912
 
7342
7913
  document.getElementById('compareResult').classList.add('visible');
7914
+ CostTracker.addOperation('compare', data.model || model, data.usage?.total_tokens || 0);
7915
+ _compareDone();
7343
7916
  } catch (err) {
7344
7917
  showError('compareError', err.message);
7345
7918
  } finally {
@@ -7351,6 +7924,7 @@ window.doCompare = async function() {
7351
7924
  window.doSearch = async function(withRerank) {
7352
7925
  hideError('searchError');
7353
7926
  sendTelemetry('api_call', { endpoint: withRerank ? 'rerank' : 'search' });
7927
+ const _searchDone = telemetryTimer(withRerank ? 'playground_rerank' : 'playground_search', { model: document.getElementById('searchEmbedModel').value });
7354
7928
  const query = document.getElementById('searchQuery').value.trim();
7355
7929
  const docsText = document.getElementById('searchDocs').value.trim();
7356
7930
  if (!query || !docsText) { showError('searchError', 'Enter a query and documents'); return; }
@@ -7383,9 +7957,10 @@ window.doSearch = async function(withRerank) {
7383
7957
  const embeddingResults = scores.slice(0, topK);
7384
7958
 
7385
7959
  let rerankResults = null;
7960
+ let rerankData = null;
7386
7961
  if (withRerank) {
7387
7962
  const rerankModel = document.getElementById('searchRerankModel').value;
7388
- const rerankData = await apiPost('/api/rerank', { query, documents, model: rerankModel, topK });
7963
+ rerankData = await apiPost('/api/rerank', { query, documents, model: rerankModel, topK });
7389
7964
  rerankResults = rerankData.data.map(r => ({
7390
7965
  index: r.index,
7391
7966
  text: documents[r.index],
@@ -7395,6 +7970,12 @@ window.doSearch = async function(withRerank) {
7395
7970
 
7396
7971
  renderSearchResults(embeddingResults, rerankResults);
7397
7972
  document.getElementById('searchResult').classList.add('visible');
7973
+ CostTracker.addOperation('search', embedModel, embedData.usage?.total_tokens || 0);
7974
+ if (withRerank && rerankResults) {
7975
+ const rerankModel = document.getElementById('searchRerankModel').value;
7976
+ CostTracker.addOperation('rerank', rerankModel, rerankData?.usage?.total_tokens || 0);
7977
+ }
7978
+ _searchDone();
7398
7979
  } catch (err) {
7399
7980
  showError('searchError', err.message);
7400
7981
  } finally {
@@ -8048,6 +8629,7 @@ window.doBenchLatency = async function() {
8048
8629
  latencies.push(data.elapsed);
8049
8630
  tokens = data.tokens;
8050
8631
  dims = data.dimensions;
8632
+ CostTracker.addOperation('bench-latency', model, data.tokens || 0);
8051
8633
  } catch (err) {
8052
8634
  document.getElementById(`bench-stats-${mi}`).textContent = 'Error';
8053
8635
  document.getElementById(`bench-bar-${mi}`).classList.remove('running');
@@ -8141,6 +8723,8 @@ window.doBenchRanking = async function() {
8141
8723
 
8142
8724
  rankedA = rankBySimilarity(dataA.embeddings, documents, topK);
8143
8725
  rankedB = rankBySimilarity(dataB.embeddings, documents, topK);
8726
+ CostTracker.addOperation('bench-ranking', modelA, dataA.tokens || 0);
8727
+ CostTracker.addOperation('bench-ranking', modelB, dataB.tokens || 0);
8144
8728
  } else {
8145
8729
  // Rerank mode
8146
8730
  const [dataA, dataB] = await Promise.all([
@@ -8158,6 +8742,8 @@ window.doBenchRanking = async function() {
8158
8742
  text: documents[r.index],
8159
8743
  score: r.relevance_score,
8160
8744
  }));
8745
+ CostTracker.addOperation('bench-ranking', modelA, dataA.usage?.total_tokens || 0);
8746
+ CostTracker.addOperation('bench-ranking', modelB, dataB.usage?.total_tokens || 0);
8161
8747
  }
8162
8748
 
8163
8749
  // Render comparison
@@ -8298,6 +8884,7 @@ window.doBenchQuantization = async function() {
8298
8884
  const start = performance.now();
8299
8885
  const data = await apiPost('/api/embed', body);
8300
8886
  const elapsed = performance.now() - start;
8887
+ CostTracker.addOperation('bench-quantization', model, data.usage?.total_tokens || 0);
8301
8888
 
8302
8889
  const embeddings = data.data.map(d => d.embedding);
8303
8890
  const queryEmbed = embeddings[0];
@@ -9859,6 +10446,7 @@ window.doMultimodalCompare = async function() {
9859
10446
  }
9860
10447
 
9861
10448
  document.getElementById('mmResult').classList.add('visible');
10449
+ CostTracker.addOperation('multimodal-compare', data.model || model, usage.total_tokens || 0);
9862
10450
  } catch (err) {
9863
10451
  showError('mmError', err.message);
9864
10452
  } finally {
@@ -10062,6 +10650,7 @@ window.doMultimodalSearch = async function() {
10062
10650
  });
10063
10651
 
10064
10652
  document.getElementById('mmSearchResult').classList.add('visible');
10653
+ CostTracker.addOperation('multimodal-search', model, data.usage?.total_tokens || 0);
10065
10654
  } catch (err) {
10066
10655
  showError('mmSearchError', err.message);
10067
10656
  } finally {
@@ -10960,20 +11549,24 @@ function renderMarkdown(md) {
10960
11549
  /**
10961
11550
  * Tool metadata: icon, label, and a function to summarize the call for the thinking panel.
10962
11551
  */
11552
+ function lucideIcon(id, size = 16) {
11553
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="color:var(--accent)"><use href="#${id}"/></svg>`;
11554
+ }
11555
+
10963
11556
  const TOOL_META = {
10964
- vai_query: { icon: '\uD83D\uDD0D', label: 'RAG Query', verb: 'Searching', descFn: a => a.query ? `"${a.query}"` : '' },
10965
- vai_search: { icon: '\uD83D\uDD0E', label: 'Vector Search', verb: 'Searching vectors', descFn: a => a.query ? `"${a.query}"` : '' },
10966
- vai_rerank: { icon: '\u2195\uFE0F', label: 'Rerank', verb: 'Reranking', descFn: a => a.query ? `${a.documents?.length || '?'} docs for "${a.query}"` : '' },
10967
- vai_embed: { icon: '\uD83E\uDDE0', label: 'Embed', verb: 'Embedding', descFn: a => a.text ? `"${a.text.slice(0, 60)}${a.text.length > 60 ? '...' : ''}"` : '' },
10968
- vai_similarity: { icon: '\uD83C\uDFAF', label: 'Similarity', verb: 'Comparing', descFn: a => a.text1 ? `two texts` : '' },
10969
- vai_collections: { icon: '\uD83D\uDDC4\uFE0F', label: 'Collections', verb: 'Discovering', descFn: a => a.db ? `in ${a.db}` : 'available databases' },
10970
- vai_models: { icon: '\uD83E\uDD16', label: 'Models', verb: 'Listing', descFn: () => 'available models' },
10971
- vai_topics: { icon: '\uD83D\uDCDA', label: 'Topics', verb: 'Browsing', descFn: () => 'educational topics' },
10972
- vai_explain: { icon: '\uD83D\uDCA1', label: 'Explain', verb: 'Explaining', descFn: a => a.topic || '' },
10973
- vai_estimate: { icon: '\uD83D\uDCB0', label: 'Cost Estimate', verb: 'Estimating', descFn: a => a.docs ? `${a.docs} docs` : '' },
10974
- vai_ingest: { icon: '\uD83D\uDCE5', label: 'Ingest', verb: 'Ingesting', descFn: a => a.source || 'document' },
11557
+ vai_query: { iconId: 'lg-search', label: 'RAG Query', verb: 'Searching', descFn: a => a.query ? `"${a.query}"` : '' },
11558
+ vai_search: { iconId: 'lg-search-code', label: 'Vector Search', verb: 'Searching vectors', descFn: a => a.query ? `"${a.query}"` : '' },
11559
+ vai_rerank: { iconId: 'lg-arrow-up-down', label: 'Rerank', verb: 'Reranking', descFn: a => a.query ? `${a.documents?.length || '?'} docs for "${a.query}"` : '' },
11560
+ vai_embed: { iconId: 'lg-lightning', label: 'Embed', verb: 'Embedding', descFn: a => a.text ? `"${a.text.slice(0, 60)}${a.text.length > 60 ? '...' : ''}"` : '' },
11561
+ vai_similarity: { iconId: 'lg-target', label: 'Similarity', verb: 'Comparing', descFn: a => a.text1 ? `two texts` : '' },
11562
+ vai_collections: { iconId: 'lg-database', label: 'Collections', verb: 'Discovering', descFn: a => a.db ? `in ${a.db}` : 'available databases' },
11563
+ vai_models: { iconId: 'lg-bot', label: 'Models', verb: 'Listing', descFn: () => 'available models' },
11564
+ vai_topics: { iconId: 'lg-cube', label: 'Topics', verb: 'Browsing', descFn: () => 'educational topics' },
11565
+ vai_explain: { iconId: 'lg-bulb', label: 'Explain', verb: 'Explaining', descFn: a => a.topic || '' },
11566
+ vai_estimate: { iconId: 'lg-coins', label: 'Cost Estimate', verb: 'Estimating', descFn: a => a.docs ? `${a.docs} docs` : '' },
11567
+ vai_ingest: { iconId: 'lg-inbox', label: 'Ingest', verb: 'Ingesting', descFn: a => a.source || 'document' },
10975
11568
  };
10976
- const DEFAULT_TOOL_META = { icon: '\u2699\uFE0F', label: '', verb: 'Running', descFn: () => '' };
11569
+ const DEFAULT_TOOL_META = { iconId: 'lg-config', label: '', verb: 'Running', descFn: () => '' };
10977
11570
 
10978
11571
  /**
10979
11572
  * Create the thinking panel <details> element.
@@ -10986,7 +11579,7 @@ function createThinkingPanel() {
10986
11579
 
10987
11580
  const summary = document.createElement('summary');
10988
11581
  summary.innerHTML =
10989
- '<span class="thinking-icon">\uD83E\uDDE0</span>' +
11582
+ '<span class="thinking-icon">' + lucideIcon('lg-brain', 14) + '</span>' +
10990
11583
  '<span class="thinking-label">Thinking</span>' +
10991
11584
  '<span class="thinking-count">0</span>' +
10992
11585
  '<span class="thinking-elapsed"></span>' +
@@ -11029,7 +11622,7 @@ function createThinkingPanel() {
11029
11622
 
11030
11623
  const iconDiv = document.createElement('div');
11031
11624
  iconDiv.className = 'thinking-step-icon';
11032
- iconDiv.textContent = meta.icon;
11625
+ iconDiv.innerHTML = lucideIcon(meta.iconId, 14);
11033
11626
  step.appendChild(iconDiv);
11034
11627
 
11035
11628
  const body = document.createElement('div');
@@ -11084,7 +11677,7 @@ function createThinkingPanel() {
11084
11677
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
11085
11678
  summary.querySelector('.thinking-elapsed').textContent = elapsed + 's';
11086
11679
  summary.querySelector('.thinking-label').textContent = 'Thought for ' + elapsed + 's';
11087
- summary.querySelector('.thinking-icon').textContent = '\u2728';
11680
+ summary.querySelector('.thinking-icon').innerHTML = lucideIcon('lg-sparkles', 14);
11088
11681
  // Mark last step done and collapse
11089
11682
  if (activeStep) {
11090
11683
  activeStep.classList.remove('active');
@@ -11209,6 +11802,7 @@ async function sendChatMessage() {
11209
11802
  let fullText = '';
11210
11803
  let sources = [];
11211
11804
  let thinkingPanel = null;
11805
+ let retrievalCostTracked = false;
11212
11806
 
11213
11807
  while (true) {
11214
11808
  const { done, value } = await reader.read();
@@ -11229,6 +11823,14 @@ async function sendChatMessage() {
11229
11823
 
11230
11824
  if (currentEvent === 'retrieval') {
11231
11825
  typing.textContent = `Retrieved ${data.docs?.length || 0} docs (${data.timeMs}ms)`;
11826
+ // Track embedding cost from retrieval (flag to avoid double-counting with done)
11827
+ if (data.tokens?.embed) {
11828
+ CostTracker.addOperation('chat-embed', 'voyage-4-lite', data.tokens.embed);
11829
+ retrievalCostTracked = true;
11830
+ }
11831
+ if (data.tokens?.rerank) {
11832
+ CostTracker.addOperation('chat-rerank', 'rerank-2.5', data.tokens.rerank);
11833
+ }
11232
11834
  }
11233
11835
 
11234
11836
  if (currentEvent === 'tool_call') {
@@ -11253,6 +11855,24 @@ async function sendChatMessage() {
11253
11855
  }
11254
11856
 
11255
11857
  if (currentEvent === 'done') {
11858
+ // Track costs from done metadata (only if retrieval event didn't already)
11859
+ const meta = data.metadata || {};
11860
+ if (!retrievalCostTracked && meta.tokens?.embed) {
11861
+ CostTracker.addOperation('chat-embed', 'voyage-4-lite', meta.tokens.embed);
11862
+ }
11863
+ if (!retrievalCostTracked && meta.tokens?.rerank) {
11864
+ CostTracker.addOperation('chat-rerank', 'rerank-2.5', meta.tokens.rerank);
11865
+ }
11866
+ // Track LLM generation cost
11867
+ if (meta.tokens?.llmInput || meta.tokens?.llmOutput) {
11868
+ CostTracker.addLLMOperation('chat-llm', meta.llmModel, meta.tokens.llmInput, meta.tokens.llmOutput);
11869
+ }
11870
+ // Agent mode: track tool call tokens from steps/layers
11871
+ if (data.steps) {
11872
+ for (const step of data.steps) {
11873
+ if (step.tokens) CostTracker.addOperation('chat-tool', step.model || 'voyage-4-lite', step.tokens);
11874
+ }
11875
+ }
11256
11876
  // Finalize the thinking panel (collapse, show elapsed)
11257
11877
  if (thinkingPanel) thinkingPanel.finalize();
11258
11878
  // Render accumulated text as markdown for assistant messages
@@ -11459,6 +12079,57 @@ init();
11459
12079
  .bug-success h3{margin:0 0 12px;color:var(--accent-text);font-size:20px}
11460
12080
  .bug-success p{margin:8px 0;color:var(--text-muted)}
11461
12081
  .bug-success code{background:rgba(255,255,255,0.1);padding:4px 8px;border-radius:4px;font-size:12px;color:var(--accent)}
12082
+
12083
+ /* ── Cost Dashboard ── */
12084
+ #costStatusBar {
12085
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 9999;
12086
+ background: var(--bg-surface); border-top: 1px solid var(--border);
12087
+ padding: 8px 16px; font-size: 13px; color: var(--text-dim);
12088
+ display: flex; align-items: center; gap: 12px; backdrop-filter: blur(8px);
12089
+ opacity: 0.95; font-family: 'SF Mono', 'Fira Code', monospace;
12090
+ }
12091
+ #costStatusBar .cost-sep { color: var(--border); }
12092
+ #costStatusBar .cost-savings { color: var(--green, #00D4AA); }
12093
+ #costStatusBar .cost-more { color: var(--red, #FF6960); }
12094
+ #costStatusBar .cost-val { color: var(--accent); font-weight: 600; }
12095
+ #costDetailsToggle {
12096
+ margin-left: auto; cursor: pointer; color: var(--accent);
12097
+ background: none; border: 1px solid var(--border); border-radius: 6px;
12098
+ padding: 3px 10px; font-size: 12px; font-family: inherit;
12099
+ }
12100
+ #costDetailsToggle:hover { background: var(--bg-card); }
12101
+ #costDetailPanel {
12102
+ position: fixed; bottom: 40px; left: 0; right: 0; z-index: 9998;
12103
+ background: var(--bg-surface); border-top: 1px solid var(--border);
12104
+ max-height: 0; overflow: hidden; transition: max-height 0.3s ease;
12105
+ }
12106
+ #costDetailPanel.open { max-height: 50vh; overflow-y: auto; }
12107
+ #costDetailPanel .cost-panel-inner { padding: 16px 20px; }
12108
+ #costDetailPanel table {
12109
+ width: 100%; border-collapse: collapse; font-size: 12px; font-family: 'SF Mono', 'Fira Code', monospace;
12110
+ }
12111
+ #costDetailPanel th { text-align: left; color: var(--text-muted); font-weight: 500; padding: 4px 8px; border-bottom: 1px solid var(--border); }
12112
+ #costDetailPanel td { padding: 4px 8px; color: var(--text-dim); border-bottom: 1px solid var(--border); }
12113
+ #costDetailPanel .cost-panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
12114
+ #costDetailPanel .cost-panel-header h3 { margin: 0; font-size: 14px; color: var(--text); }
12115
+ #costResetBtn {
12116
+ background: none; border: 1px solid var(--border); border-radius: 6px;
12117
+ padding: 3px 10px; font-size: 12px; color: var(--text-dim); cursor: pointer; font-family: inherit;
12118
+ }
12119
+ #costResetBtn:hover { background: var(--bg-card); color: var(--text); }
12120
+ .cost-projection { margin-top: 12px; font-size: 12px; color: var(--text-muted); }
12121
+ .cost-projection span { color: var(--accent); font-weight: 600; }
12122
+ .cost-toast {
12123
+ position: fixed; bottom: 52px; left: 50%; transform: translateX(-50%);
12124
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px;
12125
+ padding: 8px 16px; font-size: 12px; color: var(--text-dim); z-index: 10000;
12126
+ font-family: 'SF Mono', 'Fira Code', monospace; white-space: nowrap;
12127
+ animation: costToastIn 0.2s ease, costToastOut 0.3s ease 2.7s forwards;
12128
+ pointer-events: none;
12129
+ }
12130
+ @keyframes costToastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
12131
+ @keyframes costToastOut { from { opacity: 1; } to { opacity: 0; transform: translateX(-50%) translateY(-10px); } }
12132
+ body { padding-bottom: 44px; }
11462
12133
  </style>
11463
12134
 
11464
12135
  <!-- Bug button moved to sidebar footer -->
@@ -11602,92 +12273,119 @@ async function wfLoadLibrary() {
11602
12273
  }
11603
12274
  }
11604
12275
 
12276
+ // ── Library Search ──
12277
+ let _wfLibSearchTimer = null;
12278
+ function wfOnLibrarySearch() {
12279
+ clearTimeout(_wfLibSearchTimer);
12280
+ _wfLibSearchTimer = setTimeout(() => wfRenderLibrary(), 150);
12281
+ const input = document.getElementById('wfLibrarySearch');
12282
+ const clear = document.getElementById('wfLibrarySearchClear');
12283
+ if (input && clear) clear.style.display = input.value ? 'block' : 'none';
12284
+ }
12285
+ function wfClearLibrarySearch() {
12286
+ const input = document.getElementById('wfLibrarySearch');
12287
+ if (input) { input.value = ''; input.focus(); }
12288
+ const clear = document.getElementById('wfLibrarySearchClear');
12289
+ if (clear) clear.style.display = 'none';
12290
+ wfRenderLibrary();
12291
+ }
12292
+
12293
+ function wfGetLibSectionStates() {
12294
+ const state = wfGetLayoutState();
12295
+ return (state.library && state.library.sections) || { builtin: true, examples: true, catalog: false, community: false };
12296
+ }
12297
+ function wfSaveLibSectionState(key, expanded) {
12298
+ const state = wfGetLayoutState();
12299
+ if (!state.library) state.library = { open: true };
12300
+ if (!state.library.sections) state.library.sections = { builtin: true, examples: true, catalog: false, community: false };
12301
+ state.library.sections[key] = expanded;
12302
+ try { localStorage.setItem('vai-workflow-layout', JSON.stringify(state)); } catch(e) {}
12303
+ }
12304
+ function wfToggleLibSection(key) {
12305
+ const states = wfGetLibSectionStates();
12306
+ const newVal = !states[key];
12307
+ wfSaveLibSectionState(key, newVal);
12308
+ // Toggle UI directly
12309
+ const header = document.querySelector(`.wf-lib-section-header[data-section="${key}"]`);
12310
+ const body = document.querySelector(`.wf-lib-section-body[data-section="${key}"]`);
12311
+ if (header && body) {
12312
+ header.classList.toggle('expanded', newVal);
12313
+ if (newVal) {
12314
+ body.style.maxHeight = body.scrollHeight + 'px';
12315
+ setTimeout(() => { body.style.maxHeight = 'none'; }, 200);
12316
+ } else {
12317
+ body.style.maxHeight = body.scrollHeight + 'px';
12318
+ requestAnimationFrame(() => { body.style.maxHeight = '0px'; });
12319
+ }
12320
+ }
12321
+ }
12322
+
11605
12323
  function wfRenderLibrary() {
11606
12324
  const list = document.getElementById('wfLibraryList');
11607
12325
  if (!list) return;
11608
- if (wfState.workflows.length === 0 && (!wfState.examples || wfState.examples.length === 0)) {
11609
- list.innerHTML = '<div style="padding:16px;color:var(--text-muted);font-size:12px;">No workflows found</div>';
11610
- return;
12326
+
12327
+ const searchInput = document.getElementById('wfLibrarySearch');
12328
+ const query = (searchInput ? searchInput.value : '').toLowerCase().trim();
12329
+ const sectionStates = wfGetLibSectionStates();
12330
+
12331
+ function matchesSearch(w) {
12332
+ if (!query) return true;
12333
+ const name = (w.name || '').toLowerCase();
12334
+ const desc = (w.description || '').toLowerCase();
12335
+ return name.includes(query) || desc.includes(query);
11611
12336
  }
11612
12337
 
11613
- // Built-in templates
11614
- let html = wfState.workflows.map(w => {
11615
- const displayName = w.name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
12338
+ function renderItem(w, prefix) {
12339
+ const displayName = prefix + w.name.replace(/^@vaicli\/vai-workflow-/, '').replace(/^vai-workflow-/, '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
12340
+ const authorLine = w.author ? `<span style="color:var(--text-muted);font-size:10px;">by ${w.author}${w.version ? ' · v' + w.version : ''}</span>` : '';
12341
+ const tagLine = (w.tags || []).length ? `<div style="margin-top:2px;font-size:10px;color:var(--text-muted);">${w.tags.join(' · ')}</div>` : '';
11616
12342
  return `<div class="wf-library-item" data-wf-name="${w.name}" onclick="wfSelectWorkflow('${w.name}')">
11617
12343
  <div class="wf-library-item-name">${displayName}</div>
11618
12344
  <div class="wf-library-item-desc">${w.description || ''}</div>
12345
+ ${authorLine}${tagLine}
11619
12346
  </div>`;
11620
- }).join('');
12347
+ }
11621
12348
 
11622
- // Collapsible examples section
12349
+ function renderSection(key, label, items, prefix) {
12350
+ const filtered = items.filter(matchesSearch);
12351
+ if (filtered.length === 0) return '';
12352
+ const expanded = sectionStates[key] !== false;
12353
+ const chevron = '&#9654;';
12354
+ const itemsHtml = filtered.map(w => renderItem(w, prefix)).join('');
12355
+ return `<div class="wf-library-section" data-section="${key}">
12356
+ <div class="wf-lib-section-header ${expanded ? 'expanded' : ''}" data-section="${key}" onclick="wfToggleLibSection('${key}')">
12357
+ <span class="wf-chevron">${chevron}</span>
12358
+ <span>${label}</span>
12359
+ <span class="wf-section-count">(${filtered.length})</span>
12360
+ </div>
12361
+ <div class="wf-lib-section-body" data-section="${key}" style="max-height:${expanded ? 'none' : '0px'};">
12362
+ ${itemsHtml}
12363
+ </div>
12364
+ </div>`;
12365
+ }
12366
+
12367
+ let html = '';
12368
+
12369
+ // Built-in
12370
+ html += renderSection('builtin', 'Built-in', wfState.workflows, '');
12371
+
12372
+ // Examples
11623
12373
  const examples = wfState.examples || [];
11624
12374
  if (examples.length > 0) {
11625
- html += `<div class="wf-library-section">
11626
- <button class="wf-library-section-toggle" onclick="wfToggleExamples(this)">
11627
- <span class="arrow">&#9654;</span> Examples (${examples.length})
11628
- </button>
11629
- <div class="wf-examples-content" style="display:none;">`;
11630
-
11631
- const categories = ['Retrieval', 'RAG', 'Ingestion', 'Analysis', 'Other'];
11632
- for (const cat of categories) {
11633
- const items = examples.filter(e => e.category === cat);
11634
- if (items.length === 0) continue;
11635
- html += `<div class="wf-library-category">${cat}</div>`;
11636
- html += items.map(w => {
11637
- const displayName = w.name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
11638
- return `<div class="wf-library-item" data-wf-name="${w.name}" onclick="wfSelectWorkflow('${w.name}')">
11639
- <div class="wf-library-item-name">${displayName}</div>
11640
- <div class="wf-library-item-desc">${w.description || ''}</div>
11641
- </div>`;
11642
- }).join('');
11643
- }
11644
- html += '</div></div>';
12375
+ html += renderSection('examples', 'Examples', examples, '');
11645
12376
  }
11646
12377
 
11647
- // Official catalog section
12378
+ // Official catalog
11648
12379
  const official = wfState.official || [];
11649
- html += `<div class="wf-library-section">
11650
- <div class="wf-library-category" style="display:flex;align-items:center;justify-content:space-between;">
11651
- <span>Official Catalog (@vaicli) (${official.length})</span>
11652
- </div>`;
11653
- if (official.length === 0) {
11654
- html += '<div style="padding:8px 12px;color:var(--text-muted);font-size:11px;">No official workflows installed.</div>';
11655
- } else {
11656
- html += official.map(w => {
11657
- const displayName = w.name.replace(/^@vaicli\/vai-workflow-/, '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
11658
- const authorLine = w.author ? `<span style="color:var(--text-muted);font-size:10px;">by ${w.author}${w.version ? ' · v' + w.version : ''}</span>` : '';
11659
- const tagLine = (w.tags || []).length ? `<div style="margin-top:2px;font-size:10px;color:var(--text-muted);">${w.tags.join(' · ')}</div>` : '';
11660
- return `<div class="wf-library-item" data-wf-name="${w.name}" onclick="wfSelectWorkflow('${w.name}')">
11661
- <div class="wf-library-item-name">✓ ${displayName}</div>
11662
- <div class="wf-library-item-desc">${w.description || ''}</div>
11663
- ${authorLine}${tagLine}
11664
- </div>`;
11665
- }).join('');
11666
- }
11667
- html += '</div>';
12380
+ html += renderSection('catalog', 'Official Catalog (@vaicli)', official, '✓ ');
11668
12381
 
11669
- // Community workflows section
12382
+ // Community
11670
12383
  const community = wfState.community || [];
11671
- html += `<div class="wf-library-section">
11672
- <div class="wf-library-category" style="display:flex;align-items:center;justify-content:space-between;">
11673
- <span>Community (${community.length})</span>
11674
- <button onclick="wfLoadLibrary()" title="Refresh" style="background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:12px;display:flex;align-items:center;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg></button>
11675
- </div>`;
11676
- if (community.length === 0) {
11677
- html += '<div style="padding:8px 12px;color:var(--text-muted);font-size:11px;">No community workflows installed.<br><code style="font-size:10px;">vai workflow install &lt;name&gt;</code></div>';
11678
- } else {
11679
- html += community.map(w => {
11680
- const displayName = w.name.replace(/^vai-workflow-/, '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
11681
- const authorLine = w.author ? `<span style="color:var(--text-muted);font-size:10px;">by ${w.author}${w.version ? ' · v' + w.version : ''}</span>` : '';
11682
- const tagLine = (w.tags || []).length ? `<div style="margin-top:2px;font-size:10px;color:var(--text-muted);">${w.tags.join(' · ')}</div>` : '';
11683
- return `<div class="wf-library-item" data-wf-name="${w.name}" onclick="wfSelectWorkflow('${w.name}')">
11684
- <div class="wf-library-item-name">🌐 ${displayName}</div>
11685
- <div class="wf-library-item-desc">${w.description || ''}</div>
11686
- ${authorLine}${tagLine}
11687
- </div>`;
11688
- }).join('');
12384
+ html += renderSection('community', 'Community', community, '🌐 ');
12385
+
12386
+ if (!html) {
12387
+ html = '<div style="padding:16px;color:var(--text-muted);font-size:12px;">No workflows found</div>';
11689
12388
  }
11690
- html += '</div>';
11691
12389
 
11692
12390
  // Install from npm button
11693
12391
  html += `<div style="padding:8px 12px;">
@@ -12244,22 +12942,200 @@ function wfDrawEdge(fromId, toId, positions) {
12244
12942
  return g;
12245
12943
  }
12246
12944
 
12945
+ // ── Panel Layout State ──
12946
+ let _wfLayoutSaveTimer = null;
12947
+ function wfGetLayoutState() {
12948
+ try {
12949
+ const s = localStorage.getItem('vai-workflow-layout');
12950
+ if (s) return JSON.parse(s);
12951
+ } catch(e) {}
12952
+ return { library: { open: true }, properties: { open: true } };
12953
+ }
12954
+ function wfSaveLayoutState() {
12955
+ clearTimeout(_wfLayoutSaveTimer);
12956
+ _wfLayoutSaveTimer = setTimeout(() => {
12957
+ const lib = document.getElementById('wfLibrary');
12958
+ const insp = document.getElementById('wfInspector');
12959
+ const existing = wfGetLayoutState();
12960
+ const state = {
12961
+ library: { open: lib ? !lib.classList.contains('collapsed') : true, sections: (existing.library && existing.library.sections) || { builtin: true, examples: true, catalog: false, community: false } },
12962
+ properties: { open: insp ? !insp.classList.contains('collapsed') : true, sections: (existing.properties && existing.properties.sections) || { inputs: true, steps: false, output: false } }
12963
+ };
12964
+ try { localStorage.setItem('vai-workflow-layout', JSON.stringify(state)); } catch(e) {}
12965
+ }, 300);
12966
+ }
12967
+ // ── Properties Accordion State ──
12968
+ function wfGetAccordionStates() {
12969
+ const state = wfGetLayoutState();
12970
+ return (state.properties && state.properties.sections) || { inputs: true, steps: false, output: false };
12971
+ }
12972
+ function wfSaveAccordionState(key, expanded) {
12973
+ const state = wfGetLayoutState();
12974
+ if (!state.properties) state.properties = { open: true };
12975
+ if (!state.properties.sections) state.properties.sections = { inputs: true, steps: false, output: false };
12976
+ state.properties.sections[key] = expanded;
12977
+ try { localStorage.setItem('vai-workflow-layout', JSON.stringify(state)); } catch(e) {}
12978
+ }
12979
+ function wfToggleAccordion(key, headerEl) {
12980
+ const body = document.querySelector(`.wf-accordion-body[data-acc="${key}"]`);
12981
+ if (!body) return;
12982
+ const expanded = headerEl.classList.contains('expanded');
12983
+ const newVal = !expanded;
12984
+ headerEl.classList.toggle('expanded', newVal);
12985
+ if (newVal) {
12986
+ body.style.maxHeight = body.scrollHeight + 'px';
12987
+ setTimeout(() => { body.style.maxHeight = 'none'; }, 200);
12988
+ } else {
12989
+ body.style.maxHeight = body.scrollHeight + 'px';
12990
+ requestAnimationFrame(() => { body.style.maxHeight = '0px'; });
12991
+ }
12992
+ wfSaveAccordionState(key, newVal);
12993
+ }
12994
+
12995
+ function wfSyncPanelUI() {
12996
+ const lib = document.getElementById('wfLibrary');
12997
+ const insp = document.getElementById('wfInspector');
12998
+ const libBtn = document.getElementById('wfToggleLibBtn');
12999
+ const propsBtn = document.getElementById('wfTogglePropsBtn');
13000
+ const leftHandle = document.getElementById('wfEdgeHandleLeft');
13001
+ const rightHandle = document.getElementById('wfEdgeHandleRight');
13002
+ const libCollapsed = lib && lib.classList.contains('collapsed');
13003
+ const inspCollapsed = insp && insp.classList.contains('collapsed');
13004
+ if (libBtn) libBtn.classList.toggle('active', !libCollapsed);
13005
+ if (propsBtn) propsBtn.classList.toggle('active', !inspCollapsed);
13006
+ if (leftHandle) leftHandle.classList.toggle('visible', !!libCollapsed);
13007
+ if (rightHandle) rightHandle.classList.toggle('visible', !!inspCollapsed);
13008
+ }
13009
+ function wfRestoreLayoutState() {
13010
+ const state = wfGetLayoutState();
13011
+ const lib = document.getElementById('wfLibrary');
13012
+ const insp = document.getElementById('wfInspector');
13013
+ if (lib) lib.classList.toggle('collapsed', !state.library.open);
13014
+ if (insp) insp.classList.toggle('collapsed', !state.properties.open);
13015
+ wfSyncPanelUI();
13016
+ }
13017
+
13018
+ // ── Library Toggle ──
13019
+ function wfToggleLibrary() {
13020
+ const panel = document.getElementById('wfLibrary');
13021
+ if (!panel) return;
13022
+ panel.classList.toggle('collapsed');
13023
+ wfSyncPanelUI();
13024
+ wfSaveLayoutState();
13025
+ }
13026
+
12247
13027
  // ── Inspector Toggle ──
12248
13028
  function wfToggleInspector() {
12249
13029
  const panel = document.getElementById('wfInspector');
12250
- const btn = document.getElementById('wfInspectorToggle');
12251
13030
  if (!panel) return;
12252
13031
  panel.classList.toggle('collapsed');
12253
- if (btn) btn.innerHTML = panel.classList.contains('collapsed') ? '&lsaquo;' : '&rsaquo;';
13032
+ wfSyncPanelUI();
13033
+ wfSaveLayoutState();
12254
13034
  }
12255
13035
 
12256
13036
  function wfOpenInspector() {
12257
13037
  const panel = document.getElementById('wfInspector');
12258
- const btn = document.getElementById('wfInspectorToggle');
12259
13038
  if (!panel || !panel.classList.contains('collapsed')) return;
12260
13039
  panel.classList.remove('collapsed');
12261
- if (btn) btn.innerHTML = '&rsaquo;';
13040
+ wfSyncPanelUI();
13041
+ wfSaveLayoutState();
13042
+ }
13043
+
13044
+ // ── Sidebar Collapse ──
13045
+ function toggleSidebarCollapse() {
13046
+ const sidebar = document.querySelector('.sidebar');
13047
+ if (!sidebar) return;
13048
+ sidebar.classList.toggle('collapsed');
13049
+ // Persist
13050
+ const state = wfGetLayoutState();
13051
+ if (!state.sidebar) state.sidebar = {};
13052
+ state.sidebar.collapsed = sidebar.classList.contains('collapsed');
13053
+ try { localStorage.setItem('vai-workflow-layout', JSON.stringify(state)); } catch(e) {}
13054
+ }
13055
+ function restoreSidebarState() {
13056
+ const state = wfGetLayoutState();
13057
+ if (state.sidebar && state.sidebar.collapsed) {
13058
+ const sidebar = document.querySelector('.sidebar');
13059
+ if (sidebar) sidebar.classList.add('collapsed');
13060
+ }
12262
13061
  }
13062
+ restoreSidebarState();
13063
+
13064
+ // ── Keyboard Shortcuts (Workflows tab only) ──
13065
+ let _wfLastOpenedPanel = null; // 'library' or 'properties'
13066
+ (function() {
13067
+ const origToggleLib = window.wfToggleLibrary;
13068
+ window.wfToggleLibrary = function() {
13069
+ origToggleLib();
13070
+ const lib = document.getElementById('wfLibrary');
13071
+ if (lib && !lib.classList.contains('collapsed')) _wfLastOpenedPanel = 'library';
13072
+ };
13073
+ const origToggleInsp = window.wfToggleInspector;
13074
+ window.wfToggleInspector = function() {
13075
+ origToggleInsp();
13076
+ const insp = document.getElementById('wfInspector');
13077
+ if (insp && !insp.classList.contains('collapsed')) _wfLastOpenedPanel = 'properties';
13078
+ };
13079
+ })();
13080
+
13081
+ document.addEventListener('keydown', function(e) {
13082
+ // Only active when Workflows tab is visible
13083
+ const wfTab = document.getElementById('tab-workflows');
13084
+ if (!wfTab || wfTab.style.display === 'none' || !wfTab.classList.contains('active')) {
13085
+ // Check if tab-btn-workflows is active instead
13086
+ const wfBtn = document.getElementById('tab-btn-workflows');
13087
+ if (!wfBtn || !wfBtn.classList.contains('active')) return;
13088
+ }
13089
+
13090
+ const mod = e.metaKey || e.ctrlKey;
13091
+ if (!mod && e.key !== 'Escape') return;
13092
+
13093
+ // Cmd/Ctrl + Shift + B → Toggle sidebar
13094
+ if (mod && e.shiftKey && (e.key === 'B' || e.key === 'b')) {
13095
+ e.preventDefault();
13096
+ toggleSidebarCollapse();
13097
+ return;
13098
+ }
13099
+ // Cmd/Ctrl + B → Toggle Library
13100
+ if (mod && !e.shiftKey && (e.key === 'B' || e.key === 'b')) {
13101
+ e.preventDefault();
13102
+ wfToggleLibrary();
13103
+ return;
13104
+ }
13105
+ // Cmd/Ctrl + I → Toggle Properties
13106
+ if (mod && (e.key === 'I' || e.key === 'i')) {
13107
+ e.preventDefault();
13108
+ wfToggleInspector();
13109
+ return;
13110
+ }
13111
+ // Cmd/Ctrl + \ → Collapse both panels
13112
+ if (mod && e.key === '\\') {
13113
+ e.preventDefault();
13114
+ const lib = document.getElementById('wfLibrary');
13115
+ const insp = document.getElementById('wfInspector');
13116
+ if (lib && !lib.classList.contains('collapsed')) { lib.classList.add('collapsed'); }
13117
+ if (insp && !insp.classList.contains('collapsed')) { insp.classList.add('collapsed'); }
13118
+ wfSyncPanelUI();
13119
+ wfSaveLayoutState();
13120
+ return;
13121
+ }
13122
+ // Escape → Close most recently opened panel
13123
+ if (e.key === 'Escape' && !mod) {
13124
+ if (_wfLastOpenedPanel === 'library') {
13125
+ const lib = document.getElementById('wfLibrary');
13126
+ if (lib && !lib.classList.contains('collapsed')) { wfToggleLibrary(); _wfLastOpenedPanel = null; return; }
13127
+ }
13128
+ if (_wfLastOpenedPanel === 'properties') {
13129
+ const insp = document.getElementById('wfInspector');
13130
+ if (insp && !insp.classList.contains('collapsed')) { wfToggleInspector(); _wfLastOpenedPanel = null; return; }
13131
+ }
13132
+ // Fallback: close properties then library
13133
+ const insp = document.getElementById('wfInspector');
13134
+ if (insp && !insp.classList.contains('collapsed')) { wfToggleInspector(); return; }
13135
+ const lib = document.getElementById('wfLibrary');
13136
+ if (lib && !lib.classList.contains('collapsed')) { wfToggleLibrary(); return; }
13137
+ }
13138
+ });
12263
13139
 
12264
13140
  // ── Node Selection ──
12265
13141
  function wfSelectNode(stepId) {
@@ -12321,56 +13197,83 @@ function wfUpdateInspector() {
12321
13197
  <div style="font-size:10px;color:var(--text-muted);">Add steps from the Palette tab, then drag between ports to connect them.</div>
12322
13198
  </div>`;
12323
13199
  } else {
12324
- // Read-only: Description
13200
+ // Read-only: Description (above accordion)
12325
13201
  if (def.description) {
12326
13202
  html += `<div class="wf-inspector-section">
12327
- <div class="wf-inspector-section-title">Description</div>
12328
13203
  <div style="font-size:12px;color:var(--text);line-height:1.4;">${escapeHtml(def.description)}</div>
12329
13204
  </div>`;
12330
13205
  }
12331
13206
 
12332
- // Read-only: Inputs
12333
- if (def.inputs && Object.keys(def.inputs).length > 0) {
12334
- html += '<div class="wf-inspector-section"><div class="wf-inspector-section-title">Inputs</div>';
13207
+ // Get accordion states from localStorage
13208
+ const accStates = wfGetAccordionStates();
13209
+ const hasDone = !!wfState.executionResults._done;
13210
+
13211
+ // ── INPUTS Accordion ──
13212
+ const inputKeys = def.inputs ? Object.keys(def.inputs) : [];
13213
+ const inputsExpanded = accStates.inputs !== false;
13214
+ html += `<div class="wf-accordion-header ${inputsExpanded ? 'expanded' : ''}" onclick="wfToggleAccordion('inputs', this)">
13215
+ <span class="wf-acc-left"><span class="wf-chevron">&#9654;</span> INPUTS</span>
13216
+ <span class="wf-acc-summary">${inputKeys.length} field${inputKeys.length !== 1 ? 's' : ''}</span>
13217
+ </div>
13218
+ <div class="wf-accordion-body" data-acc="inputs" style="max-height:${inputsExpanded ? 'none' : '0px'};">
13219
+ <div class="wf-accordion-body-inner">`;
13220
+ if (inputKeys.length > 0) {
12335
13221
  for (const [key, spec] of Object.entries(def.inputs)) {
12336
13222
  const req = spec.required ? ' <span style="color:#e74c3c">*</span>' : '';
12337
- const defVal = spec.default !== undefined ? ` (default: ${spec.default})` : '';
12338
- html += `<div style="margin-bottom:8px;">
13223
+ html += `<div style="margin-bottom:12px;">
12339
13224
  <div style="font-size:12px;font-weight:600;color:var(--text);">${escapeHtml(key)}${req}</div>
12340
- <div style="font-size:11px;color:var(--text-muted);">${escapeHtml(spec.description || spec.type || '')}${defVal}</div>
12341
- <input class="wf-inspector-input" id="wf-input-${key}" placeholder="${escapeHtml(key)}" value="${spec.default !== undefined ? spec.default : ''}">
13225
+ <div style="font-size:10px;color:var(--text-muted);margin-bottom:4px;">${escapeHtml(spec.description || spec.type || '')}</div>
13226
+ <input class="wf-inspector-input" id="wf-input-${key}" placeholder="${escapeHtml(key)}" value="${spec.default !== undefined ? escapeHtml(String(spec.default)) : ''}">
12342
13227
  </div>`;
12343
13228
  }
12344
- html += '</div>';
13229
+ } else {
13230
+ html += '<div style="font-size:12px;color:var(--text-muted);">No inputs defined</div>';
12345
13231
  }
12346
-
12347
- // Steps summary
12348
- html += `<div class="wf-inspector-section">
12349
- <div class="wf-inspector-section-title">Steps</div>
12350
- <div style="font-size:12px;color:var(--text);">${def.steps.length} step${def.steps.length !== 1 ? 's' : ''}${wfState.layers ? ' in ' + wfState.layers.length + ' layer' + (wfState.layers.length !== 1 ? 's' : '') : ''}</div>
12351
- </div>`;
12352
-
12353
- // Output mapping
12354
- if (def.output) {
12355
- html += `<div class="wf-inspector-section">
12356
- <div class="wf-inspector-section-title">Output</div>
12357
- <div class="wf-inspector-code">${escapeHtml(JSON.stringify(def.output, null, 2))}</div>
13232
+ html += '</div></div>';
13233
+
13234
+ // ── STEPS Accordion ──
13235
+ const stepCount = def.steps.length;
13236
+ const layerCount = wfState.layers ? wfState.layers.length : 0;
13237
+ const stepsSummary = `${stepCount} step${stepCount !== 1 ? 's' : ''}${layerCount ? ' · ' + layerCount + ' layer' + (layerCount !== 1 ? 's' : '') : ''}`;
13238
+ const stepsExpanded = accStates.steps === true;
13239
+ html += `<div class="wf-accordion-header ${stepsExpanded ? 'expanded' : ''}" onclick="wfToggleAccordion('steps', this)">
13240
+ <span class="wf-acc-left"><span class="wf-chevron">&#9654;</span> STEPS</span>
13241
+ <span class="wf-acc-summary">${stepsSummary}</span>
13242
+ </div>
13243
+ <div class="wf-accordion-body" data-acc="steps" style="max-height:${stepsExpanded ? 'none' : '0px'};">
13244
+ <div class="wf-accordion-body-inner">`;
13245
+ for (const step of def.steps) {
13246
+ const meta = WF_NODE_META[step.tool] || { color: '#666' };
13247
+ const result = wfState.executionResults[step.id];
13248
+ const timeStr = result && result.timeMs != null ? result.timeMs + 'ms' : '—';
13249
+ html += `<div class="wf-step-row">
13250
+ <span class="wf-step-dot" style="background:${meta.color}"></span>
13251
+ <span class="wf-step-name">${escapeHtml(step.name || step.id)}</span>
13252
+ <span class="wf-step-time">${timeStr}</span>
12358
13253
  </div>`;
12359
13254
  }
12360
- }
13255
+ html += '</div></div>';
12361
13256
 
12362
- // Execution result (shown in both modes)
12363
- if (wfState.executionResults._done) {
12364
- const r = wfState.executionResults._done;
12365
- const doneJson = JSON.stringify(r.output, null, 2);
12366
- html += `<div class="wf-inspector-section">
12367
- <div class="wf-inspector-section-title">Result</div>
12368
- <div class="wf-inspector-result success">
12369
- <div style="font-weight:600;margin-bottom:4px;">Completed in ${r.totalTimeMs}ms</div>
12370
- <div class="wf-inspector-code" style="max-height:150px;overflow:auto;">${escapeHtml(doneJson)}</div>
12371
- </div>
12372
- <button class="wf-output-expand-btn" data-expand-step="_done">&#x2922; Expand</button>
12373
- </div>`;
13257
+ // ── OUTPUT Accordion ──
13258
+ const outputExpanded = hasDone || accStates.output === true;
13259
+ html += `<div class="wf-accordion-header ${outputExpanded ? 'expanded' : ''}" onclick="wfToggleAccordion('output', this)">
13260
+ <span class="wf-acc-left"><span class="wf-chevron">&#9654;</span> OUTPUT</span>
13261
+ <span class="wf-acc-summary"></span>
13262
+ </div>
13263
+ <div class="wf-accordion-body" data-acc="output" style="max-height:${outputExpanded ? 'none' : '0px'};">
13264
+ <div class="wf-accordion-body-inner">`;
13265
+ if (hasDone) {
13266
+ const r = wfState.executionResults._done;
13267
+ const doneJson = JSON.stringify(r.output, null, 2);
13268
+ html += `<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">Completed in ${r.totalTimeMs}ms</div>
13269
+ <div class="wf-inspector-code" style="max-height:200px;overflow:auto;color:#40E0FF;">${escapeHtml(doneJson)}</div>
13270
+ <button class="wf-output-expand-btn" data-expand-step="_done">&#x2922; Expand</button>`;
13271
+ } else if (def.output) {
13272
+ html += `<div class="wf-inspector-code" style="color:#40E0FF;">${escapeHtml(JSON.stringify(def.output, null, 2))}</div>`;
13273
+ } else {
13274
+ html += '<div style="font-size:12px;color:var(--text-muted);">Run the workflow to see output</div>';
13275
+ }
13276
+ html += '</div></div>';
12374
13277
  }
12375
13278
 
12376
13279
  body.innerHTML = html;
@@ -12390,10 +13293,13 @@ function wfUpdateInspector() {
12390
13293
 
12391
13294
  let html = '';
12392
13295
 
12393
- // Tool badge (always shown)
13296
+ // Tool badge (always shown) with help button
12394
13297
  html += `<div class="wf-inspector-section">
12395
13298
  <div class="wf-inspector-section-title">Tool</div>
12396
- <span class="wf-tool-badge" style="background:${meta.color}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="${meta.icon || WF_FALLBACK_ICON}"/></svg>${meta.label}</span>
13299
+ <div style="display:flex;align-items:center;">
13300
+ <span class="wf-tool-badge" style="background:${meta.color}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="${meta.icon || WF_FALLBACK_ICON}"/></svg>${meta.label}</span>
13301
+ <button class="wf-inspector-help-btn" onclick="wfOpenHelpModal('${step.tool}')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> Help</button>
13302
+ </div>
12397
13303
  </div>`;
12398
13304
 
12399
13305
  if (wfState.builderMode) {
@@ -13051,9 +13957,105 @@ function wfCopyOutput() {
13051
13957
  });
13052
13958
  }
13053
13959
 
13054
- // Close modal on Escape
13960
+ // ── Node Help Modal ──
13961
+ let _nodeHelpCache = null;
13962
+ async function getNodeHelp() {
13963
+ if (_nodeHelpCache) return _nodeHelpCache;
13964
+ try {
13965
+ const res = await fetch('/api/workflows/node-help');
13966
+ const data = await res.json();
13967
+ _nodeHelpCache = data.nodeHelp || {};
13968
+ } catch { _nodeHelpCache = {}; }
13969
+ return _nodeHelpCache;
13970
+ }
13971
+
13972
+ async function wfOpenHelpModal(tool) {
13973
+ const helpData = await getNodeHelp();
13974
+ const help = helpData[tool];
13975
+ const meta = WF_NODE_META[tool] || { icon: WF_FALLBACK_ICON, label: tool, color: '#666', category: 'unknown' };
13976
+ const categoryLabel = WF_CATEGORY_LABELS[meta.category] || meta.category || 'Unknown';
13977
+
13978
+ const headerEl = document.getElementById('wfHelpModalHeader');
13979
+ const bodyEl = document.getElementById('wfHelpModalBody');
13980
+ const backdrop = document.getElementById('wfHelpModalBackdrop');
13981
+ if (!headerEl || !bodyEl || !backdrop) return;
13982
+
13983
+ // Build header
13984
+ const isEmoji = !meta.icon.includes(' ');
13985
+ const iconHtml = isEmoji
13986
+ ? `<div class="wf-help-icon" style="background:${meta.color}22;font-size:18px;">${meta.icon}</div>`
13987
+ : `<div class="wf-help-icon" style="background:${meta.color}22;color:${meta.color};"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="${meta.icon}"/></svg></div>`;
13988
+
13989
+ headerEl.innerHTML = iconHtml
13990
+ + `<span class="wf-help-title">${escapeHtml(meta.label)}</span>`
13991
+ + `<span class="wf-help-category-badge">${escapeHtml(categoryLabel)}</span>`
13992
+ + `<button class="wf-help-close-btn" onclick="wfCloseHelpModal()" title="Close"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
13993
+
13994
+ // Build body
13995
+ if (!help) {
13996
+ bodyEl.innerHTML = `<div class="wf-help-section"><p>No help content available for this node yet.</p></div>`;
13997
+ backdrop.style.display = 'flex';
13998
+ return;
13999
+ }
14000
+
14001
+ let html = '';
14002
+
14003
+ // Overview
14004
+ if (help.description) {
14005
+ html += `<div class="wf-help-section"><div class="wf-help-section-title">Overview</div><p>${escapeHtml(help.description)}</p></div>`;
14006
+ }
14007
+
14008
+ // How it Works
14009
+ if (help.howItWorks) {
14010
+ html += `<div class="wf-help-section"><div class="wf-help-section-title">How it Works</div><p>${escapeHtml(help.howItWorks)}</p></div>`;
14011
+ }
14012
+
14013
+ // Inputs
14014
+ if (help.inputs && help.inputs.length > 0) {
14015
+ html += `<div class="wf-help-section"><div class="wf-help-section-title">Inputs</div><table class="wf-help-io-table"><thead><tr><th>Parameter</th><th>Type</th><th>Description</th></tr></thead><tbody>`;
14016
+ for (const inp of help.inputs) {
14017
+ const reqBadge = inp.required ? '<span class="wf-help-required">required</span>' : '';
14018
+ html += `<tr><td><span class="wf-help-key">${escapeHtml(inp.key)}</span>${reqBadge}</td><td><span class="wf-help-type">${escapeHtml(inp.type)}</span></td><td>${escapeHtml(inp.desc)}</td></tr>`;
14019
+ }
14020
+ html += '</tbody></table></div>';
14021
+ }
14022
+
14023
+ // Outputs
14024
+ if (help.outputs && help.outputs.length > 0) {
14025
+ html += `<div class="wf-help-section"><div class="wf-help-section-title">Output</div><table class="wf-help-io-table"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody>`;
14026
+ for (const out of help.outputs) {
14027
+ html += `<tr><td><span class="wf-help-key">${escapeHtml(out.key)}</span></td><td><span class="wf-help-type">${escapeHtml(out.type)}</span></td><td>${escapeHtml(out.desc)}</td></tr>`;
14028
+ }
14029
+ html += '</tbody></table></div>';
14030
+ }
14031
+
14032
+ // Tips
14033
+ if (help.tips && help.tips.length > 0) {
14034
+ html += `<div class="wf-help-section"><div class="wf-help-section-title">Tips</div>`;
14035
+ for (const tip of help.tips) {
14036
+ html += `<div class="wf-help-tip"><span class="wf-help-tip-dot"></span><span>${escapeHtml(tip)}</span></div>`;
14037
+ }
14038
+ html += '</div>';
14039
+ }
14040
+
14041
+ bodyEl.innerHTML = html;
14042
+ backdrop.style.display = 'flex';
14043
+ }
14044
+
14045
+ function wfCloseHelpModal() {
14046
+ const backdrop = document.getElementById('wfHelpModalBackdrop');
14047
+ if (backdrop) backdrop.style.display = 'none';
14048
+ }
14049
+
14050
+ // Close modals on Escape
13055
14051
  document.addEventListener('keydown', (e) => {
13056
14052
  if (e.key === 'Escape') {
14053
+ const helpBackdrop = document.getElementById('wfHelpModalBackdrop');
14054
+ if (helpBackdrop && helpBackdrop.style.display !== 'none') {
14055
+ wfCloseHelpModal();
14056
+ e.preventDefault();
14057
+ return;
14058
+ }
13057
14059
  const backdrop = document.getElementById('wfOutputModalBackdrop');
13058
14060
  if (backdrop && backdrop.style.display !== 'none') {
13059
14061
  wfCloseOutputModal();
@@ -13222,6 +14224,7 @@ function wfRenderPalette() {
13222
14224
  html += `<div class="wf-palette-item" draggable="true" ondragstart="event.dataTransfer.setData('text/plain','${item.tool}')" onclick="wfAddNodeFromPalette('${item.tool}')">
13223
14225
  <span class="wf-palette-icon" style="color:${item.color}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="${item.icon || WF_FALLBACK_ICON}"/></svg></span>
13224
14226
  <span class="wf-palette-label">${item.label}</span>
14227
+ <button class="wf-help-trigger" onclick="event.stopPropagation();wfOpenHelpModal('${item.tool}')" title="Help: ${item.label}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></button>
13225
14228
  </div>`;
13226
14229
  }
13227
14230
  html += '</div>';
@@ -13728,7 +14731,25 @@ const _WF_FALLBACK_BRANDING = {
13728
14731
  'batch-quality-gate': { icon:'check-circle', color:'#166534', iconPath:'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4 12 14.01l-3-3' },
13729
14732
  'index-health-check': { icon:'bar-chart-3', color:'#1D4ED8', iconPath:'M12 20V10M18 20V4M6 20v-4' },
13730
14733
  };
13731
- WF_STORE_FALLBACK.forEach(wf => { wf.branding = _WF_FALLBACK_BRANDING[wf.name] || { icon:'zap', color:'#64748B', iconPath:'M13 2 3 14h9l-1 8 10-12h-9l1-8z' }; });
14734
+ WF_STORE_FALLBACK.forEach(wf => {
14735
+ wf.branding = _WF_FALLBACK_BRANDING[wf.name] || { icon:'zap', color:'#64748B', iconPath:'M13 2 3 14h9l-1 8 10-12h-9l1-8z' };
14736
+ wf.verified = true;
14737
+ wf.security = [];
14738
+ wf.rating = null;
14739
+ });
14740
+
14741
+ function getCapabilityBadges(tools) {
14742
+ if (!Array.isArray(tools)) return [];
14743
+ const badges = [];
14744
+ if (tools.includes('http')) badges.push({ emoji: '🌐', label: 'NETWORK', cls: 'wf-store-badge-cap-network' });
14745
+ if (tools.includes('ingest') || tools.includes('aggregate')) badges.push({ emoji: '💾', label: 'WRITE_DB', cls: 'wf-store-badge-cap-writedb' });
14746
+ if (tools.includes('generate')) badges.push({ emoji: '🤖', label: 'LLM', cls: 'wf-store-badge-cap-llm' });
14747
+ if (tools.includes('loop') || tools.includes('forEach')) badges.push({ emoji: '🔄', label: 'LOOP', cls: 'wf-store-badge-cap-loop' });
14748
+ if (tools.some(t => ['query','search','collections','aggregate'].includes(t))) badges.push({ emoji: '📊', label: 'READ_DB', cls: 'wf-store-badge-cap-readdb' });
14749
+ return badges;
14750
+ }
14751
+
14752
+ let _wfStoreUserRatings = {};
13732
14753
 
13733
14754
  let _wfStoreCatalog = null;
13734
14755
  let _wfStoreCat = 'all';
@@ -13833,8 +14854,13 @@ function wfStoreRender() {
13833
14854
  const dots = (wf.tools||[]).slice(0,5).map(t =>
13834
14855
  `<div class="wf-store-card-dot" style="background:${WF_STORE_TOOL_COLORS[t]||'#666'}"></div>`
13835
14856
  ).join('') + ((wf.tools||[]).length > 5 ? `<span style="font-family:var(--mono);font-size:9px;color:var(--text-muted);margin-left:2px">+${wf.tools.length-5}</span>` : '');
13836
- const badges = (wf.installed ? '<span class="wf-store-badge-installed">installed</span>' : '') +
14857
+ const badges = (wf.verified ? '<span class="wf-store-badge-verified">✓ VERIFIED</span>' : '') +
14858
+ (wf.installed ? '<span class="wf-store-badge-installed">installed</span>' : '') +
13837
14859
  `<span class="wf-store-badge-official">${wf.tier==='official' ? '✓ OFFICIAL' : 'COMMUNITY'}</span>`;
14860
+ const capBadges = getCapabilityBadges(wf.tools);
14861
+ const capBadgesHtml = capBadges.length > 0
14862
+ ? `<div class="wf-store-cap-badges">${capBadges.map(b => `<span class="wf-store-badge-capability ${b.cls}">${b.emoji} ${b.label}</span>`).join('')}</div>`
14863
+ : '';
13838
14864
  const cardIconColor = (wf.branding && wf.branding.color) || '#64748B';
13839
14865
  const cardIconSvg = wfBrandingIcon(wf, 16);
13840
14866
  const cardIconHtml = cardIconSvg
@@ -13848,6 +14874,7 @@ function wfStoreRender() {
13848
14874
  </div>
13849
14875
  <div class="wf-store-card-desc">${wf.description||''}</div>
13850
14876
  ${wf.author && wf.author.name && wf.author.name !== 'unknown' ? `<div class="wf-store-card-author">by ${wf.author.name}</div>` : ''}
14877
+ ${capBadgesHtml}
13851
14878
  <div class="wf-store-card-meta">
13852
14879
  <span class="wf-store-card-cat">${wf.category||'utility'}</span>
13853
14880
  <div class="wf-store-card-dots">${dots}</div>
@@ -13893,6 +14920,7 @@ function wfStoreShowDetail(name) {
13893
14920
  modal.className = 'wf-store-detail-bg';
13894
14921
  modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
13895
14922
  modal.innerHTML = `<div class="wf-store-detail-panel" onclick="event.stopPropagation()">
14923
+ <div class="wf-store-detail-scroll">
13896
14924
  <div class="wf-store-detail-hero" style="background:${wf.gradient}">
13897
14925
  <button class="wf-store-detail-close" onclick="document.getElementById('wfStoreDetail').remove()">×</button>
13898
14926
  <div class="wf-store-detail-hero-inner">
@@ -13955,12 +14983,45 @@ function wfStoreShowDetail(name) {
13955
14983
  <span>$ ${rc}</span><span class="wf-store-detail-cmd-hint">copy</span>
13956
14984
  </div>
13957
14985
  </div>
14986
+ ${(() => {
14987
+ const capBadges = getCapabilityBadges(wf.tools);
14988
+ if (capBadges.length === 0) return '';
14989
+ return `<div class="wf-store-detail-section">
14990
+ <div class="wf-store-detail-label">Capabilities</div>
14991
+ <div style="display:flex;gap:5px;flex-wrap:wrap">${capBadges.map(b => `<span class="wf-store-badge-capability ${b.cls}">${b.emoji} ${b.label}</span>`).join('')}</div>
14992
+ </div>`;
14993
+ })()}
14994
+ ${(() => {
14995
+ const sec = wf.security || [];
14996
+ if (sec.length === 0) return `<div class="wf-store-detail-section"><div class="wf-store-detail-label">Security</div><div class="wf-store-detail-security-ok">✓ No security issues found</div></div>`;
14997
+ return `<div class="wf-store-detail-section"><div class="wf-store-detail-label">Security</div>${sec.map(f => `<div class="wf-store-detail-security-item"><span class="wf-store-detail-security-sev ${f.severity||'low'}">${f.severity||'low'}</span><span>${f.message||f.rule||'Issue found'}</span></div>`).join('')}</div>`;
14998
+ })()}
14999
+ ${wf.verified ? `<div class="wf-store-detail-section"><div class="wf-store-detail-label">Verified</div><div class="wf-store-detail-security-ok">✓ This workflow has passed all validation checks (L1–L4): schema, security audit, quality audit, and capability analysis.</div></div>` : ''}
15000
+ <div class="wf-store-detail-section">
15001
+ <div class="wf-store-detail-label">Rating</div>
15002
+ <div style="display:flex;align-items:center;gap:12px">
15003
+ <span style="font-size:12px;color:var(--text-dim)">${wf.rating != null ? wf.rating.toFixed(1) + ' / 5' : 'Not yet rated'}</span>
15004
+ </div>
15005
+ <div style="margin-top:8px;font-size:11px;color:var(--text-muted)">Rate this workflow:</div>
15006
+ <div class="wf-store-detail-stars" id="wfStoreRateStars">
15007
+ ${[1,2,3,4,5].map(i => `<span onclick="_wfStoreUserRatings['${wf.name}']=${i};document.querySelectorAll('#wfStoreRateStars span').forEach((s,j)=>s.className=j<${i}?'filled':'')" class="${(_wfStoreUserRatings[wf.name]||0)>=i?'filled':''}">★</span>`).join('')}
15008
+ </div>
15009
+ </div>
15010
+ <div class="wf-store-detail-section">
15011
+ <button class="wf-store-detail-btn wf-store-detail-btn-secondary" style="flex:none;width:auto;padding:6px 14px;font-size:11px" onclick="document.getElementById('wfStoreReportForm').style.display=document.getElementById('wfStoreReportForm').style.display==='none'?'block':'none'">⚑ Report</button>
15012
+ <div id="wfStoreReportForm" class="wf-store-report-form" style="display:none">
15013
+ <select id="wfStoreReportReason"><option value="">Select reason...</option><option value="malicious">Malicious</option><option value="broken">Broken</option><option value="low quality">Low quality</option><option value="spam">Spam</option></select>
15014
+ <textarea id="wfStoreReportComment" placeholder="Optional comment..."></textarea>
15015
+ <button class="wf-store-report-submit" onclick="document.getElementById('wfStoreReportForm').style.display='none';const t=document.createElement('div');t.style.cssText='position:fixed;bottom:20px;right:20px;background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;padding:12px 20px;color:var(--text);font-size:13px;z-index:9999;animation:wfStoreDetailFadeIn .2s';t.textContent='✓ Report submitted';document.body.appendChild(t);setTimeout(()=>t.remove(),3000)">Submit Report</button>
15016
+ </div>
15017
+ </div>
13958
15018
  <div class="wf-store-detail-actions">
13959
15019
  ${installBtn}
13960
15020
  <button class="wf-store-detail-btn wf-store-detail-btn-secondary" onclick="wfStoreRun('${wf.name}')">Run</button>
13961
15021
  <button class="wf-store-detail-btn wf-store-detail-btn-secondary" onclick="wfStoreCanvas('${wf.name}')">Canvas</button>
13962
15022
  </div>
13963
15023
  </div>
15024
+ </div>
13964
15025
  </div>`;
13965
15026
  document.body.appendChild(modal);
13966
15027
  }
@@ -14046,6 +15107,7 @@ document.addEventListener('keydown', (e) => {
14046
15107
  });
14047
15108
 
14048
15109
  function wfInit() {
15110
+ wfRestoreLayoutState();
14049
15111
  wfLoadLibrary();
14050
15112
  wfInitPan();
14051
15113
  }
@@ -14177,5 +15239,241 @@ let wfInitialized = false;
14177
15239
  })();
14178
15240
  </script>
14179
15241
 
15242
+ <!-- Cost Dashboard -->
15243
+ <div id="costDetailPanel">
15244
+ <div class="cost-panel-inner">
15245
+ <div class="cost-panel-header">
15246
+ <h3>💰 Cost Dashboard</h3>
15247
+ <button id="costResetBtn" onclick="CostTracker.reset()">Reset Session</button>
15248
+ </div>
15249
+ <table id="costOpsTable">
15250
+ <thead><tr><th>Time</th><th>Operation</th><th>Model</th><th>Tokens</th><th>Cost</th><th>If v4-large</th><th>If OpenAI</th></tr></thead>
15251
+ <tbody></tbody>
15252
+ </table>
15253
+ <div class="cost-projection" id="costProjection"></div>
15254
+ </div>
15255
+ </div>
15256
+ <div id="costStatusBar">
15257
+ <span>💰 Session: <span class="cost-val" id="costTotal">$0.000000</span></span>
15258
+ <span class="cost-sep">│</span>
15259
+ <span><span id="costTokens">0</span> tokens</span>
15260
+ <span class="cost-sep">│</span>
15261
+ <span><span id="costOps">0</span> ops</span>
15262
+ <span class="cost-sep">│</span>
15263
+ <span>LLM: <span class="cost-val" id="costLLM">$0.000000</span> (<span id="costLLMIn">0</span>in/<span id="costLLMOut">0</span>out)</span>
15264
+ <span class="cost-sep">│</span>
15265
+ <span>If symmetric: <span id="costSymmetric">$0.000000</span> <span id="costSymDelta"></span></span>
15266
+ <span class="cost-sep">│</span>
15267
+ <span>If OpenAI: <span id="costCompetitor">$0.000000</span> <span id="costCompDelta"></span></span>
15268
+ <button id="costDetailsToggle" onclick="CostTracker.togglePanel()">Details ▾</button>
15269
+ </div>
15270
+
15271
+ <script>
15272
+ (function() {
15273
+ const PRICING = {
15274
+ 'voyage-4-large': 0.12, 'voyage-4': 0.06, 'voyage-4-lite': 0.02,
15275
+ 'voyage-code-3': 0.18, 'voyage-finance-2': 0.12, 'voyage-law-2': 0.12,
15276
+ 'rerank-2.5': 0.05, 'rerank-2.5-lite': 0.02,
15277
+ 'voyage-multimodal-3': 0.12,
15278
+ };
15279
+ const LARGE_PRICE = 0.12;
15280
+ const OPENAI_PRICE = 0.13;
15281
+
15282
+ // LLM pricing: per-million tokens { input, output }
15283
+ const LLM_PRICING = {
15284
+ 'claude-sonnet-4-5-20250929': { input: 3.00, output: 15.00 },
15285
+ 'claude-opus-4-20250514': { input: 15.00, output: 75.00 },
15286
+ 'claude-3-5-haiku-20241022': { input: 0.80, output: 4.00 },
15287
+ 'claude-sonnet-4': { input: 3.00, output: 15.00 },
15288
+ 'claude-opus-4': { input: 15.00, output: 75.00 },
15289
+ 'gpt-4o': { input: 2.50, output: 10.00 },
15290
+ 'gpt-4o-mini': { input: 0.15, output: 0.60 },
15291
+ 'gpt-4-turbo': { input: 10.00, output: 30.00 },
15292
+ 'o1': { input: 15.00, output: 60.00 },
15293
+ 'o1-mini': { input: 3.00, output: 12.00 },
15294
+ 'o3-mini': { input: 1.10, output: 4.40 },
15295
+ };
15296
+
15297
+ function getLLMPrice(model) {
15298
+ if (!model) return { input: 0, output: 0 };
15299
+ if (LLM_PRICING[model]) return LLM_PRICING[model];
15300
+ // Fuzzy match
15301
+ for (const k of Object.keys(LLM_PRICING)) {
15302
+ if (model.includes(k) || k.includes(model)) return LLM_PRICING[k];
15303
+ }
15304
+ // Match by prefix (e.g. "claude-sonnet-4-5" matches the full versioned key)
15305
+ for (const k of Object.keys(LLM_PRICING)) {
15306
+ const base = k.replace(/-\d{8}$/, '');
15307
+ if (model.includes(base) || base.includes(model)) return LLM_PRICING[k];
15308
+ }
15309
+ return { input: 0, output: 0 }; // Unknown model: free (don't inflate costs)
15310
+ }
15311
+
15312
+ function llmCostForTokens(model, inputTokens, outputTokens) {
15313
+ const prices = getLLMPrice(model);
15314
+ return (prices.input / 1_000_000) * inputTokens + (prices.output / 1_000_000) * outputTokens;
15315
+ }
15316
+
15317
+ function getPrice(model) {
15318
+ if (PRICING[model] != null) return PRICING[model];
15319
+ // fuzzy match
15320
+ for (const k of Object.keys(PRICING)) {
15321
+ if (model.includes(k) || k.includes(model)) return PRICING[k];
15322
+ }
15323
+ return 0.06; // default fallback
15324
+ }
15325
+
15326
+ function costForTokens(pricePerMillion, tokens) {
15327
+ return (pricePerMillion / 1_000_000) * tokens;
15328
+ }
15329
+
15330
+ const session = {
15331
+ operations: [], totalCost: 0, totalTokens: 0, totalOperations: 0,
15332
+ symmetricCost: 0, competitorCost: 0,
15333
+ llmCost: 0, llmInputTokens: 0, llmOutputTokens: 0,
15334
+ };
15335
+
15336
+ window.CostTracker = {
15337
+ addOperation(op, model, tokens) {
15338
+ if (!tokens || tokens <= 0) return;
15339
+ const price = getPrice(model);
15340
+ const cost = costForTokens(price, tokens);
15341
+ const largeCost = costForTokens(LARGE_PRICE, tokens);
15342
+ const competitorCost = costForTokens(OPENAI_PRICE, tokens);
15343
+
15344
+ session.operations.push({
15345
+ timestamp: new Date(), operation: op, model, tokens, cost,
15346
+ hypotheticalLargeCost: largeCost, hypotheticalCompetitorCost: competitorCost,
15347
+ });
15348
+ session.totalCost += cost;
15349
+ session.totalTokens += tokens;
15350
+ session.totalOperations++;
15351
+ session.symmetricCost += largeCost;
15352
+ session.competitorCost += competitorCost;
15353
+
15354
+ this._updateUI();
15355
+ this._showToast(op, model, tokens, cost, largeCost);
15356
+ },
15357
+
15358
+ addLLMOperation(op, model, inputTokens, outputTokens) {
15359
+ if ((!inputTokens || inputTokens <= 0) && (!outputTokens || outputTokens <= 0)) return;
15360
+ const cost = llmCostForTokens(model, inputTokens || 0, outputTokens || 0);
15361
+ const totalTokens = (inputTokens || 0) + (outputTokens || 0);
15362
+
15363
+ session.operations.push({
15364
+ timestamp: new Date(), operation: op, model: model || 'unknown',
15365
+ tokens: totalTokens, cost, isLLM: true,
15366
+ inputTokens: inputTokens || 0, outputTokens: outputTokens || 0,
15367
+ hypotheticalLargeCost: 0, hypotheticalCompetitorCost: 0,
15368
+ });
15369
+ session.totalCost += cost;
15370
+ session.totalTokens += totalTokens;
15371
+ session.totalOperations++;
15372
+ session.llmCost += cost;
15373
+ session.llmInputTokens += (inputTokens || 0);
15374
+ session.llmOutputTokens += (outputTokens || 0);
15375
+
15376
+ this._updateUI();
15377
+ this._showLLMToast(op, model, inputTokens || 0, outputTokens || 0, cost);
15378
+ },
15379
+
15380
+ reset() {
15381
+ session.operations.length = 0;
15382
+ session.totalCost = 0; session.totalTokens = 0; session.totalOperations = 0;
15383
+ session.symmetricCost = 0; session.competitorCost = 0;
15384
+ session.llmCost = 0; session.llmInputTokens = 0; session.llmOutputTokens = 0;
15385
+ this._updateUI();
15386
+ },
15387
+
15388
+ getProjectedMonthlyCost(queriesPerMonth) {
15389
+ if (session.totalOperations === 0) return { actual: 0, symmetric: 0, competitor: 0 };
15390
+ const avgCost = session.totalCost / session.totalOperations;
15391
+ const avgSym = session.symmetricCost / session.totalOperations;
15392
+ const avgComp = session.competitorCost / session.totalOperations;
15393
+ return { actual: avgCost * queriesPerMonth, symmetric: avgSym * queriesPerMonth, competitor: avgComp * queriesPerMonth };
15394
+ },
15395
+
15396
+ togglePanel() {
15397
+ const panel = document.getElementById('costDetailPanel');
15398
+ const btn = document.getElementById('costDetailsToggle');
15399
+ const isOpen = panel.classList.toggle('open');
15400
+ btn.textContent = isOpen ? 'Details ▴' : 'Details ▾';
15401
+ if (isOpen && typeof sendTelemetry === 'function') sendTelemetry('cost_panel_viewed');
15402
+ },
15403
+
15404
+ _updateUI() {
15405
+ document.getElementById('costTotal').textContent = '$' + session.totalCost.toFixed(6);
15406
+ document.getElementById('costTokens').textContent = session.totalTokens.toLocaleString();
15407
+ document.getElementById('costOps').textContent = session.totalOperations;
15408
+ document.getElementById('costLLM').textContent = '$' + session.llmCost.toFixed(6);
15409
+ document.getElementById('costLLMIn').textContent = session.llmInputTokens.toLocaleString();
15410
+ document.getElementById('costLLMOut').textContent = session.llmOutputTokens.toLocaleString();
15411
+ document.getElementById('costSymmetric').textContent = '$' + session.symmetricCost.toFixed(6);
15412
+ document.getElementById('costCompetitor').textContent = '$' + session.competitorCost.toFixed(6);
15413
+
15414
+ // Deltas
15415
+ const symDelta = document.getElementById('costSymDelta');
15416
+ const compDelta = document.getElementById('costCompDelta');
15417
+ if (session.totalCost > 0) {
15418
+ const symPct = ((session.symmetricCost - session.totalCost) / session.totalCost * 100).toFixed(0);
15419
+ const compPct = ((session.competitorCost - session.totalCost) / session.totalCost * 100).toFixed(0);
15420
+ symDelta.textContent = symPct > 0 ? `(+${symPct}%)` : `(${symPct}%)`;
15421
+ symDelta.className = symPct > 0 ? 'cost-more' : 'cost-savings';
15422
+ compDelta.textContent = compPct > 0 ? `(+${compPct}%)` : `(${compPct}%)`;
15423
+ compDelta.className = compPct > 0 ? 'cost-more' : 'cost-savings';
15424
+ } else {
15425
+ symDelta.textContent = ''; compDelta.textContent = '';
15426
+ }
15427
+
15428
+ // Table
15429
+ const tbody = document.querySelector('#costOpsTable tbody');
15430
+ tbody.innerHTML = session.operations.map(o => {
15431
+ const tokensDisplay = o.isLLM
15432
+ ? `${o.inputTokens.toLocaleString()}in / ${o.outputTokens.toLocaleString()}out`
15433
+ : o.tokens.toLocaleString();
15434
+ const largeCostDisplay = o.isLLM ? 'n/a' : `$${o.hypotheticalLargeCost.toFixed(6)}`;
15435
+ const compCostDisplay = o.isLLM ? 'n/a' : `$${o.hypotheticalCompetitorCost.toFixed(6)}`;
15436
+ return `<tr${o.isLLM ? ' style="background:rgba(180,90,242,0.08)"' : ''}>
15437
+ <td>${o.timestamp.toLocaleTimeString()}</td>
15438
+ <td>${o.operation}</td>
15439
+ <td>${o.model}</td>
15440
+ <td>${tokensDisplay}</td>
15441
+ <td>$${o.cost.toFixed(6)}</td>
15442
+ <td>${largeCostDisplay}</td>
15443
+ <td>${compCostDisplay}</td>
15444
+ </tr>`;
15445
+ }).join('');
15446
+
15447
+ // Projection
15448
+ const proj = this.getProjectedMonthlyCost(1_000_000);
15449
+ const projEl = document.getElementById('costProjection');
15450
+ if (session.totalOperations > 0) {
15451
+ projEl.innerHTML = `📊 Projected at 1M queries/month: <span>$${proj.actual.toFixed(2)}</span> actual │ $${proj.symmetric.toFixed(2)} symmetric │ $${proj.competitor.toFixed(2)} OpenAI`;
15452
+ } else {
15453
+ projEl.innerHTML = '';
15454
+ }
15455
+ },
15456
+
15457
+ _showToast(op, model, tokens, cost, largeCost) {
15458
+ const saved = largeCost > 0 ? Math.round((1 - cost / largeCost) * 100) : 0;
15459
+ const savingsText = saved > 0 ? ` — saved ${saved}% vs v4-large` : '';
15460
+ const toast = document.createElement('div');
15461
+ toast.className = 'cost-toast';
15462
+ toast.textContent = `✅ ${op} — $${cost.toFixed(6)} (${model}, ${tokens.toLocaleString()} tokens)${savingsText}`;
15463
+ document.body.appendChild(toast);
15464
+ setTimeout(() => toast.remove(), 3100);
15465
+ },
15466
+
15467
+ _showLLMToast(op, model, inputTokens, outputTokens, cost) {
15468
+ const toast = document.createElement('div');
15469
+ toast.className = 'cost-toast';
15470
+ toast.textContent = `🤖 ${op} — $${cost.toFixed(6)} (${model || 'LLM'}, ${inputTokens.toLocaleString()}in/${outputTokens.toLocaleString()}out)`;
15471
+ document.body.appendChild(toast);
15472
+ setTimeout(() => toast.remove(), 3100);
15473
+ }
15474
+ };
15475
+ })();
15476
+ </script>
15477
+
14180
15478
  </body>
14181
15479
  </html>