voyageai-cli 1.26.1 → 1.28.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.
@@ -573,7 +573,7 @@ body:not(.is-electron) .sidebar-drag-region {
573
573
  background-repeat: no-repeat;
574
574
  opacity: 0.05;
575
575
  pointer-events: none;
576
- z-index: 0;
576
+ z-index: -1;
577
577
  filter: invert(1);
578
578
  }
579
579
 
@@ -593,7 +593,7 @@ body:not(.is-electron) .content-drag-region { display: none; }
593
593
  body:not(.is-electron) .update-banner { display: none !important; }
594
594
 
595
595
  /* Main */
596
- .main { padding: 24px; max-width: 1200px; margin: 0 auto; width: 100%; }
596
+ .main { padding: 24px; max-width: 1200px; margin: 0 auto; width: 100%; flex: 1; overflow-y: auto; min-height: 0; }
597
597
 
598
598
  /* Legacy compat — hide old horizontal bar (replaced by sidebar) */
599
599
  .nav { display: none; }
@@ -992,8 +992,9 @@ select:focus { outline: none; border-color: var(--accent); }
992
992
  }
993
993
 
994
994
  .explore-card-icon {
995
- font-size: 28px;
996
995
  margin-bottom: 8px;
996
+ color: var(--accent, #00D4AA);
997
+ opacity: 0.85;
997
998
  }
998
999
  .explore-card-title {
999
1000
  font-size: 16px;
@@ -1056,7 +1057,7 @@ select:focus { outline: none; border-color: var(--accent); }
1056
1057
  padding: 24px 28px 16px;
1057
1058
  border-bottom: 1px solid var(--border);
1058
1059
  }
1059
- .explore-modal-icon { font-size: 32px; }
1060
+ .explore-modal-icon { color: var(--accent, #00D4AA); }
1060
1061
  .explore-modal-title {
1061
1062
  font-size: 18px;
1062
1063
  font-weight: 600;
@@ -1825,6 +1826,34 @@ select:focus { outline: none; border-color: var(--accent); }
1825
1826
  line-height: 1.5;
1826
1827
  }
1827
1828
 
1829
+ /* ── Contextual docs link ── */
1830
+ .page-header { position: relative; }
1831
+ .page-header-docs {
1832
+ position: absolute; top: 0; right: 0;
1833
+ display: inline-flex; align-items: center; gap: 5px;
1834
+ font-size: 12px; font-weight: 500;
1835
+ color: var(--text-muted); text-decoration: none;
1836
+ padding: 4px 10px; border-radius: 6px;
1837
+ border: 1px solid var(--border);
1838
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
1839
+ }
1840
+ .page-header-docs:hover {
1841
+ color: var(--accent, #6c63ff); border-color: var(--accent, #6c63ff);
1842
+ background: rgba(108,99,255,0.06);
1843
+ }
1844
+ .page-header-docs svg { opacity: 0.6; }
1845
+ .page-header-docs:hover svg { opacity: 1; }
1846
+ .sidebar-docs-link {
1847
+ display: inline-flex; align-items: center; gap: 4px;
1848
+ font-size: 10px; color: var(--text-muted); text-decoration: none;
1849
+ padding: 2px 6px; border-radius: 4px;
1850
+ transition: color 0.15s, background 0.15s;
1851
+ }
1852
+ .sidebar-docs-link:hover {
1853
+ color: var(--accent, #6c63ff); background: rgba(108,99,255,0.08);
1854
+ }
1855
+ .sidebar-docs-link svg { width: 12px; height: 12px; }
1856
+
1828
1857
  /* ── Settings page ── */
1829
1858
  .settings-container { max-width: 640px; margin: 0 auto; }
1830
1859
  .settings-section {
@@ -2052,6 +2081,34 @@ select:focus { outline: none; border-color: var(--accent); }
2052
2081
  transition: opacity 0.3s;
2053
2082
  }
2054
2083
  .settings-saved.show { opacity: 1; }
2084
+ .doctor-check {
2085
+ display: flex;
2086
+ align-items: flex-start;
2087
+ gap: 10px;
2088
+ padding: 10px 14px;
2089
+ border-radius: var(--radius);
2090
+ background: var(--bg-input);
2091
+ border: 1px solid var(--border);
2092
+ }
2093
+ .doctor-check-icon { font-size: 15px; flex-shrink: 0; line-height: 1.4; }
2094
+ .doctor-check-body { flex: 1; min-width: 0; }
2095
+ .doctor-check-name { font-size: 13px; font-weight: 600; color: var(--text); }
2096
+ .doctor-check-msg { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
2097
+ .doctor-check-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; font-family: var(--mono); }
2098
+ .doctor-check-badge {
2099
+ font-size: 10px;
2100
+ padding: 2px 8px;
2101
+ border-radius: 10px;
2102
+ font-weight: 600;
2103
+ text-transform: uppercase;
2104
+ letter-spacing: 0.5px;
2105
+ flex-shrink: 0;
2106
+ align-self: center;
2107
+ }
2108
+ .doctor-check-badge.pass { background: rgba(0,212,170,0.12); color: var(--success); }
2109
+ .doctor-check-badge.fail { background: rgba(255,105,96,0.12); color: var(--error); }
2110
+ .doctor-check-badge.warn { background: rgba(255,192,16,0.12); color: var(--warning); }
2111
+ .doctor-check-badge.optional { background: rgba(136,147,151,0.12); color: var(--text-muted); }
2055
2112
  .settings-reset-btn {
2056
2113
  background: transparent;
2057
2114
  border: 1px solid var(--border);
@@ -2316,6 +2373,25 @@ select:focus { outline: none; border-color: var(--accent); }
2316
2373
  background: var(--bg-card);
2317
2374
  border: 1px solid var(--border);
2318
2375
  border-bottom-left-radius: 4px;
2376
+ position: relative;
2377
+ }
2378
+ .chat-copy-btn {
2379
+ position: absolute; top: 6px; right: 6px;
2380
+ width: 28px; height: 28px; border-radius: 6px;
2381
+ border: 1px solid var(--border); background: var(--bg);
2382
+ color: var(--text-muted); cursor: pointer;
2383
+ display: flex; align-items: center; justify-content: center;
2384
+ opacity: 0; transition: opacity 0.15s, background 0.15s, color 0.15s;
2385
+ pointer-events: none; z-index: 1;
2386
+ }
2387
+ .chat-message.assistant:hover .chat-copy-btn {
2388
+ opacity: 1; pointer-events: auto;
2389
+ }
2390
+ .chat-copy-btn:hover {
2391
+ background: var(--bg-card); color: var(--text); border-color: var(--text-muted);
2392
+ }
2393
+ .chat-copy-btn.copied {
2394
+ color: #69F0AE; border-color: #69F0AE;
2319
2395
  }
2320
2396
  .chat-message.system-msg {
2321
2397
  align-self: center;
@@ -2421,21 +2497,160 @@ select:focus { outline: none; border-color: var(--accent); }
2421
2497
  margin: 12px 0;
2422
2498
  }
2423
2499
 
2424
- .chat-tool-calls {
2425
- display: flex; flex-direction: column; gap: 4px;
2426
- margin-bottom: 4px; align-self: flex-start; max-width: 85%;
2500
+ /* Agent thinking panel */
2501
+ .chat-thinking {
2502
+ align-self: flex-start;
2503
+ max-width: 85%;
2504
+ margin-bottom: 2px;
2505
+ font-size: 13px;
2427
2506
  }
2428
- .chat-tool-call {
2429
- font-size: 12px; color: var(--text-muted);
2430
- padding: 6px 12px; border-radius: 8px;
2431
- background: var(--bg-card); border: 1px dashed var(--border);
2432
- display: flex; align-items: center; gap: 6px;
2507
+ .chat-thinking summary {
2508
+ cursor: pointer;
2509
+ list-style: none;
2510
+ display: flex;
2511
+ align-items: center;
2512
+ gap: 6px;
2513
+ padding: 8px 14px;
2514
+ border-radius: 10px;
2515
+ background: var(--bg-card);
2516
+ border: 1px solid var(--border);
2517
+ color: var(--text-muted);
2518
+ font-size: 13px;
2519
+ transition: background 0.15s, border-color 0.15s;
2520
+ user-select: none;
2521
+ }
2522
+ .chat-thinking summary::-webkit-details-marker { display: none; }
2523
+ .chat-thinking summary:hover {
2524
+ background: var(--bg-surface);
2525
+ border-color: var(--accent-dim, #009E80);
2526
+ }
2527
+ .chat-thinking[open] summary {
2528
+ border-radius: 10px 10px 0 0;
2529
+ border-bottom-color: transparent;
2530
+ }
2531
+ .chat-thinking .thinking-icon {
2532
+ font-size: 14px;
2533
+ line-height: 1;
2534
+ }
2535
+ .chat-thinking .thinking-label {
2536
+ font-weight: 500;
2537
+ }
2538
+ .chat-thinking .thinking-chevron {
2539
+ margin-left: auto;
2540
+ font-size: 10px;
2541
+ opacity: 0.5;
2542
+ transition: transform 0.15s;
2543
+ }
2544
+ .chat-thinking[open] .thinking-chevron {
2545
+ transform: rotate(90deg);
2546
+ }
2547
+ .chat-thinking .thinking-count {
2548
+ background: var(--bg-input, #112733);
2549
+ color: var(--text-muted);
2550
+ font-size: 11px;
2551
+ padding: 1px 7px;
2552
+ border-radius: 10px;
2553
+ font-weight: 600;
2554
+ }
2555
+ .chat-thinking .thinking-elapsed {
2556
+ font-size: 11px;
2557
+ opacity: 0.45;
2558
+ }
2559
+ .chat-thinking .thinking-timeline {
2560
+ border: 1px solid var(--border);
2561
+ border-top: none;
2562
+ border-radius: 0 0 10px 10px;
2563
+ padding: 10px 14px;
2564
+ background: var(--bg-card);
2565
+ }
2566
+ .thinking-step {
2567
+ display: flex;
2568
+ gap: 10px;
2569
+ padding: 7px 0;
2570
+ position: relative;
2571
+ animation: thinkingSlideIn 0.25s ease-out;
2572
+ }
2573
+ @keyframes thinkingSlideIn {
2574
+ from { opacity: 0; transform: translateY(-4px); }
2575
+ to { opacity: 1; transform: translateY(0); }
2576
+ }
2577
+ .thinking-step + .thinking-step {
2578
+ border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
2579
+ }
2580
+ .thinking-step-icon {
2581
+ width: 28px;
2582
+ height: 28px;
2583
+ border-radius: 50%;
2584
+ display: flex;
2585
+ align-items: center;
2586
+ justify-content: center;
2587
+ font-size: 14px;
2588
+ flex-shrink: 0;
2589
+ background: var(--bg-input, #112733);
2590
+ border: 1px solid var(--border);
2591
+ }
2592
+ .thinking-step.active .thinking-step-icon {
2593
+ border-color: var(--accent);
2594
+ animation: thinkingPulse 1.2s ease-in-out infinite;
2595
+ }
2596
+ @keyframes thinkingPulse {
2597
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(0, 212, 170, 0.25); }
2598
+ 50% { box-shadow: 0 0 0 6px rgba(0, 212, 170, 0); }
2599
+ }
2600
+ .thinking-step.error .thinking-step-icon {
2601
+ border-color: #e74c3c;
2602
+ background: rgba(231, 76, 60, 0.08);
2603
+ }
2604
+ .thinking-step.done .thinking-step-icon {
2605
+ border-color: var(--accent-dim, #009E80);
2606
+ opacity: 0.7;
2607
+ }
2608
+ .thinking-step-body {
2609
+ flex: 1;
2610
+ min-width: 0;
2611
+ display: flex;
2612
+ flex-direction: column;
2613
+ justify-content: center;
2614
+ gap: 2px;
2615
+ }
2616
+ .thinking-step-header {
2617
+ display: flex;
2618
+ align-items: center;
2619
+ gap: 6px;
2620
+ }
2621
+ .thinking-step-name {
2622
+ font-weight: 600;
2623
+ color: var(--text);
2624
+ font-size: 12.5px;
2625
+ }
2626
+ .thinking-step-desc {
2627
+ font-size: 12px;
2628
+ color: var(--text-muted);
2629
+ opacity: 0.7;
2630
+ }
2631
+ .thinking-step-time {
2632
+ font-size: 11px;
2633
+ opacity: 0.45;
2634
+ margin-left: auto;
2635
+ white-space: nowrap;
2636
+ }
2637
+ .thinking-step-detail {
2638
+ font-size: 11.5px;
2639
+ color: var(--text-muted);
2640
+ margin-top: 2px;
2641
+ line-height: 1.4;
2642
+ overflow: hidden;
2643
+ text-overflow: ellipsis;
2644
+ display: -webkit-box;
2645
+ -webkit-line-clamp: 2;
2646
+ -webkit-box-orient: vertical;
2647
+ }
2648
+ .thinking-step-detail code {
2649
+ font-size: 11px;
2650
+ background: var(--bg-input, #112733);
2651
+ padding: 1px 4px;
2652
+ border-radius: 3px;
2433
2653
  }
2434
- .chat-tool-call .tool-icon { opacity: 0.5; font-size: 11px; }
2435
- .chat-tool-call .tool-name { font-weight: 600; }
2436
- .chat-tool-call .tool-time { opacity: 0.5; }
2437
- .chat-tool-call.error { border-color: #e74c3c; }
2438
- .chat-tool-call .tool-error { color: #e74c3c; font-size: 11px; }
2439
2654
  .chat-input-area {
2440
2655
  padding: 12px 16px;
2441
2656
  border-top: 1px solid var(--border);
@@ -2750,56 +2965,654 @@ select:focus { outline: none; border-color: var(--accent); }
2750
2965
  .settings-row { flex-direction: column; align-items: flex-start; gap: 8px; }
2751
2966
  .settings-select, .settings-input { min-width: 100%; }
2752
2967
  }
2968
+
2969
+ /* ========== WORKFLOW VISUALIZER ========== */
2970
+ /* Expand .main when workflows tab is active to fill viewport */
2971
+ .main:has(#tab-workflows.active) {
2972
+ max-width: none; padding: 0; overflow: hidden;
2973
+ }
2974
+ #tab-workflows.tab-panel.active {
2975
+ display: flex; flex-direction: column;
2976
+ height: 100%;
2977
+ /* Fill entire .main which is flex:1 inside the 100vh app-shell */
2978
+ }
2979
+ .wf-container {
2980
+ display: flex; flex: 1; gap: 0; overflow: hidden;
2981
+ border-radius: 0;
2982
+ background: var(--bg-card);
2983
+ min-height: 0;
2984
+ }
2985
+ .wf-library {
2986
+ width: 220px; min-width: 180px; flex-shrink: 0;
2987
+ border-right: 1px solid var(--border);
2988
+ display: flex; flex-direction: column;
2989
+ background: var(--bg);
2990
+ }
2991
+ .wf-library-header {
2992
+ padding: 14px 16px 10px; font-weight: 700; font-size: 13px;
2993
+ color: var(--text); border-bottom: 1px solid var(--border);
2994
+ display: flex; align-items: center; justify-content: space-between;
2995
+ }
2996
+ .wf-library-list {
2997
+ flex: 1; overflow-y: auto; padding: 8px;
2998
+ }
2999
+ .wf-library-footer {
3000
+ padding: 8px; border-top: 1px solid var(--border);
3001
+ }
3002
+ .wf-load-file-btn {
3003
+ width: 100%; padding: 7px 12px; border-radius: 6px;
3004
+ border: 1px dashed var(--border); background: transparent;
3005
+ color: var(--text-muted); cursor: pointer; font-size: 12px;
3006
+ display: flex; align-items: center; justify-content: center; gap: 6px;
3007
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
3008
+ }
3009
+ .wf-load-file-btn:hover {
3010
+ color: var(--text); border-color: var(--text-muted); background: var(--bg-card);
3011
+ }
3012
+ .wf-library-item {
3013
+ padding: 10px 12px; border-radius: 8px; cursor: pointer;
3014
+ margin-bottom: 4px; transition: background 0.15s;
3015
+ border: 1px solid transparent;
3016
+ }
3017
+ .wf-library-item:hover { background: var(--bg-card); }
3018
+ .wf-library-item.active {
3019
+ background: var(--bg-card); border-color: var(--accent, #6c63ff);
3020
+ }
3021
+ .wf-library-item-name {
3022
+ font-weight: 600; font-size: 13px; color: var(--text);
3023
+ margin-bottom: 2px;
3024
+ }
3025
+ .wf-library-item-desc {
3026
+ font-size: 11px; color: var(--text-muted);
3027
+ line-height: 1.3; display: -webkit-box;
3028
+ -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
3029
+ }
3030
+ .wf-library-item-meta {
3031
+ font-size: 10px; color: var(--text-muted); margin-top: 4px;
3032
+ display: flex; gap: 8px;
3033
+ }
3034
+ /* Collapsible examples section */
3035
+ .wf-library-section { padding: 0; margin-top: 4px; }
3036
+ .wf-library-section-toggle {
3037
+ display: flex; align-items: center; gap: 6px;
3038
+ width: 100%; padding: 8px 12px; border: none; background: none;
3039
+ color: var(--text-muted); font-size: 11px; font-weight: 600;
3040
+ text-transform: uppercase; letter-spacing: 0.5px; cursor: pointer;
3041
+ transition: color 0.15s;
3042
+ }
3043
+ .wf-library-section-toggle:hover { color: var(--text); }
3044
+ .wf-library-section-toggle .arrow {
3045
+ font-size: 8px; transition: transform 0.2s; display: inline-block;
3046
+ }
3047
+ .wf-library-section-toggle.open .arrow { transform: rotate(90deg); }
3048
+ .wf-library-category {
3049
+ font-size: 10px; font-weight: 600; color: var(--text-muted);
3050
+ padding: 8px 12px 2px; text-transform: uppercase; letter-spacing: 0.5px;
3051
+ }
3052
+ .wf-canvas-area {
3053
+ flex: 1; position: relative; overflow: hidden;
3054
+ background: var(--bg);
3055
+ background-image: radial-gradient(circle, var(--border) 1px, transparent 1px);
3056
+ background-size: 20px 20px;
3057
+ }
3058
+ .wf-canvas-toolbar {
3059
+ position: absolute; top: 12px; right: 12px; z-index: 10;
3060
+ display: flex; gap: 4px;
3061
+ }
3062
+ .wf-canvas-toolbar button {
3063
+ width: 32px; height: 32px; border-radius: 8px;
3064
+ border: 1px solid var(--border); background: var(--bg-card);
3065
+ color: var(--text); cursor: pointer; font-size: 14px;
3066
+ display: flex; align-items: center; justify-content: center;
3067
+ transition: background 0.15s;
3068
+ }
3069
+ .wf-canvas-toolbar button:hover { background: var(--bg); border-color: var(--text-muted); }
3070
+ .wf-canvas-toolbar .wf-plan-btn {
3071
+ width: auto; padding: 0 12px; gap: 4px;
3072
+ font-weight: 600; font-size: 12px;
3073
+ }
3074
+ .wf-canvas-toolbar .wf-plan-btn:disabled { opacity: 0.5; cursor: not-allowed; }
3075
+ .wf-canvas-toolbar .wf-run-btn {
3076
+ width: auto; padding: 0 12px; gap: 4px;
3077
+ background: var(--accent, #6c63ff); color: #fff; border-color: transparent;
3078
+ font-weight: 600; font-size: 12px;
3079
+ }
3080
+ .wf-canvas-toolbar .wf-run-btn:hover { opacity: 0.9; }
3081
+ .wf-canvas-toolbar .wf-run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
3082
+ .wf-canvas-toolbar .wf-new-btn {
3083
+ width: auto; padding: 0 12px; gap: 4px;
3084
+ font-weight: 600; font-size: 12px;
3085
+ background: var(--bg-card); border: 1px solid var(--accent);
3086
+ color: var(--accent);
3087
+ }
3088
+ .wf-canvas-toolbar .wf-new-btn:hover { background: var(--accent); color: #fff; }
3089
+ .wf-canvas-toolbar .wf-edit-btn {
3090
+ width: auto; padding: 0 12px; gap: 4px;
3091
+ font-weight: 600; font-size: 12px;
3092
+ background: var(--bg-card); border: 1px solid var(--border);
3093
+ color: var(--text-muted);
3094
+ }
3095
+ .wf-canvas-toolbar .wf-edit-btn:hover { border-color: var(--accent); color: var(--accent); }
3096
+ .wf-canvas-toolbar .wf-edit-btn:disabled { opacity: 0.4; cursor: not-allowed; }
3097
+ .wf-canvas-toolbar .wf-edit-btn:disabled:hover { border-color: var(--border); color: var(--text-muted); }
3098
+ .wf-toolbar-sep {
3099
+ width: 1px; height: 20px; background: var(--border); margin: 0 2px;
3100
+ }
3101
+ /* Library / Palette tabs */
3102
+ .wf-library-tabs {
3103
+ display: flex; gap: 2px; width: 100%;
3104
+ background: var(--bg-input); border-radius: 6px; padding: 2px;
3105
+ }
3106
+ .wf-lib-tab {
3107
+ flex: 1; padding: 5px 0; border: none; border-radius: 4px;
3108
+ background: transparent; color: var(--text-muted); cursor: pointer;
3109
+ font-size: 11px; font-weight: 600; font-family: var(--font);
3110
+ transition: background 0.15s, color 0.15s; text-align: center;
3111
+ }
3112
+ .wf-lib-tab.active { background: var(--accent); color: #fff; }
3113
+ .wf-lib-tab:hover:not(.active) { color: var(--text); }
3114
+ /* Palette items */
3115
+ .wf-palette-category { margin-bottom: 8px; }
3116
+ .wf-palette-category-title {
3117
+ font-size: 10px; font-weight: 700; text-transform: uppercase;
3118
+ letter-spacing: 0.5px; color: var(--text-muted);
3119
+ padding: 4px 12px; margin-bottom: 2px;
3120
+ }
3121
+ .wf-palette-item {
3122
+ display: flex; align-items: center; gap: 8px;
3123
+ padding: 7px 12px; border-radius: 6px; cursor: grab;
3124
+ font-size: 12px; color: var(--text); transition: background 0.15s;
3125
+ }
3126
+ .wf-palette-item:hover { background: var(--bg-card); }
3127
+ .wf-palette-item:active { cursor: grabbing; }
3128
+ .wf-palette-icon { width: 20px; text-align: center; display: flex; align-items: center; justify-content: center; }
3129
+ .wf-palette-label { font-weight: 500; }
3130
+ /* Builder port styles */
3131
+ .wf-port-builder { cursor: crosshair; transition: r 0.15s; pointer-events: all !important; }
3132
+ .wf-port-builder:hover { r: 9; filter: brightness(1.4); }
3133
+ .wf-inspector-delete-btn {
3134
+ width: 100%; padding: 8px 0; border: none; border-radius: 6px;
3135
+ background: var(--error); color: #fff; cursor: pointer;
3136
+ font-size: 12px; font-weight: 600; margin-top: 12px;
3137
+ transition: opacity 0.15s;
3138
+ }
3139
+ .wf-inspector-delete-btn:hover { opacity: 0.85; }
3140
+ .wf-validation-errors {
3141
+ background: rgba(255,105,96,0.08); border: 1px solid var(--error);
3142
+ border-radius: 6px; padding: 10px 12px; margin-bottom: 12px;
3143
+ font-size: 11px; color: var(--error);
3144
+ }
3145
+ .wf-validation-errors ul { margin: 4px 0 0 16px; }
3146
+ /* Dry-run plan overlay */
3147
+ .wf-dryrun-overlay {
3148
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0;
3149
+ z-index: 8; background: rgba(0,0,0,0.5);
3150
+ display: flex; align-items: center; justify-content: center;
3151
+ animation: wf-modal-fade-in 0.15s ease;
3152
+ }
3153
+ .wf-dryrun-panel {
3154
+ width: 90%; max-width: 520px; max-height: 70vh;
3155
+ background: var(--bg); border: 1px solid var(--border);
3156
+ border-radius: 12px; display: flex; flex-direction: column;
3157
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
3158
+ animation: wf-modal-slide-up 0.2s ease;
3159
+ }
3160
+ .wf-dryrun-header {
3161
+ display: flex; align-items: center; justify-content: space-between;
3162
+ padding: 14px 20px; border-bottom: 1px solid var(--border);
3163
+ }
3164
+ .wf-dryrun-title { font-weight: 700; font-size: 14px; color: var(--text); }
3165
+ .wf-dryrun-close {
3166
+ border: none; background: none; color: var(--text-muted);
3167
+ cursor: pointer; font-size: 20px; padding: 2px 8px;
3168
+ }
3169
+ .wf-dryrun-close:hover { color: var(--text); }
3170
+ .wf-dryrun-body {
3171
+ flex: 1; overflow-y: auto; padding: 16px 20px;
3172
+ }
3173
+ .wf-dryrun-layer {
3174
+ margin-bottom: 16px;
3175
+ }
3176
+ .wf-dryrun-layer-title {
3177
+ font-size: 11px; font-weight: 700; text-transform: uppercase;
3178
+ letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 8px;
3179
+ display: flex; align-items: center; gap: 8px;
3180
+ }
3181
+ .wf-dryrun-layer-badge {
3182
+ font-size: 10px; padding: 1px 6px; border-radius: 10px;
3183
+ background: var(--accent); color: #fff; font-weight: 600;
3184
+ }
3185
+ .wf-dryrun-step {
3186
+ padding: 8px 12px; border-radius: 6px; margin-bottom: 4px;
3187
+ background: var(--bg-card); border: 1px solid var(--border);
3188
+ display: flex; align-items: center; gap: 10px; font-size: 12px;
3189
+ }
3190
+ .wf-dryrun-step-icon { flex-shrink: 0; display: flex; align-items: center; }
3191
+ .wf-dryrun-step-info { flex: 1; min-width: 0; }
3192
+ .wf-dryrun-step-name { font-weight: 600; color: var(--text); }
3193
+ .wf-dryrun-step-tool { color: var(--text-muted); font-size: 11px; }
3194
+ .wf-dryrun-step-cond {
3195
+ font-size: 10px; color: #FFD54F; margin-top: 2px;
3196
+ font-family: 'SF Mono', 'Fira Code', monospace;
3197
+ }
3198
+ .wf-dryrun-summary {
3199
+ padding: 12px 16px; border-radius: 8px; margin-top: 8px;
3200
+ background: var(--bg-card); border: 1px solid var(--border);
3201
+ font-size: 12px; color: var(--text-muted);
3202
+ display: flex; gap: 16px;
3203
+ }
3204
+ .wf-dryrun-stat { text-align: center; }
3205
+ .wf-dryrun-stat-value { font-size: 20px; font-weight: 700; color: var(--text); }
3206
+ .wf-dryrun-stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.3px; }
3207
+ [data-theme="light"] .wf-dryrun-panel { box-shadow: 0 8px 32px rgba(0,30,43,0.15); }
3208
+ [data-theme="light"] .wf-dryrun-step-cond { color: #944F01; }
3209
+ .wf-canvas-toolbar .wf-stop-btn {
3210
+ width: auto; padding: 0 12px; gap: 4px;
3211
+ background: #e74c3c; color: #fff; border-color: transparent;
3212
+ font-weight: 600; font-size: 12px;
3213
+ }
3214
+ .wf-canvas-toolbar .wf-stop-btn:hover { opacity: 0.9; background: #c0392b; }
3215
+ /* Execution status bar */
3216
+ .wf-exec-status {
3217
+ position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
3218
+ z-index: 10; display: flex; align-items: center; gap: 8px;
3219
+ padding: 6px 16px; border-radius: 20px;
3220
+ background: var(--bg-card); border: 1px solid var(--border);
3221
+ font-size: 12px; color: var(--text); box-shadow: 0 2px 8px rgba(0,0,0,0.15);
3222
+ }
3223
+ .wf-exec-status-dot {
3224
+ width: 8px; height: 8px; border-radius: 50%;
3225
+ background: #69F0AE; animation: wf-status-blink 1s ease-in-out infinite;
3226
+ }
3227
+ .wf-exec-status.error .wf-exec-status-dot { background: #e74c3c; animation: none; }
3228
+ .wf-exec-status.stopped .wf-exec-status-dot { background: #FFB74D; animation: none; }
3229
+ .wf-exec-status.done .wf-exec-status-dot { background: #69F0AE; animation: none; }
3230
+ @keyframes wf-status-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
3231
+ .wf-exec-status-time { color: var(--text-muted); font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; }
3232
+ /* Output modal */
3233
+ .wf-output-modal-backdrop {
3234
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
3235
+ background: rgba(0,0,0,0.6); z-index: 9999;
3236
+ display: flex; align-items: center; justify-content: center;
3237
+ animation: wf-modal-fade-in 0.15s ease;
3238
+ }
3239
+ @keyframes wf-modal-fade-in { from { opacity: 0; } to { opacity: 1; } }
3240
+ .wf-output-modal {
3241
+ width: 90%; max-width: 800px; max-height: 80vh;
3242
+ background: var(--bg); border: 1px solid var(--border);
3243
+ border-radius: 12px; display: flex; flex-direction: column;
3244
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
3245
+ animation: wf-modal-slide-up 0.2s ease;
3246
+ }
3247
+ @keyframes wf-modal-slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
3248
+ .wf-output-modal-header {
3249
+ display: flex; align-items: center; justify-content: space-between;
3250
+ padding: 14px 20px; border-bottom: 1px solid var(--border);
3251
+ }
3252
+ .wf-output-modal-title { font-weight: 700; font-size: 14px; color: var(--text); }
3253
+ .wf-output-modal-actions { display: flex; align-items: center; gap: 6px; }
3254
+ .wf-output-modal-btn {
3255
+ display: inline-flex; align-items: center; gap: 4px;
3256
+ padding: 5px 10px; border-radius: 6px;
3257
+ border: 1px solid var(--border); background: var(--bg-card);
3258
+ color: var(--text); cursor: pointer; font-size: 12px;
3259
+ transition: background 0.15s, border-color 0.15s;
3260
+ }
3261
+ .wf-output-modal-btn:hover { background: var(--bg); border-color: var(--text-muted); }
3262
+ .wf-output-modal-btn.close { border: none; font-size: 20px; padding: 2px 8px; }
3263
+ .wf-output-modal-body {
3264
+ flex: 1; overflow: auto; padding: 16px 20px;
3265
+ margin: 0; font-family: 'SF Mono', 'Fira Code', monospace;
3266
+ font-size: 12px; line-height: 1.5; color: var(--text);
3267
+ white-space: pre-wrap; word-break: break-word;
3268
+ }
3269
+ /* Expand button for output sections */
3270
+ .wf-output-expand-btn {
3271
+ display: inline-flex; align-items: center; gap: 4px;
3272
+ padding: 3px 8px; border-radius: 4px; margin-top: 6px;
3273
+ border: 1px solid var(--border); background: transparent;
3274
+ color: var(--text-muted); cursor: pointer; font-size: 10px;
3275
+ transition: color 0.15s, border-color 0.15s;
3276
+ }
3277
+ .wf-output-expand-btn:hover { color: var(--text); border-color: var(--text-muted); }
3278
+ /* Input modal (pre-execution) */
3279
+ .wf-input-modal-backdrop {
3280
+ position: fixed; inset: 0; z-index: 9999;
3281
+ background: rgba(0,0,0,0.55); display: flex;
3282
+ align-items: center; justify-content: center;
3283
+ }
3284
+ .wf-input-modal {
3285
+ background: var(--bg-surface, var(--bg)); border: 1px solid var(--border);
3286
+ border-radius: 10px; width: 440px; max-width: 90vw;
3287
+ max-height: 80vh; display: flex; flex-direction: column;
3288
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
3289
+ }
3290
+ .wf-input-modal-header {
3291
+ display: flex; align-items: center; justify-content: space-between;
3292
+ padding: 14px 20px; border-bottom: 1px solid var(--border);
3293
+ }
3294
+ .wf-input-modal-title { font-weight: 700; font-size: 14px; color: var(--text); }
3295
+ .wf-input-modal-body {
3296
+ flex: 1; overflow-y: auto; padding: 16px 20px;
3297
+ }
3298
+ .wf-input-modal-field { margin-bottom: 14px; }
3299
+ .wf-input-modal-label {
3300
+ font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 4px;
3301
+ }
3302
+ .wf-input-modal-desc {
3303
+ font-size: 11px; color: var(--text-muted); margin-bottom: 4px; line-height: 1.3;
3304
+ }
3305
+ .wf-input-modal-input {
3306
+ width: 100%; box-sizing: border-box; padding: 6px 10px;
3307
+ border: 1px solid var(--border); border-radius: 6px;
3308
+ background: var(--bg); color: var(--text); font-size: 13px;
3309
+ font-family: inherit; outline: none;
3310
+ transition: border-color 0.15s;
3311
+ }
3312
+ .wf-input-modal-input:focus { border-color: var(--accent, #6c63ff); }
3313
+ .wf-input-modal-input.error { border-color: #e74c3c; }
3314
+ .wf-input-modal-error { font-size: 11px; color: #e74c3c; margin-top: 3px; display: none; }
3315
+ .wf-input-modal-footer {
3316
+ display: flex; align-items: center; justify-content: flex-end; gap: 8px;
3317
+ padding: 12px 20px; border-top: 1px solid var(--border);
3318
+ }
3319
+ .wf-input-modal-cancel {
3320
+ padding: 6px 16px; border: 1px solid var(--border); border-radius: 6px;
3321
+ background: transparent; color: var(--text); font-size: 12px; cursor: pointer;
3322
+ transition: background 0.15s, border-color 0.15s;
3323
+ }
3324
+ .wf-input-modal-cancel:hover { background: var(--bg); border-color: var(--text-muted); }
3325
+ .wf-input-modal-run {
3326
+ padding: 6px 16px; border: none; border-radius: 6px;
3327
+ background: var(--accent, #6c63ff); color: #fff; font-size: 12px;
3328
+ font-weight: 600; cursor: pointer; transition: filter 0.15s;
3329
+ }
3330
+ .wf-input-modal-run:hover { filter: brightness(1.15); }
3331
+ #wf-canvas {
3332
+ width: 100%; height: 100%; display: block; position: relative; z-index: 1;
3333
+ }
3334
+ /* SVG node styles */
3335
+ .wf-node { cursor: pointer; }
3336
+ .wf-node rect {
3337
+ rx: 10; ry: 10; stroke-width: 2;
3338
+ transition: stroke 0.2s, filter 0.2s;
3339
+ }
3340
+ .wf-node:hover rect { filter: brightness(1.1); }
3341
+ .wf-node.selected rect { stroke-width: 3; stroke: var(--accent, #6c63ff); }
3342
+ .wf-node-label {
3343
+ font-size: 12px; font-weight: 600; fill: #fff;
3344
+ pointer-events: none; text-anchor: middle; dominant-baseline: central;
3345
+ }
3346
+ .wf-node-icon {
3347
+ pointer-events: none;
3348
+ color: #fff;
3349
+ }
3350
+ .wf-node-badge {
3351
+ font-size: 10px; fill: rgba(255,255,255,0.7);
3352
+ pointer-events: none; text-anchor: middle; dominant-baseline: central;
3353
+ }
3354
+ .wf-node-condition {
3355
+ font-size: 10px; fill: #FFD54F;
3356
+ pointer-events: none; text-anchor: end;
3357
+ }
3358
+ /* Execution states */
3359
+ .wf-node--pending rect { opacity: 0.5; }
3360
+ .wf-node--running rect {
3361
+ animation: wf-pulse 1.2s ease-in-out infinite;
3362
+ }
3363
+ @keyframes wf-pulse {
3364
+ 0%, 100% { filter: drop-shadow(0 0 4px rgba(108,99,255,0.4)); }
3365
+ 50% { filter: drop-shadow(0 0 12px rgba(108,99,255,0.8)); }
3366
+ }
3367
+ .wf-node--completed rect { opacity: 1; }
3368
+ .wf-node--skipped rect { opacity: 0.3; }
3369
+ .wf-node--error rect { stroke: #e74c3c !important; stroke-width: 3; }
3370
+ .wf-node-status {
3371
+ font-size: 12px; pointer-events: none; fill: #fff;
3372
+ text-anchor: middle; dominant-baseline: central;
3373
+ }
3374
+ .wf-node--error .wf-node-status { fill: #e74c3c; }
3375
+ .wf-node--skipped .wf-node-status { fill: rgba(255,255,255,0.5); }
3376
+ .wf-node-time {
3377
+ font-size: 9px; fill: rgba(255,255,255,0.6);
3378
+ pointer-events: none; text-anchor: middle;
3379
+ }
3380
+ /* Port styles */
3381
+ .wf-port { pointer-events: none; transition: fill 0.2s, stroke 0.2s; }
3382
+ .wf-port-in { fill: var(--bg-card); }
3383
+ .wf-port-out { fill: currentColor; stroke: rgba(255,255,255,0.8); }
3384
+ .wf-node:hover .wf-port-out { filter: brightness(1.3); }
3385
+ .wf-node--completed .wf-port-out { fill: #69F0AE; stroke: #fff; }
3386
+ .wf-node--error .wf-port-in { fill: #e74c3c; }
3387
+ /* Edge styles */
3388
+ .wf-edge {
3389
+ fill: none; stroke: #6b7b8d; stroke-width: 2;
3390
+ opacity: 0.5; transition: opacity 0.3s, stroke 0.3s;
3391
+ stroke-linecap: round;
3392
+ }
3393
+ .wf-edge--backward {
3394
+ stroke-dasharray: 6 4; opacity: 0.35;
3395
+ }
3396
+ .wf-edge--active {
3397
+ stroke: var(--accent, #6c63ff); opacity: 0.85;
3398
+ stroke-dasharray: 8 4;
3399
+ animation: wf-flow 0.6s linear infinite;
3400
+ }
3401
+ @keyframes wf-flow { to { stroke-dashoffset: -12; } }
3402
+ .wf-edge--complete { stroke: var(--accent, #6c63ff); opacity: 0.6; stroke-dasharray: none; }
3403
+ /* Inspector */
3404
+ .wf-inspector {
3405
+ flex-shrink: 0; position: relative;
3406
+ display: flex; flex-direction: row;
3407
+ background: var(--bg);
3408
+ transition: width 0.25s ease;
3409
+ width: 300px;
3410
+ overflow: hidden;
3411
+ align-self: stretch;
3412
+ }
3413
+ .wf-inspector.collapsed {
3414
+ width: 28px;
3415
+ }
3416
+ .wf-inspector-toggle {
3417
+ width: 28px; flex-shrink: 0; align-self: stretch;
3418
+ border: none; border-left: 1px solid var(--border);
3419
+ background: var(--bg); color: var(--text-muted);
3420
+ cursor: pointer; font-size: 14px; padding: 0;
3421
+ display: flex; align-items: center; justify-content: center;
3422
+ transition: color 0.15s, background 0.15s;
3423
+ }
3424
+ .wf-inspector-toggle:hover {
3425
+ color: var(--text); background: var(--bg-card);
3426
+ }
3427
+ .wf-inspector.collapsed .wf-inspector-toggle {
3428
+ border-left: 1px solid var(--border);
3429
+ }
3430
+ .wf-inspector.collapsed .wf-inspector-content {
3431
+ display: none;
3432
+ }
3433
+ .wf-inspector-content {
3434
+ flex: 1; display: flex; flex-direction: column;
3435
+ border-left: 1px solid var(--border);
3436
+ overflow-y: auto; min-width: 0; height: 100%;
3437
+ }
3438
+ .wf-inspector-header {
3439
+ padding: 14px 16px 10px; font-weight: 700; font-size: 13px;
3440
+ color: var(--text); border-bottom: 1px solid var(--border);
3441
+ }
3442
+ .wf-inspector-body {
3443
+ padding: 16px; flex: 1; overflow-y: auto;
3444
+ }
3445
+ .wf-inspector-empty {
3446
+ color: var(--text-muted); font-size: 13px; text-align: center;
3447
+ padding: 40px 16px;
3448
+ }
3449
+ .wf-inspector-section {
3450
+ margin-bottom: 16px;
3451
+ }
3452
+ .wf-inspector-section-title {
3453
+ font-size: 11px; font-weight: 700; text-transform: uppercase;
3454
+ letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 6px;
3455
+ }
3456
+ .wf-inspector-field {
3457
+ font-size: 12px; color: var(--text); margin-bottom: 4px;
3458
+ display: flex; gap: 6px;
3459
+ }
3460
+ .wf-inspector-field-label {
3461
+ font-weight: 600; min-width: 60px; color: var(--text-muted);
3462
+ }
3463
+ .wf-inspector-field-value {
3464
+ word-break: break-all;
3465
+ }
3466
+ .wf-inspector-code {
3467
+ font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px;
3468
+ background: var(--bg-card); border: 1px solid var(--border);
3469
+ border-radius: 6px; padding: 8px; overflow-x: auto;
3470
+ white-space: pre-wrap; color: var(--text); margin-top: 4px;
3471
+ }
3472
+ .wf-tool-badge {
3473
+ display: inline-flex; align-items: center; gap: 4px;
3474
+ padding: 2px 8px; border-radius: 10px; font-size: 11px;
3475
+ font-weight: 600; color: #fff;
3476
+ }
3477
+ .wf-inspector-input {
3478
+ width: 100%; padding: 6px 10px; border-radius: 6px;
3479
+ border: 1px solid var(--border); background: var(--bg-card);
3480
+ color: var(--text); font-size: 12px; margin-top: 4px;
3481
+ }
3482
+ .wf-inspector-input:focus {
3483
+ outline: none; border-color: var(--accent, #6c63ff);
3484
+ }
3485
+ .wf-inspector-btn {
3486
+ width: 100%; padding: 8px 16px; border-radius: 8px;
3487
+ border: none; background: var(--accent, #6c63ff);
3488
+ color: #fff; font-weight: 600; font-size: 13px;
3489
+ cursor: pointer; margin-top: 12px;
3490
+ }
3491
+ .wf-inspector-btn:hover { opacity: 0.9; }
3492
+ .wf-inspector-btn:disabled { opacity: 0.5; cursor: not-allowed; }
3493
+ .wf-inspector-result {
3494
+ margin-top: 8px; padding: 8px; border-radius: 6px;
3495
+ background: var(--bg-card); border: 1px solid var(--border);
3496
+ font-size: 11px; max-height: 200px; overflow-y: auto;
3497
+ }
3498
+ .wf-inspector-result.success { border-color: #69F0AE; }
3499
+ .wf-inspector-result.error { border-color: #e74c3c; }
3500
+ /* Responsive */
3501
+ @media (max-width: 900px) {
3502
+ .wf-library { display: none; }
3503
+ .wf-inspector:not(.collapsed) { width: 240px; }
3504
+ }
3505
+ @media (max-width: 600px) {
3506
+ .wf-inspector { display: none; }
3507
+ }
3508
+ /* Workflow visualizer light mode */
3509
+ [data-theme="light"] .wf-node-label { fill: #001E2B; }
3510
+ [data-theme="light"] .wf-node-icon { color: #001E2B; }
3511
+ [data-theme="light"] .wf-node-badge { fill: rgba(0,30,43,0.55); }
3512
+ [data-theme="light"] .wf-node-time { fill: rgba(0,30,43,0.5); }
3513
+ [data-theme="light"] .wf-node-condition { fill: #944F01; }
3514
+ [data-theme="light"] .wf-node-status { fill: #001E2B; }
3515
+ [data-theme="light"] .wf-node--skipped .wf-node-status { fill: rgba(0,30,43,0.4); }
3516
+ [data-theme="light"] .wf-node--error .wf-node-status { fill: #DB3030; }
3517
+ [data-theme="light"] .wf-node rect { stroke-opacity: 0.7; }
3518
+ [data-theme="light"] .wf-node.selected rect { stroke: var(--accent); }
3519
+ [data-theme="light"] .wf-edge { stroke: #889397; opacity: 0.45; }
3520
+ [data-theme="light"] .wf-edge--backward { opacity: 0.3; }
3521
+ [data-theme="light"] .wf-edge--active { stroke: var(--accent); opacity: 0.7; }
3522
+ [data-theme="light"] .wf-edge--complete { stroke: var(--accent); opacity: 0.5; }
3523
+ [data-theme="light"] .wf-port-in { fill: var(--bg); }
3524
+ [data-theme="light"] .wf-port-out { stroke: rgba(0,30,43,0.3); }
3525
+ [data-theme="light"] .wf-node--completed .wf-port-out { fill: #009E80; stroke: var(--bg); }
3526
+ [data-theme="light"] .wf-canvas-area { background: var(--bg-surface); }
3527
+ [data-theme="light"] .wf-inspector-result.success { border-color: #009E80; }
3528
+ [data-theme="light"] .wf-inspector-result.error { border-color: #DB3030; }
3529
+ [data-theme="light"] .wf-exec-status { box-shadow: 0 2px 8px rgba(0,30,43,0.08); }
3530
+ [data-theme="light"] .wf-run-btn { background: var(--accent); }
3531
+ [data-theme="light"] .wf-output-modal { box-shadow: 0 8px 32px rgba(0,30,43,0.15); }
3532
+ [data-theme="light"] .wf-input-modal { box-shadow: 0 8px 32px rgba(0,30,43,0.15); }
3533
+ [data-theme="light"] .wf-input-modal-backdrop { background: rgba(0,0,0,0.3); }
3534
+ @keyframes wf-pulse-light {
3535
+ 0%, 100% { filter: drop-shadow(0 0 4px rgba(0,158,128,0.3)); }
3536
+ 50% { filter: drop-shadow(0 0 12px rgba(0,158,128,0.6)); }
3537
+ }
3538
+ [data-theme="light"] .wf-node--running rect { animation-name: wf-pulse-light; }
3539
+ /* Empty canvas state */
3540
+ .wf-canvas-empty {
3541
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
3542
+ text-align: center; color: var(--text-muted); pointer-events: none;
3543
+ }
3544
+ .wf-canvas-empty-icon { margin-bottom: 16px; opacity: 0.15; }
3545
+ .wf-canvas-empty-icon img { width: 200px; height: 200px; filter: invert(1); }
3546
+ [data-theme="light"] .wf-canvas-empty-icon img { filter: none; opacity: 0.1; }
3547
+ .wf-canvas-empty-text { font-size: 14px; }
3548
+ /* Persistent watermark behind workflows */
3549
+ .wf-canvas-watermark {
3550
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
3551
+ pointer-events: none; z-index: 0; opacity: 0.04;
3552
+ }
3553
+ .wf-canvas-watermark img { width: 300px; height: 300px; filter: invert(1); }
3554
+ [data-theme="light"] .wf-canvas-watermark img { filter: none; }
2753
3555
  </style>
2754
3556
  </head>
2755
3557
  <body>
2756
3558
 
2757
- <!-- LeafyGreen Icon Sprites (mongodb.design) -->
3559
+ <!-- Lucide Icons (lucide.dev) — stroke-based, 16×16 -->
2758
3560
  <svg xmlns="http://www.w3.org/2000/svg" style="display:none;">
2759
- <symbol id="lg-lightning" viewBox="0 0 16 16">
2760
- <path d="M9.22274 1.99296C9.22274 1.49561 8.56293 1.31233 8.30107 1.73667L4.07384 8.58696C3.87133 8.91513 4.10921 9.33717 4.49748 9.33717H6.77682L6.77682 14.0066C6.77682 14.504 7.43627 14.6879 7.69813 14.2635L11.9262 7.4118C12.1288 7.08363 11.8903 6.66244 11.5021 6.66244H9.22274V1.99296Z" fill="currentColor"/>
3561
+ <!-- Zap (Embed) -->
3562
+ <symbol id="lg-lightning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3563
+ <path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/>
2761
3564
  </symbol>
2762
- <symbol id="lg-arrows" viewBox="0 0 16 16">
2763
- <path d="M5 8.57279V13.4272C5 13.9565 4.39241 14.2015 4.0721 13.8014L2.12898 11.3742C1.95701 11.1594 1.95701 10.8406 2.12898 10.6258L4.0721 8.19856C4.39241 7.79846 5 8.04351 5 8.57279Z" fill="currentColor"/>
2764
- <path d="M5 10H12.5C12.7761 10 13 10.2239 13 10.5V11.5C13 11.7761 12.7761 12 12.5 12H5V10Z" fill="currentColor"/>
2765
- <path d="M11 7.42721V2.57279C11 2.04351 11.6076 1.79846 11.9279 2.19856L13.871 4.62577C14.043 4.84058 14.043 5.15942 13.871 5.37423L11.9279 7.80144C11.6076 8.20154 11 7.95649 11 7.42721Z" fill="currentColor"/>
2766
- <path d="M3 4.5C3 4.22386 3.22386 4 3.5 4H11V6H3.5C3.22386 6 3 5.77614 3 5.5V4.5Z" fill="currentColor"/>
3565
+ <!-- Arrow Left Right (Compare) -->
3566
+ <symbol id="lg-arrows" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3567
+ <path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/>
2767
3568
  </symbol>
2768
- <symbol id="lg-search" viewBox="0 0 16 16">
2769
- <path fill-rule="evenodd" clip-rule="evenodd" d="M2.3234 9.81874C4.07618 11.5715 6.75062 11.8398 8.78588 10.6244L12.93 14.7685C13.4377 15.2762 14.2608 15.2762 14.7685 14.7685C15.2762 14.2608 15.2762 13.4377 14.7685 12.93L10.6244 8.78588C11.8398 6.75062 11.5715 4.07619 9.81873 2.32341C7.74896 0.253628 4.39318 0.253628 2.3234 2.32341C0.253624 4.39319 0.253624 7.74896 2.3234 9.81874ZM7.98026 4.16188C9.03467 5.2163 9.03467 6.92585 7.98026 7.98026C6.92584 9.03468 5.2163 9.03468 4.16188 7.98026C3.10746 6.92585 3.10746 5.2163 4.16188 4.16188C5.2163 3.10747 6.92584 3.10747 7.98026 4.16188Z" fill="currentColor"/>
3569
+ <!-- Search -->
3570
+ <symbol id="lg-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3571
+ <circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
2770
3572
  </symbol>
2771
- <symbol id="lg-gauge" viewBox="0 0 16 16">
2772
- <path d="M1.041 10.2514C0.996713 10.6632 1.33666 11 1.75088 11H4.2449C4.65912 11 4.98569 10.6591 5.08798 10.2577C5.22027 9.73864 5.49013 9.25966 5.87533 8.87446C6.43906 8.31073 7.20364 7.99403 8.00088 7.99403C8.27011 7.99403 8.53562 8.03015 8.79093 8.0997L11.7818 5.10887C10.6623 4.39046 9.35172 4 8.00088 4C6.14436 4 4.36388 4.7375 3.05113 6.05025C1.91604 7.18534 1.21104 8.67012 1.041 10.2514Z" fill="currentColor"/>
2773
- <path d="M13.2967 6.42237L10.455 9.26409C10.6678 9.56493 10.8231 9.90191 10.9138 10.2577C11.0161 10.6591 11.3426 11 11.7568 11L14.2509 11C14.6651 11 15.005 10.6632 14.9608 10.2514C14.8087 8.83759 14.229 7.50093 13.2967 6.42237Z" fill="currentColor"/>
3573
+ <!-- Gauge (Benchmark) -->
3574
+ <symbol id="lg-gauge" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3575
+ <path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>
2774
3576
  </symbol>
2775
- <symbol id="lg-bulb" viewBox="0 0 16 16">
2776
- <path d="M12.3311 8.5C12.7565 7.76457 13 6.91072 13 6C13 3.23858 10.7614 1 8 1C5.23858 1 3 3.23858 3 6C3 6.94628 3.26287 7.83117 3.71958 8.58561L5.40749 11.501C5.58628 11.8099 5.91607 12 6.27291 12H6.5V6C6.5 5.17157 7.17157 4.5 8 4.5C8.82843 4.5 9.5 5.17157 9.5 6V12H9.72368C10.0793 12 10.4082 11.8111 10.5874 11.5039L12.34 8.5H12.3311Z" fill="currentColor"/>
2777
- <path d="M7.5 6V12H8.5V6C8.5 5.72386 8.27614 5.5 8 5.5C7.72386 5.5 7.5 5.72386 7.5 6Z" fill="currentColor"/>
2778
- <path d="M10 14V13H6V14C6 14.5523 6.44772 15 7 15H9C9.55228 15 10 14.5523 10 14Z" fill="currentColor"/>
3577
+ <!-- Lightbulb (Explore) -->
3578
+ <symbol id="lg-bulb" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3579
+ <path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>
2779
3580
  </symbol>
2780
- <symbol id="lg-info" viewBox="0 0 16 16">
2781
- <path fill-rule="evenodd" clip-rule="evenodd" d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15ZM9 4C9 4.55228 8.55228 5 8 5C7.44772 5 7 4.55228 7 4C7 3.44772 7.44772 3 8 3C8.55228 3 9 3.44772 9 4ZM8 6C8.55228 6 9 6.44772 9 7V11H9.5C9.77614 11 10 11.2239 10 11.5C10 11.7761 9.77614 12 9.5 12H6.5C6.22386 12 6 11.7761 6 11.5C6 11.2239 6.22386 11 6.5 11H7V7H6.5C6.22386 7 6 6.77614 6 6.5C6 6.22386 6.22386 6 6.5 6H8Z" fill="currentColor"/>
3581
+ <!-- Info (About) -->
3582
+ <symbol id="lg-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3583
+ <circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>
2782
3584
  </symbol>
2783
- <symbol id="lg-image" viewBox="0 0 16 16">
2784
- <path fill-rule="evenodd" clip-rule="evenodd" d="M2 3C2 2.44772 2.44772 2 3 2H13C13.5523 2 14 2.44772 14 3V13C14 13.5523 13.5523 14 13 14H3C2.44772 14 2 13.5523 2 13V3ZM4 4V9.586L5.793 7.793C6.183 7.403 6.817 7.403 7.207 7.793L8.5 9.086L10.293 7.293C10.683 6.903 11.317 6.903 11.707 7.293L12 7.586V4H4ZM12 10.414L10.5 8.914L8.207 11.207C7.817 11.597 7.183 11.597 6.793 11.207L5.5 9.914L4 11.414V12H12V10.414ZM10 5.5C10 6.328 10.672 7 11.5 7C11.776 7 12 6.776 12 6.5V5C12 4.724 11.776 4.5 11.5 4.5H10.5C10.224 4.5 10 4.724 10 5V5.5Z" fill="currentColor"/>
3585
+ <!-- Image (Multimodal) -->
3586
+ <symbol id="lg-image" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3587
+ <rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
2785
3588
  </symbol>
2786
- <symbol id="lg-config" viewBox="0 0 16 16">
2787
- <path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 1.5A.5.5 0 0 1 7 1h2a.5.5 0 0 1 .5.5v1.05a5 5 0 0 1 1.37.57l.74-.74a.5.5 0 0 1 .7 0l1.42 1.42a.5.5 0 0 1 0 .7l-.74.74c.25.43.44.89.57 1.37H14.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-1.05a5 5 0 0 1-.57 1.37l.74.74a.5.5 0 0 1 0 .7l-1.42 1.42a.5.5 0 0 1-.7 0l-.74-.74a5 5 0 0 1-1.37.57V14.5a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5v-1.05a5 5 0 0 1-1.37-.57l-.74.74a.5.5 0 0 1-.7 0L2.27 12.2a.5.5 0 0 1 0-.7l.74-.74a5 5 0 0 1-.57-1.37H1.5A.5.5 0 0 1 1 9V7a.5.5 0 0 1 .5-.5h1.05c.13-.48.32-.94.57-1.37l-.74-.74a.5.5 0 0 1 0-.7L3.8 2.27a.5.5 0 0 1 .7 0l.74.74A5 5 0 0 1 6.6 2.44V1.5zM8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z" fill="currentColor"/>
3589
+ <!-- Settings (Config) -->
3590
+ <symbol id="lg-config" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3591
+ <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>
2788
3592
  </symbol>
2789
- <symbol id="lg-code" viewBox="0 0 16 16">
2790
- <path fill="currentColor" d="M5.854 4.146a.5.5 0 0 1 0 .708L2.707 8l3.147 3.146a.5.5 0 0 1-.708.708l-3.5-3.5a.5.5 0 0 1 0-.708l3.5-3.5a.5.5 0 0 1 .708 0zm4.292 0a.5.5 0 0 0 0 .708L13.293 8l-3.147 3.146a.5.5 0 0 0 .708.708l3.5-3.5a.5.5 0 0 0 0-.708l-3.5-3.5a.5.5 0 0 0-.708 0z"/>
3593
+ <!-- Code (Generate) -->
3594
+ <symbol id="lg-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3595
+ <polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
2791
3596
  </symbol>
2792
- <symbol id="lg-palette" viewBox="0 0 16 16">
2793
- <path fill-rule="evenodd" clip-rule="evenodd" d="M8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15C8.55228 15 9 14.5523 9 14V12.5C9 11.6716 9.67157 11 10.5 11H12C13.6569 11 15 9.65685 15 8C15 4.13401 11.866 1 8 1ZM4.5 9C5.32843 9 6 8.32843 6 7.5C6 6.67157 5.32843 6 4.5 6C3.67157 6 3 6.67157 3 7.5C3 8.32843 3.67157 9 4.5 9ZM7 5.5C7 6.32843 6.32843 7 5.5 7C4.67157 7 4 6.32843 4 5.5C4 4.67157 4.67157 4 5.5 4C6.32843 4 7 4.67157 7 5.5ZM9.5 6C10.3284 6 11 5.32843 11 4.5C11 3.67157 10.3284 3 9.5 3C8.67157 3 8 3.67157 8 4.5C8 5.32843 8.67157 6 9.5 6ZM13 7.5C13 8.32843 12.3284 9 11.5 9C10.6716 9 10 8.32843 10 7.5C10 6.67157 10.6716 6 11.5 6C12.3284 6 13 6.67157 13 7.5Z" fill="currentColor"/>
3597
+ <!-- Palette (Theme) -->
3598
+ <symbol id="lg-palette" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3599
+ <circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>
2794
3600
  </symbol>
2795
- <symbol id="lg-cube" viewBox="0 0 16 16">
2796
- <path fill-rule="evenodd" clip-rule="evenodd" d="M8.35 1.18a.75.75 0 0 0-.7 0l-6 3.25A.75.75 0 0 0 1.25 5v6a.75.75 0 0 0 .4.66l6 3.25a.75.75 0 0 0 .7 0l6-3.25a.75.75 0 0 0 .4-.66V5a.75.75 0 0 0-.4-.66l-6-3.16zM8 3.2 3.47 5.65 8 7.82l4.53-2.17L8 3.2zM2.75 6.8v3.82L7.25 12.8V8.97L2.75 6.8zm5.5 6 4.5-2.18V6.8L8.25 8.97V12.8z" fill="currentColor"/>
3601
+ <!-- Box (Cube) -->
3602
+ <symbol id="lg-cube" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3603
+ <path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>
2797
3604
  </symbol>
2798
- <symbol id="lg-chat" viewBox="0 0 16 16">
2799
- <path d="M2 2h12v9H5l-3 3V2z" fill="none" stroke="currentColor" stroke-width="1.5"/>
3605
+ <!-- Message Square (Chat) -->
3606
+ <symbol id="lg-chat" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3607
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
2800
3608
  </symbol>
2801
- <symbol id="lg-shield" viewBox="0 0 16 16">
2802
- <path fill-rule="evenodd" clip-rule="evenodd" d="M8.35 1.18a.75.75 0 0 0-.7 0l-5 2.7A.75.75 0 0 0 2.25 4.5V8c0 2.9 2.1 5.5 5.5 6.95a.75.75 0 0 0 .5 0C11.65 13.5 13.75 10.9 13.75 8V4.5a.75.75 0 0 0-.4-.62l-5-2.7zM8 3.2 3.75 5.5V8c0 2.2 1.6 4.2 4.25 5.45C10.65 12.2 12.25 10.2 12.25 8V5.5L8 3.2z" fill="currentColor"/>
3609
+ <!-- Shield -->
3610
+ <symbol id="lg-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3611
+ <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 .5-.87l7-4a1 1 0 0 1 1 0l7 4A1 1 0 0 1 20 6z"/>
3612
+ </symbol>
3613
+ <!-- Activity (Pulse) -->
3614
+ <symbol id="lg-pulse" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
3615
+ <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"/>
2803
3616
  </symbol>
2804
3617
  </svg>
2805
3618
 
@@ -2810,7 +3623,7 @@ select:focus { outline: none; border-color: var(--accent); }
2810
3623
  <div class="sidebar-drag-region">
2811
3624
  <img class="sidebar-logo" id="sidebarLogo" src="/icons/dark/64.png" alt="Vai">
2812
3625
  <span class="sidebar-title">Vai</span>
2813
- <button class="sidebar-settings-btn" data-tab="settings" title="Settings"><svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-config"/></svg></button>
3626
+ <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>
2814
3627
  </div>
2815
3628
  <nav class="sidebar-nav">
2816
3629
  <div class="sidebar-nav-group" role="tablist" aria-label="Tools">
@@ -2820,7 +3633,8 @@ select:focus { outline: none; border-color: var(--accent); }
2820
3633
  <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>
2821
3634
  <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>
2822
3635
  <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>
2823
- <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 width="16" height="16" viewBox="0 0 16 16"><path d="M2 2h12v9H5l-3 3V2z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg></span><span>Chat</span></button>
3636
+ <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>
3637
+ <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>
2824
3638
  </div>
2825
3639
  <div class="sidebar-nav-divider"></div>
2826
3640
  <div class="sidebar-nav-group" role="tablist" aria-label="Learn">
@@ -2840,10 +3654,16 @@ select:focus { outline: none; border-color: var(--accent); }
2840
3654
  </div>
2841
3655
  <div style="display:flex;align-items:center;justify-content:space-between;">
2842
3656
  <div id="appVersionLabel" style="font-size:10px;color:var(--text-muted);"></div>
2843
- <button class="sidebar-bug-link" id="bugButton" title="Report a Bug">
2844
- <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="9" r="4.5"/><path d="M5.5 5.5L3 3M10.5 5.5L13 3M3 9H1M15 9h-2M5.5 12.5L4 15M10.5 12.5L12 15"/></svg>
2845
- <span class="sidebar-bug-label">Bug</span>
2846
- </button>
3657
+ <div style="display:flex;align-items:center;gap:6px;">
3658
+ <a class="sidebar-docs-link" href="https://docs.vaicli.com" target="_blank" rel="noopener" title="Documentation (F1)">
3659
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 1h7l3 3v11H3z"/><path d="M10 1v3h3"/><path d="M6 8h4M6 10.5h4"/></svg>
3660
+ <span>Docs</span>
3661
+ </a>
3662
+ <button class="sidebar-bug-link" id="bugButton" title="Report a Bug">
3663
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="9" r="4.5"/><path d="M5.5 5.5L3 3M10.5 5.5L13 3M3 9H1M15 9h-2M5.5 12.5L4 15M10.5 12.5L12 15"/></svg>
3664
+ <span class="sidebar-bug-label">Bug</span>
3665
+ </button>
3666
+ </div>
2847
3667
  </div>
2848
3668
  </div>
2849
3669
  </aside>
@@ -2873,6 +3693,7 @@ select:focus { outline: none; border-color: var(--accent); }
2873
3693
  <h2 class="page-header-title">Embed</h2>
2874
3694
  <p class="page-header-subtitle">Generate vector embeddings for text</p>
2875
3695
  <p class="page-header-hint">Paste or type text below, choose a model, and hit Embed to see the raw vectors and token usage.</p>
3696
+ <a class="page-header-docs" href="https://docs.vaicli.com/docs/commands/embeddings/embed" target="_blank" rel="noopener" title="Embed documentation"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 1h7l3 3v11H3z"/><path d="M10 1v3h3"/></svg>Docs</a>
2876
3697
  </div>
2877
3698
  <div class="card">
2878
3699
  <div class="card-title">Input Text</div>
@@ -2912,7 +3733,7 @@ select:focus { outline: none; border-color: var(--accent); }
2912
3733
  <option value="ubinary">ubinary (32× smaller)</option>
2913
3734
  </select>
2914
3735
  </div>
2915
- <button class="btn" id="embedBtn" onclick="doEmbed()">⚡ Embed</button>
3736
+ <button class="btn" id="embedBtn" onclick="doEmbed()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>Embed</button>
2916
3737
  </div>
2917
3738
 
2918
3739
  <div class="error-msg" id="embedError"></div>
@@ -2923,7 +3744,7 @@ select:focus { outline: none; border-color: var(--accent); }
2923
3744
  <div id="embedStats"></div>
2924
3745
  <div class="vector-preview" id="embedVector"></div>
2925
3746
  <div style="margin-top:8px;">
2926
- <button class="btn btn-secondary btn-small" onclick="copyVector()">📋 Copy Full Vector</button>
3747
+ <button class="btn btn-secondary btn-small" onclick="copyVector()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M9 9V6a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-3M3 15a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3z"/></svg>Copy Full Vector</button>
2927
3748
  </div>
2928
3749
  <div class="card-title" style="margin-top:16px;">Vector Heatmap</div>
2929
3750
  <div class="heatmap" id="embedHeatmap"></div>
@@ -2936,7 +3757,8 @@ select:focus { outline: none; border-color: var(--accent); }
2936
3757
  <div class="page-header">
2937
3758
  <h2 class="page-header-title">Compare</h2>
2938
3759
  <p class="page-header-subtitle">Visualize similarity between text pairs</p>
2939
- <p class="page-header-hint">Enter two texts and compare their embeddings see cosine similarity, a heatmap of vector dimensions, and a visual diff.</p>
3760
+ <p class="page-header-hint">Enter two texts and compare their embeddings: see cosine similarity, a heatmap of vector dimensions, and a visual diff.</p>
3761
+ <a class="page-header-docs" href="https://docs.vaicli.com/docs/commands/embeddings/similarity" target="_blank" rel="noopener" title="Similarity documentation"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 1h7l3 3v11H3z"/><path d="M10 1v3h3"/></svg>Docs</a>
2940
3762
  </div>
2941
3763
  <div class="compare-grid">
2942
3764
  <div class="card">
@@ -2964,7 +3786,7 @@ select:focus { outline: none; border-color: var(--accent); }
2964
3786
  <option value="2048">2048</option>
2965
3787
  </select>
2966
3788
  </div>
2967
- <button class="btn" id="compareBtn" onclick="doCompare()">⚖️ Compare</button>
3789
+ <button class="btn" id="compareBtn" onclick="doCompare()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M8 3 4 7l4 4M4 7h16M16 21l4-4-4-4M20 17H4"/></svg>Compare</button>
2968
3790
  </div>
2969
3791
 
2970
3792
  <div class="error-msg" id="compareError"></div>
@@ -2990,7 +3812,8 @@ select:focus { outline: none; border-color: var(--accent); }
2990
3812
  <div class="page-header">
2991
3813
  <h2 class="page-header-title">Rerank</h2>
2992
3814
  <p class="page-header-subtitle">Re-order documents by relevance to a query</p>
2993
- <p class="page-header-hint">Enter a search query and a set of documents the reranker scores and sorts them by semantic relevance.</p>
3815
+ <p class="page-header-hint">Enter a search query and a set of documents: the reranker scores and sorts them by semantic relevance.</p>
3816
+ <a class="page-header-docs" href="https://docs.vaicli.com/docs/commands/embeddings/rerank" target="_blank" rel="noopener" title="Rerank documentation"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 1h7l3 3v11H3z"/><path d="M10 1v3h3"/></svg>Docs</a>
2994
3817
  </div>
2995
3818
  <div class="card">
2996
3819
  <div class="card-title">Query</div>
@@ -3025,8 +3848,8 @@ Semantic search understands meaning beyond keyword matching</textarea>
3025
3848
  <option value="10">10</option>
3026
3849
  </select>
3027
3850
  </div>
3028
- <button class="btn" id="searchBtn" onclick="doSearch(false)">🔍 Search</button>
3029
- <button class="btn btn-secondary" id="searchRerankBtn" onclick="doSearch(true)">🔍+ Rerank</button>
3851
+ <button class="btn" id="searchBtn" onclick="doSearch(false)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"/></svg>Search</button>
3852
+ <button class="btn btn-secondary" id="searchRerankBtn" onclick="doSearch(true)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M3 6h18M7 12h10M10 18h4"/></svg>Rerank</button>
3030
3853
  </div>
3031
3854
 
3032
3855
  <div class="error-msg" id="searchError"></div>
@@ -3041,7 +3864,8 @@ Semantic search understands meaning beyond keyword matching</textarea>
3041
3864
  <div class="page-header">
3042
3865
  <h2 class="page-header-title">Multimodal</h2>
3043
3866
  <p class="page-header-subtitle">Compare images and text in the same vector space</p>
3044
- <p class="page-header-hint">Voyage AI's multimodal models embed images and text into a unified vector space so you can compare them directly with cosine similarity.</p>
3867
+ <p class="page-header-hint">Voyage AI's multimodal models embed images and text into a unified vector space, so you can compare them directly with cosine similarity.</p>
3868
+ <a class="page-header-docs" href="https://docs.vaicli.com/docs/commands/embeddings/embed" target="_blank" rel="noopener" title="Multimodal embedding documentation"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 1h7l3 3v11H3z"/><path d="M10 1v3h3"/></svg>Docs</a>
3045
3869
  </div>
3046
3870
 
3047
3871
  <!-- Section A: Image ↔ Text Similarity -->
@@ -3051,7 +3875,7 @@ Semantic search understands meaning beyond keyword matching</textarea>
3051
3875
  <div class="mm-drop-zone" id="mmDropZone">
3052
3876
  <div class="mm-drop-icon">🖼️</div>
3053
3877
  <div class="mm-drop-text">Drop an image here or click to browse</div>
3054
- <div class="mm-drop-hint">PNG, JPEG, WebP, GIF max 20 MB · Paste from clipboard (⌘V)</div>
3878
+ <div class="mm-drop-hint">PNG, JPEG, WebP, GIF, max 20 MB. Paste from clipboard (⌘V)</div>
3055
3879
  </div>
3056
3880
  <input type="file" id="mmFileInput" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
3057
3881
  <div class="mm-preview" id="mmPreview">
@@ -3084,7 +3908,7 @@ Semantic search understands meaning beyond keyword matching</textarea>
3084
3908
  <option value="2048">2048</option>
3085
3909
  </select>
3086
3910
  </div>
3087
- <button class="btn" id="mmCompareBtn" onclick="doMultimodalCompare()">🔮 Compare</button>
3911
+ <button class="btn" id="mmCompareBtn" onclick="doMultimodalCompare()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M8 3 4 7l4 4M4 7h16M16 21l4-4-4-4M20 17H4"/></svg>Compare</button>
3088
3912
  </div>
3089
3913
 
3090
3914
  <div class="error-msg" id="mmError"></div>
@@ -3122,20 +3946,20 @@ Semantic search understands meaning beyond keyword matching</textarea>
3122
3946
  <div class="card" style="margin-top:12px;">
3123
3947
  <div class="card-title">Search Query</div>
3124
3948
  <div class="mm-search-mode" id="mmSearchMode">
3125
- <button class="active" data-mode="text" onclick="setMmSearchMode('text')">📝 Text Query</button>
3126
- <button data-mode="image" onclick="setMmSearchMode('image')">🖼️ Image Query</button>
3949
+ <button class="active" data-mode="text" onclick="setMmSearchMode('text')"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><path d="M17 6.1H3M21 12.1H3M15.1 18H3"/></svg>Text Query</button>
3950
+ <button data-mode="image" onclick="setMmSearchMode('image')"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>Image Query</button>
3127
3951
  </div>
3128
3952
  <div id="mmSearchTextWrap">
3129
3953
  <div class="mm-search-row">
3130
3954
  <input type="text" id="mmSearchQuery" placeholder="Enter a search query...">
3131
- <button class="btn" id="mmSearchBtn" onclick="doMultimodalSearch()">🔮 Search Corpus</button>
3955
+ <button class="btn" id="mmSearchBtn" onclick="doMultimodalSearch()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"/></svg>Search Corpus</button>
3132
3956
  </div>
3133
3957
  </div>
3134
3958
  <div id="mmSearchImageWrap" style="display:none;">
3135
3959
  <p style="font-size:13px;color:var(--text-dim);margin-bottom:8px;">Click an image in the corpus above to use it as the search query, then:</p>
3136
3960
  <div style="display:flex;gap:8px;align-items:center;">
3137
3961
  <span id="mmSearchImageLabel" style="font-size:13px;color:var(--text-muted);">No image selected</span>
3138
- <button class="btn" id="mmSearchImgBtn" onclick="doMultimodalSearch()">🔮 Search Corpus</button>
3962
+ <button class="btn" id="mmSearchImgBtn" onclick="doMultimodalSearch()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"/></svg>Search Corpus</button>
3139
3963
  </div>
3140
3964
  </div>
3141
3965
  </div>
@@ -3157,16 +3981,17 @@ Semantic search understands meaning beyond keyword matching</textarea>
3157
3981
  <h2 class="page-header-title">Benchmark</h2>
3158
3982
  <p class="page-header-subtitle">Compare model speed, cost, and quality</p>
3159
3983
  <p class="page-header-hint">Run latency tests, compare ranking accuracy, analyze quantization trade-offs, and estimate costs across models.</p>
3984
+ <a class="page-header-docs" href="https://docs.vaicli.com/docs/commands/evaluation/benchmark" target="_blank" rel="noopener" title="Benchmark documentation"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 1h7l3 3v11H3z"/><path d="M10 1v3h3"/></svg>Docs</a>
3160
3985
  </div>
3161
3986
 
3162
3987
  <!-- Sub-panel switcher -->
3163
3988
  <div class="bench-panels">
3164
- <button class="bench-panel-btn active" data-bench="latency">⚡ Latency</button>
3165
- <button class="bench-panel-btn" data-bench="ranking">🏆 Ranking</button>
3166
- <button class="bench-panel-btn" data-bench="competitors">⚔️ vs Competitors</button>
3167
- <button class="bench-panel-btn" data-bench="quantization">⚗️ Quantization</button>
3168
- <button class="bench-panel-btn" data-bench="cost">💰 Cost</button>
3169
- <button class="bench-panel-btn" data-bench="history">📊 History</button>
3989
+ <button class="bench-panel-btn active" data-bench="latency"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>Latency</button>
3990
+ <button class="bench-panel-btn" data-bench="ranking"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><path d="M12 20V10M18 20V4M6 20v-4"/></svg>Ranking</button>
3991
+ <button class="bench-panel-btn" data-bench="competitors"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><path d="M16 3h5v5M8 3H3v5M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3M21 3l-7.828 7.828A4 4 0 0 0 12 13.7V22"/></svg>vs Competitors</button>
3992
+ <button class="bench-panel-btn" data-bench="quantization"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><path d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2M8.5 2h7M7 16h10"/></svg>Quantization</button>
3993
+ <button class="bench-panel-btn" data-bench="cost"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>Cost</button>
3994
+ <button class="bench-panel-btn" data-bench="history"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><path d="M12 20V10M18 20V4M6 20v-4"/></svg>History</button>
3170
3995
  </div>
3171
3996
 
3172
3997
  <!-- ── Latency Panel ── -->
@@ -3188,7 +4013,7 @@ Semantic search understands meaning beyond keyword matching</textarea>
3188
4013
  <option value="10">10</option>
3189
4014
  </select>
3190
4015
  </div>
3191
- <button class="btn" id="benchLatencyBtn" onclick="doBenchLatency()">⚡ Run Benchmark</button>
4016
+ <button class="btn" id="benchLatencyBtn" onclick="doBenchLatency()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>Run Benchmark</button>
3192
4017
  </div>
3193
4018
  </div>
3194
4019
 
@@ -3242,7 +4067,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
3242
4067
  <option value="8">8</option>
3243
4068
  </select>
3244
4069
  </div>
3245
- <button class="btn" id="benchRankBtn" onclick="doBenchRanking()">🏆 Compare Rankings</button>
4070
+ <button class="btn" id="benchRankBtn" onclick="doBenchRanking()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M12 20V10M18 20V4M6 20v-4"/></svg>Compare Rankings</button>
3246
4071
  </div>
3247
4072
  </div>
3248
4073
 
@@ -3318,7 +4143,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
3318
4143
 
3319
4144
  <!-- Cost Comparison -->
3320
4145
  <div class="card" style="margin-top:16px;">
3321
- <div class="card-title">💰 Cost per Million Tokens</div>
4146
+ <div class="card-title"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px;"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>Cost per Million Tokens</div>
3322
4147
  <p style="color:var(--text-dim);font-size:13px;margin-bottom:16px;">
3323
4148
  Voyage AI offers significant cost savings, especially with asymmetric retrieval strategies.
3324
4149
  </p>
@@ -3479,7 +4304,7 @@ Approximate nearest neighbor algorithms like HNSW enable fast similarity search
3479
4304
  Reranking models rescore initial search results to improve relevance ordering.</textarea>
3480
4305
  </div>
3481
4306
  <div style="margin-top:12px;">
3482
- <button class="btn" id="quantBtn" onclick="doBenchQuantization()">⚗️ Run Quantization Benchmark</button>
4307
+ <button class="btn" id="quantBtn" onclick="doBenchQuantization()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2M8.5 2h7M7 16h10"/></svg>Run Quantization Benchmark</button>
3483
4308
  </div>
3484
4309
  </div>
3485
4310
 
@@ -3507,7 +4332,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
3507
4332
  <!-- ── Cost Panel ── -->
3508
4333
  <div class="bench-view" id="bench-cost">
3509
4334
  <div class="card">
3510
- <div class="card-title">💰 RAG Cost Calculator <button class="cost-help-btn" id="costHelpBtn" title="How the math works">?</button></div>
4335
+ <div class="card-title"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>RAG Cost Calculator <button class="cost-help-btn" id="costHelpBtn" title="How the math works">?</button></div>
3511
4336
 
3512
4337
  <!-- Mode toggle -->
3513
4338
  <div style="margin-bottom: 20px;">
@@ -3628,7 +4453,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
3628
4453
  <div class="history-empty">No benchmarks recorded yet. Run a latency benchmark to start tracking.</div>
3629
4454
  </div>
3630
4455
  <div style="margin-top:12px;text-align:right;">
3631
- <button class="btn btn-secondary btn-small" onclick="clearHistory()">🗑 Clear History</button>
4456
+ <button class="btn btn-secondary btn-small" onclick="clearHistory()"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>Clear History</button>
3632
4457
  </div>
3633
4458
  </div>
3634
4459
  </div>
@@ -3645,7 +4470,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
3645
4470
  <span id="chatStatusDb">No database</span>
3646
4471
  </div>
3647
4472
  <button class="chat-config-toggle" id="chatOpenSettings" onclick="openChatSettings()" title="Configure in Settings">
3648
- <svg width="14" height="14" viewBox="0 0 16 16"><use href="#lg-config"/></svg>
4473
+ <svg width="14" height="14" viewBox="0 0 24 24"><use href="#lg-config"/></svg>
3649
4474
  Configure
3650
4475
  </button>
3651
4476
  </div>
@@ -3664,6 +4489,103 @@ Reranking models rescore initial search results to improve relevance ordering.</
3664
4489
  </div>
3665
4490
  </div>
3666
4491
 
4492
+ <!-- ========== WORKFLOWS TAB ========== -->
4493
+ <div class="tab-panel" id="tab-workflows" role="tabpanel" aria-labelledby="tab-btn-workflows" tabindex="0">
4494
+ <div class="wf-container">
4495
+ <div class="wf-library">
4496
+ <div class="wf-library-header">
4497
+ <div class="wf-library-tabs">
4498
+ <button class="wf-lib-tab active" data-lib-tab="library" onclick="wfSwitchLibTab('library')">Library</button>
4499
+ <button class="wf-lib-tab" data-lib-tab="palette" onclick="wfSwitchLibTab('palette')">Palette</button>
4500
+ </div>
4501
+ </div>
4502
+ <div class="wf-library-list" id="wfLibraryList">
4503
+ <div style="padding: 16px; color: var(--text-muted); font-size: 12px;">Loading...</div>
4504
+ </div>
4505
+ <div class="wf-palette-list" id="wfPaletteList" style="display:none; flex:1; overflow-y:auto; padding:8px;"></div>
4506
+ <div class="wf-library-footer">
4507
+ <button class="wf-load-file-btn" onclick="wfLoadFromFile()" title="Load workflow JSON from file">
4508
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 14h10M8 2v9M5 8l3 3 3-3"/></svg>
4509
+ Load File
4510
+ </button>
4511
+ <input type="file" id="wfFileInput" accept=".json" style="display:none;" onchange="wfHandleFileLoad(event)">
4512
+ </div>
4513
+ </div>
4514
+ <div class="wf-canvas-area">
4515
+ <div class="wf-canvas-toolbar" id="wfToolbar">
4516
+ <button class="wf-new-btn" onclick="wfNewWorkflow()" title="Create new workflow">+ New</button>
4517
+ <button class="wf-edit-btn" id="wfEditBtn" onclick="wfEditWorkflow()" disabled title="Edit current workflow">&#9998; Edit</button>
4518
+ <span class="wf-toolbar-sep"></span>
4519
+ <button onclick="wfZoom(1)" title="Zoom in">+</button>
4520
+ <button onclick="wfZoom(-1)" title="Zoom out">&minus;</button>
4521
+ <button onclick="wfFitToView()" title="Fit to view">&#8862;</button>
4522
+ <button onclick="wfResetExecution()" title="Reset">&#8635;</button>
4523
+ <span class="wf-toolbar-sep"></span>
4524
+ <button class="wf-plan-btn" onclick="wfDryRun()" id="wfDryRunBtn" disabled title="Dry run: show execution plan">&#9881; Plan</button>
4525
+ <button class="wf-run-btn" id="wfRunBtn" onclick="wfExecute()" disabled title="Run workflow">&#9654; Run</button>
4526
+ <button class="wf-stop-btn" id="wfStopBtn" onclick="wfStopExecution()" style="display:none;" title="Stop workflow">&#9632; Stop</button>
4527
+ <span class="wf-toolbar-sep"></span>
4528
+ <button onclick="wfExportJson()" id="wfExportBtn" disabled title="Export workflow JSON">
4529
+ <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>
4530
+ </button>
4531
+ </div>
4532
+ <!-- Execution status bar -->
4533
+ <div class="wf-exec-status" id="wfExecStatus" style="display:none;">
4534
+ <span class="wf-exec-status-dot"></span>
4535
+ <span class="wf-exec-status-text" id="wfExecStatusText">Running...</span>
4536
+ <span class="wf-exec-status-time" id="wfExecStatusTime"></span>
4537
+ </div>
4538
+ <div class="wf-canvas-empty" id="wfCanvasEmpty">
4539
+ <div class="wf-canvas-empty-icon"><img src="/icons/watermark.png" alt="Vai"></div>
4540
+ <div class="wf-canvas-empty-text">Select a workflow from the library</div>
4541
+ </div>
4542
+ <div class="wf-canvas-watermark" id="wfCanvasWatermark"><img src="/icons/watermark.png" alt=""></div>
4543
+ <svg id="wf-canvas" xmlns="http://www.w3.org/2000/svg" ondragover="event.preventDefault()" ondrop="wfCanvasDrop(event)"></svg>
4544
+ </div>
4545
+ <div class="wf-inspector collapsed" id="wfInspector">
4546
+ <button class="wf-inspector-toggle" id="wfInspectorToggle" onclick="wfToggleInspector()" title="Toggle inspector">&lsaquo;</button>
4547
+ <div class="wf-inspector-content">
4548
+ <div class="wf-inspector-header" id="wfInspectorHeader">Inspector</div>
4549
+ <div class="wf-inspector-body" id="wfInspectorBody">
4550
+ <div class="wf-inspector-empty">Click a node to inspect</div>
4551
+ </div>
4552
+ </div>
4553
+ </div>
4554
+ </div>
4555
+ </div>
4556
+
4557
+ <!-- ── Workflow Output Modal ── -->
4558
+ <div class="wf-output-modal-backdrop" id="wfOutputModalBackdrop" style="display:none;" onclick="wfCloseOutputModal()">
4559
+ <div class="wf-output-modal" onclick="event.stopPropagation()">
4560
+ <div class="wf-output-modal-header">
4561
+ <span class="wf-output-modal-title" id="wfOutputModalTitle">Output</span>
4562
+ <div class="wf-output-modal-actions">
4563
+ <button class="wf-output-modal-btn" onclick="wfCopyOutput()" title="Copy to clipboard" id="wfOutputCopyBtn">
4564
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M5 11H3.5A1.5 1.5 0 0 1 2 9.5V3.5A1.5 1.5 0 0 1 3.5 2h6A1.5 1.5 0 0 1 11 3.5V5"/></svg>
4565
+ <span id="wfOutputCopyLabel">Copy</span>
4566
+ </button>
4567
+ <button class="wf-output-modal-btn close" onclick="wfCloseOutputModal()" title="Close">&times;</button>
4568
+ </div>
4569
+ </div>
4570
+ <pre class="wf-output-modal-body" id="wfOutputModalBody"></pre>
4571
+ </div>
4572
+ </div>
4573
+
4574
+ <!-- ── Workflow Input Modal (pre-execution) ── -->
4575
+ <div class="wf-input-modal-backdrop" id="wfInputModalBackdrop" style="display:none;" onclick="wfCloseInputModal()">
4576
+ <div class="wf-input-modal" onclick="event.stopPropagation()">
4577
+ <div class="wf-input-modal-header">
4578
+ <span class="wf-input-modal-title" id="wfInputModalTitle">Workflow Inputs</span>
4579
+ <button class="wf-output-modal-btn close" onclick="wfCloseInputModal()" title="Close">&times;</button>
4580
+ </div>
4581
+ <div class="wf-input-modal-body" id="wfInputModalBody"></div>
4582
+ <div class="wf-input-modal-footer">
4583
+ <button class="wf-input-modal-cancel" onclick="wfCloseInputModal()">Cancel</button>
4584
+ <button class="wf-input-modal-run" onclick="wfInputModalSubmit()">Run Workflow</button>
4585
+ </div>
4586
+ </div>
4587
+ </div>
4588
+
3667
4589
  <!-- ========== ABOUT TAB ========== -->
3668
4590
  <div class="tab-panel" id="tab-about" role="tabpanel" aria-labelledby="tab-btn-about" tabindex="0">
3669
4591
  <div class="about-container">
@@ -3674,9 +4596,9 @@ Reranking models rescore initial search results to improve relevance ordering.</
3674
4596
  <div class="about-name">Michael Lynn</div>
3675
4597
  <div class="about-role">Principal Staff Developer Advocate · MongoDB</div>
3676
4598
  <div class="about-links">
3677
- <a href="https://github.com/mrlynn" target="_blank" rel="noopener">🔗 GitHub</a>
3678
- <a href="https://mlynn.org" target="_blank" rel="noopener">🌐 mlynn.org</a>
3679
- <a href="https://www.npmjs.com/package/voyageai-cli" target="_blank" rel="noopener">📦 npm</a>
4599
+ <a href="https://github.com/mrlynn" target="_blank" rel="noopener"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>GitHub</a>
4600
+ <a href="https://mlynn.org" target="_blank" rel="noopener"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>mlynn.org</a>
4601
+ <a href="https://www.npmjs.com/package/voyageai-cli" target="_blank" rel="noopener"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12"/></svg>npm</a>
3680
4602
  </div>
3681
4603
  </div>
3682
4604
  </div>
@@ -3709,8 +4631,11 @@ Reranking models rescore initial search results to improve relevance ordering.</
3709
4631
  <strong>⚖️ Compare</strong> — Measure similarity with cosine, dot product &amp; euclidean distance<br>
3710
4632
  <strong>🔍 Search</strong> — Semantic search with optional reranking<br>
3711
4633
  <strong>🔮 Multimodal</strong> — Compare images and text in the same vector space with voyage-multimodal-3.5<br>
4634
+ <strong>🛠️ Generate</strong> — Generate code snippets and scaffold full projects with templates<br>
4635
+ <strong>💬 Chat</strong> — RAG-powered chat with your documents, configurable system prompts<br>
4636
+ <strong>🔄 Workflows</strong> — Multi-step agent workflows with thinking panels and tool orchestration<br>
3712
4637
  <strong>⏱ Benchmark</strong> — Compare model latency, ranking quality, and costs<br>
3713
- <strong>📚 Explore</strong> — Learn about embeddings, vector search, multimodal, RAG, and more
4638
+ <strong>📚 Explore</strong> — 22 interactive concepts covering embeddings, vector search, multimodal, RAG, and more
3714
4639
  </div>
3715
4640
  </div>
3716
4641
 
@@ -3741,9 +4666,12 @@ Reranking models rescore initial search results to improve relevance ordering.</
3741
4666
  <div class="about-section" style="padding-bottom:0;">
3742
4667
  <div class="about-section-title">What's New</div>
3743
4668
  <div class="about-text" style="font-size:13px;">
3744
- <strong>v1.2</strong> — Multimodal tab (image text similarity, cross-modal gallery search),
3745
- 4 new Explore concepts (multimodal embeddings, cross-modal search, modality gap, multimodal RAG),
3746
- auto-update with in-app download &amp; restart, and a hidden easter egg 🕹️
4669
+ <strong>v1.26</strong> — Agent workflows with thinking panel &amp; markdown rendering, multi-step tool orchestration<br>
4670
+ <strong>v1.25</strong> Code generation &amp; project scaffolding tabs<br>
4671
+ <strong>v1.24</strong> MCP server install/uninstall/status commands, Electron app v1.5<br>
4672
+ <strong>v1.23</strong> — MCP server (expose vai tools to AI agents), HTTP transport, bearer auth, 71+ MCP tests<br>
4673
+ <strong>v1.22</strong> — RAG chat with smart source labels, configurable system prompts, streaming responses<br>
4674
+ <strong>v1.2</strong> — Multimodal tab, 4 new Explore concepts, auto-update, hidden easter egg 🕹️
3747
4675
  </div>
3748
4676
  </div>
3749
4677
  </div>
@@ -3760,16 +4688,17 @@ Reranking models rescore initial search results to improve relevance ordering.</
3760
4688
  <div class="page-header">
3761
4689
  <h2 class="page-header-title">Generate</h2>
3762
4690
  <p class="page-header-subtitle">Generate code and scaffold projects</p>
4691
+ <a class="page-header-docs" href="https://docs.vaicli.com/docs/commands/project-setup/generate" target="_blank" rel="noopener" title="Generate documentation"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 1h7l3 3v11H3z"/><path d="M10 1v3h3"/></svg>Docs</a>
3763
4692
  </div>
3764
4693
 
3765
4694
  <!-- Mode Toggle -->
3766
4695
  <div class="subtabs" role="tablist">
3767
4696
  <button class="subtab active" id="genModeCode" onclick="setGenerateMode('code')" role="tab" aria-selected="true">
3768
- <svg viewBox="0 0 16 16"><path fill="currentColor" d="M5.854 4.854a.5.5 0 1 0-.708-.708l-3.5 3.5a.5.5 0 0 0 0 .708l3.5 3.5a.5.5 0 0 0 .708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 0 1 .708-.708l3.5 3.5a.5.5 0 0 1 0 .708l-3.5 3.5a.5.5 0 0 1-.708-.708L13.293 8l-3.147-3.146z"/></svg>
4697
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
3769
4698
  Generate Code
3770
4699
  </button>
3771
4700
  <button class="subtab" id="genModeScaffold" onclick="setGenerateMode('scaffold')" role="tab" aria-selected="false">
3772
- <svg viewBox="0 0 16 16"><path fill="currentColor" d="M14.5 3a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-13a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h13zm-13-1A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h13a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 14.5 2h-13z"/><path fill="currentColor" d="M5 8a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7A.5.5 0 0 1 5 8zm0-2.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm0 5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm-1-5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zM4 8a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm0 2.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0z"/></svg>
4701
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 13h4"/><path d="M10 17h4"/><path d="M10 9h1"/></svg>
3773
4702
  Scaffold Project
3774
4703
  </button>
3775
4704
  </div>
@@ -3911,7 +4840,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
3911
4840
  </div>
3912
4841
  <div id="scaffoldWebButtons" style="display:none;">
3913
4842
  <button class="btn btn-primary" onclick="downloadScaffoldZip()" id="scaffoldDownloadBtn">
3914
- <svg width="16" height="16" viewBox="0 0 16 16" style="margin-right:6px;"><path fill="currentColor" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path fill="currentColor" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg>
4843
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
3915
4844
  Download ZIP
3916
4845
  </button>
3917
4846
  </div>
@@ -3933,7 +4862,8 @@ Reranking models rescore initial search results to improve relevance ordering.</
3933
4862
  <div class="page-header">
3934
4863
  <h2 class="page-header-title">Explore</h2>
3935
4864
  <p class="page-header-subtitle">Learn embedding and vector search concepts</p>
3936
- <p class="page-header-hint">Browse interactive explanations of key topics from cosine similarity to quantization to RAG pipelines.</p>
4865
+ <p class="page-header-hint">Browse interactive explanations of key topics, from cosine similarity to quantization to RAG pipelines.</p>
4866
+ <a class="page-header-docs" href="https://docs.vaicli.com/docs/commands/tools-and-learning/explain" target="_blank" rel="noopener" title="Explore documentation"><svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 1h7l3 3v11H3z"/><path d="M10 1v3h3"/></svg>Docs</a>
3937
4867
  </div>
3938
4868
  <div style="margin-bottom:16px;">
3939
4869
  <input type="text" id="exploreSearch" placeholder="🔍 Search concepts..." oninput="filterExplore()" style="max-width:400px;" aria-label="Search concepts">
@@ -3967,29 +4897,33 @@ Reranking models rescore initial search results to improve relevance ordering.</
3967
4897
  <nav class="settings-nav">
3968
4898
  <div class="settings-nav-header">Settings</div>
3969
4899
  <button class="settings-nav-item active" data-settings-section="general">
3970
- <svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-config"/></svg>
4900
+ <svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-config"/></svg>
3971
4901
  <span>General</span>
3972
4902
  </button>
3973
4903
  <button class="settings-nav-item" data-settings-section="appearance">
3974
- <svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-palette"/></svg>
4904
+ <svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-palette"/></svg>
3975
4905
  <span>Appearance</span>
3976
4906
  </button>
3977
4907
  <button class="settings-nav-item" data-settings-section="models">
3978
- <svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-cube"/></svg>
4908
+ <svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-cube"/></svg>
3979
4909
  <span>Models</span>
3980
4910
  </button>
3981
4911
  <button class="settings-nav-item" data-settings-section="chat">
3982
- <svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-chat"/></svg>
4912
+ <svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-chat"/></svg>
3983
4913
  <span>Chat</span>
3984
4914
  </button>
3985
4915
  <button class="settings-nav-item" data-settings-section="benchmark">
3986
- <svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-gauge"/></svg>
4916
+ <svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-gauge"/></svg>
3987
4917
  <span>Benchmark</span>
3988
4918
  </button>
3989
4919
  <button class="settings-nav-item" data-settings-section="privacy">
3990
- <svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-shield"/></svg>
4920
+ <svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-shield"/></svg>
3991
4921
  <span>Data &amp; Privacy</span>
3992
4922
  </button>
4923
+ <button class="settings-nav-item" data-settings-section="health">
4924
+ <svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-pulse"/></svg>
4925
+ <span>Health Check</span>
4926
+ </button>
3993
4927
  </nav>
3994
4928
 
3995
4929
  <!-- Settings content panels -->
@@ -4009,12 +4943,12 @@ Reranking models rescore initial search results to improve relevance ordering.</
4009
4943
  <div class="settings-row">
4010
4944
  <div class="settings-label">
4011
4945
  <span class="settings-label-text">API Key <span class="settings-origin" data-origin-key="apiKey"></span></span>
4012
- <span class="settings-label-hint">Encrypted via OS keychain · <a href="https://dash.voyageai.com" target="_blank" class="settings-key-link">🔑 Get a key</a></span>
4946
+ <span class="settings-label-hint">Encrypted via OS keychain · <a href="https://dash.voyageai.com" target="_blank" class="settings-key-link"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px;"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg>Get a key</a></span>
4013
4947
  </div>
4014
4948
  <div class="settings-control" style="min-width:260px;">
4015
4949
  <div class="settings-api-field">
4016
4950
  <input type="password" id="settingsApiKey" placeholder="pa-..." autocomplete="off" spellcheck="false">
4017
- <button type="button" id="settingsApiKeyToggle" title="Show/hide key">👁</button>
4951
+ <button type="button" id="settingsApiKeyToggle" title="Show/hide key"><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="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg></button>
4018
4952
  <button type="button" id="settingsApiKeySave" class="save-btn" title="Save key">Save</button>
4019
4953
  </div>
4020
4954
  </div>
@@ -4168,7 +5102,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
4168
5102
  title="Show/hide API key"
4169
5103
  style="padding:8px 12px;min-width:auto"
4170
5104
  >
4171
- 👁️
5105
+ <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="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>
4172
5106
  </button>
4173
5107
  <button
4174
5108
  class="btn"
@@ -4177,7 +5111,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
4177
5111
  title="Save API key"
4178
5112
  style="padding:8px 12px;min-width:auto"
4179
5113
  >
4180
- 💾
5114
+ <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="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>
4181
5115
  </button>
4182
5116
  </div>
4183
5117
  </div>
@@ -4349,6 +5283,30 @@ Reranking models rescore initial search results to improve relevance ordering.</
4349
5283
  </div>
4350
5284
  </div>
4351
5285
 
5286
+ <!-- ── Health Check ── -->
5287
+ <div class="settings-panel" id="settings-health">
5288
+ <div class="settings-panel-header">
5289
+ <h3 class="settings-panel-title">Health Check</h3>
5290
+ <p class="settings-panel-subtitle">Validate your vai setup and connectivity</p>
5291
+ </div>
5292
+ <div class="settings-section">
5293
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
5294
+ <span class="settings-section-title" style="margin:0;">Diagnostics</span>
5295
+ <button class="btn" id="doctorRunBtn" style="padding:6px 16px;font-size:13px;">Run Health Check</button>
5296
+ </div>
5297
+ <div id="doctorResults" style="display:none;">
5298
+ <div id="doctorCheckList" style="display:flex;flex-direction:column;gap:6px;"></div>
5299
+ <div id="doctorSummary" style="margin-top:16px;padding:12px 16px;border-radius:var(--radius);font-size:13px;"></div>
5300
+ </div>
5301
+ <div id="doctorLoading" style="display:none;text-align:center;padding:24px 0;color:var(--text-muted);font-size:13px;">
5302
+ Running health checks...
5303
+ </div>
5304
+ <div id="doctorEmpty" style="text-align:center;padding:24px 0;color:var(--text-muted);font-size:13px;">
5305
+ Click "Run Health Check" to validate your setup.
5306
+ </div>
5307
+ </div>
5308
+ </div>
5309
+
4352
5310
  <div style="text-align:center;padding:8px 0;">
4353
5311
  <span class="settings-saved" id="settingsSavedMsg">✓ Saved</span>
4354
5312
  </div>
@@ -5169,12 +6127,12 @@ async function downloadScaffoldZip() {
5169
6127
  // Show success
5170
6128
  btn.innerHTML = '<span style="margin-right:6px;">✓</span> Downloaded!';
5171
6129
  setTimeout(() => {
5172
- btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" style="margin-right:6px;"><path fill="currentColor" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path fill="currentColor" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg> Download ZIP';
6130
+ btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg> Download ZIP';
5173
6131
  btn.disabled = false;
5174
6132
  }, 2000);
5175
6133
  } catch (err) {
5176
6134
  alert('Error: ' + err.message);
5177
- btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" style="margin-right:6px;"><path fill="currentColor" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/><path fill="currentColor" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/></svg> Download ZIP';
6135
+ btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px;"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg> Download ZIP';
5178
6136
  btn.disabled = false;
5179
6137
  }
5180
6138
  }
@@ -5270,37 +6228,73 @@ document.addEventListener('DOMContentLoaded', () => {
5270
6228
  });
5271
6229
 
5272
6230
  // ── Explore: icons and tab mappings per concept ──
6231
+ // Lucide SVG icon helper — returns an inline <svg> string
6232
+ function lucideIcon(d, size = 16) {
6233
+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
6234
+ }
6235
+
6236
+ // Lucide path constants for concept/button icons
6237
+ const LI = {
6238
+ zap: 'M13 2 3 14h9l-1 8 10-12h-9l1-8z',
6239
+ search: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
6240
+ trophy: 'M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2z',
6241
+ bot: 'M12 8V4H8M8 2h8M2 14a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2zM6 14v4M10 14v4M14 14v4M18 14v4',
6242
+ ruler: 'M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z M15 5l4 4',
6243
+ target: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0M12 12m-6 0a6 6 0 1 0 12 0 6 6 0 1 0-12 0M12 12m-2 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0',
6244
+ tag: 'M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42zM7.5 7.5m-.5 0a.5.5 0 1 0 1 0 .5.5 0 1 0-1 0',
6245
+ brain: 'M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2zM14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z',
6246
+ key: 'M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4zM16.5 7.5m-.5 0a.5.5 0 1 0 1 0 .5.5 0 1 0-1 0',
6247
+ globe: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z',
6248
+ package: 'M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12',
6249
+ timer: 'M10 2h4M12 14l3-3M12 22a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
6250
+ flask: 'M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2M8.5 2h7M7 16h10',
6251
+ puzzle: 'M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.23 8.77c.24-.24.581-.353.917-.303.515.077.877.528 1.073 1.01a2.5 2.5 0 1 0 3.259-3.259c-.482-.196-.933-.558-1.01-1.073-.05-.336.062-.676.303-.917l1.525-1.525A2.402 2.402 0 0 1 12 2c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.878.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.968 1.02z',
6252
+ link: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
6253
+ barChart: 'M12 20V10M18 20V4M6 20v-4',
6254
+ microscope: 'M6 18h8M3 22h18M14 22a7 7 0 1 0 0-14h-1M9 14h2M9 12a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2z',
6255
+ image: 'M3 3h18a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM8.5 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM21 15l-5-5L5 21',
6256
+ shuffle: 'M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.7-1.1 2-1.7 3.3-1.7H22M18 2l4 4-4 4M2 6h1.9c1.5 0 2.9.9 3.6 2.2M22 18l-4 4-4-4M19 14h3',
6257
+ circle: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0',
6258
+ fileTxt: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7zM14 2v4a2 2 0 0 0 2 2h4M10 13h4M10 17h4M10 9h1',
6259
+ scale: 'M16 3h5v5M8 3H3v5M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3M21 3l-7.828 7.828A4 4 0 0 0 12 13.7V22',
6260
+ laptop: 'M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9M2 20h20M12 12h.01',
6261
+ blocks: 'M2 12h10v10H2zM14 4l6 3.5v7L14 18l-6-3.5v-7zM12 2l10 6',
6262
+ refresh: 'M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16',
6263
+ sparkle: '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',
6264
+ download: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3',
6265
+ copy: 'M9 9V6a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-3M3 15a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3z',
6266
+ filter: 'M3 6h18M7 12h10M10 18h4',
6267
+ };
6268
+
5273
6269
  const CONCEPT_META = {
5274
- embeddings: { icon: '🧮', tab: 'embed' },
5275
- reranking: { icon: '🏆', tab: 'search' },
5276
- 'vector-search': { icon: '🔎', tab: 'search' },
5277
- rag: { icon: '🤖', tab: 'search' },
5278
- 'cosine-similarity': { icon: '📐', tab: 'compare' },
5279
- 'two-stage-retrieval': { icon: '🎯', tab: 'search' },
5280
- 'input-type': { icon: '🏷️', tab: 'embed' },
5281
- models: { icon: '🧠', tab: 'embed' },
5282
- 'api-keys': { icon: '🔑', tab: 'embed' },
5283
- 'api-access': { icon: '🌐', tab: 'embed' },
5284
- 'batch-processing': { icon: '📦', tab: 'embed' },
5285
- benchmarking: { icon: '⏱', tab: 'benchmark' },
5286
- quantization: { icon: '⚗️', tab: 'benchmark' },
5287
- 'mixture-of-experts': { icon: '🧩', tab: 'embed' },
5288
- 'shared-embedding-space': { icon: '🔗', tab: 'compare' },
5289
- 'rteb-benchmarks': { icon: '📊', tab: 'benchmark' },
5290
- 'voyage-4-nano': { icon: '🔬', tab: 'embed' },
5291
- 'rerank-eval': { icon: '📐', tab: 'benchmark' },
5292
- 'multimodal-embeddings': { icon: '🖼️', tab: 'multimodal' },
5293
- 'cross-modal-search': { icon: '🔀', tab: 'multimodal' },
5294
- 'modality-gap': { icon: '🕳️', tab: 'multimodal' },
5295
- 'multimodal-rag': { icon: '📄', tab: 'multimodal' },
5296
- 'provider-comparison': { icon: '⚖️', tab: 'explore' },
5297
- // Code generation & scaffolding
5298
- 'code-generation': { icon: '💻', tab: 'explore' },
5299
- scaffolding: { icon: '🏗️', tab: 'explore' },
5300
- 'eval-comparison': { icon: '📊', tab: 'benchmark' },
5301
- // MongoDB Auto-Embedding
5302
- 'auto-embedding': { icon: '⚡', tab: 'explore' },
5303
- 'vai-vs-auto-embedding': { icon: '🔄', tab: 'explore' },
6270
+ embeddings: { icon: LI.zap, tab: 'embed' },
6271
+ reranking: { icon: LI.trophy, tab: 'search' },
6272
+ 'vector-search': { icon: LI.search, tab: 'search' },
6273
+ rag: { icon: LI.bot, tab: 'search' },
6274
+ 'cosine-similarity': { icon: LI.ruler, tab: 'compare' },
6275
+ 'two-stage-retrieval': { icon: LI.target, tab: 'search' },
6276
+ 'input-type': { icon: LI.tag, tab: 'embed' },
6277
+ models: { icon: LI.brain, tab: 'embed' },
6278
+ 'api-keys': { icon: LI.key, tab: 'embed' },
6279
+ 'api-access': { icon: LI.globe, tab: 'embed' },
6280
+ 'batch-processing': { icon: LI.package, tab: 'embed' },
6281
+ benchmarking: { icon: LI.timer, tab: 'benchmark' },
6282
+ quantization: { icon: LI.flask, tab: 'benchmark' },
6283
+ 'mixture-of-experts': { icon: LI.puzzle, tab: 'embed' },
6284
+ 'shared-embedding-space': { icon: LI.link, tab: 'compare' },
6285
+ 'rteb-benchmarks': { icon: LI.barChart, tab: 'benchmark' },
6286
+ 'voyage-4-nano': { icon: LI.microscope, tab: 'embed' },
6287
+ 'rerank-eval': { icon: LI.ruler, tab: 'benchmark' },
6288
+ 'multimodal-embeddings': { icon: LI.image, tab: 'multimodal' },
6289
+ 'cross-modal-search': { icon: LI.shuffle, tab: 'multimodal' },
6290
+ 'modality-gap': { icon: LI.circle, tab: 'multimodal' },
6291
+ 'multimodal-rag': { icon: LI.fileTxt, tab: 'multimodal' },
6292
+ 'provider-comparison': { icon: LI.scale, tab: 'explore' },
6293
+ 'code-generation': { icon: LI.laptop, tab: 'explore' },
6294
+ scaffolding: { icon: LI.blocks, tab: 'explore' },
6295
+ 'eval-comparison': { icon: LI.barChart, tab: 'benchmark' },
6296
+ 'auto-embedding': { icon: LI.zap, tab: 'explore' },
6297
+ 'vai-vs-auto-embedding': { icon: LI.refresh, tab: 'explore' },
5304
6298
  };
5305
6299
 
5306
6300
  let exploreConcepts = {};
@@ -5316,7 +6310,9 @@ async function loadConcepts() {
5316
6310
  }
5317
6311
 
5318
6312
  function escapeHtml(str) {
5319
- return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
6313
+ if (str == null) return '';
6314
+ const s = typeof str === 'string' ? str : String(str);
6315
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
5320
6316
  }
5321
6317
 
5322
6318
  function buildExploreCards() {
@@ -5324,13 +6320,13 @@ function buildExploreCards() {
5324
6320
  grid.innerHTML = '';
5325
6321
 
5326
6322
  for (const [key, concept] of Object.entries(exploreConcepts)) {
5327
- const meta = CONCEPT_META[key] || { icon: '📚', tab: 'embed' };
6323
+ const meta = CONCEPT_META[key] || { icon: LI.package, tab: 'embed' };
5328
6324
  const card = document.createElement('div');
5329
6325
  card.className = 'explore-card';
5330
6326
  card.dataset.key = key;
5331
6327
 
5332
6328
  card.innerHTML = `
5333
- <div class="explore-card-icon">${meta.icon}</div>
6329
+ <div class="explore-card-icon">${lucideIcon(meta.icon, 28)}</div>
5334
6330
  <div class="explore-card-title">${escapeHtml(concept.title)}</div>
5335
6331
  <div class="explore-card-summary">${escapeHtml(concept.summary)}</div>
5336
6332
  `;
@@ -5357,12 +6353,12 @@ let exploreModalPreviousFocus = null;
5357
6353
  function openExploreModal(key) {
5358
6354
  const concept = exploreConcepts[key];
5359
6355
  if (!concept) return;
5360
- const meta = CONCEPT_META[key] || { icon: '📚', tab: 'embed' };
6356
+ const meta = CONCEPT_META[key] || { icon: LI.package, tab: 'embed' };
5361
6357
 
5362
6358
  // Save the currently focused element to restore when modal closes
5363
6359
  exploreModalPreviousFocus = document.activeElement;
5364
6360
 
5365
- document.getElementById('exploreModalIcon').textContent = meta.icon;
6361
+ document.getElementById('exploreModalIcon').innerHTML = lucideIcon(meta.icon, 32);
5366
6362
  document.getElementById('exploreModalTitle').textContent = concept.title;
5367
6363
  document.getElementById('exploreModalSummary').textContent = concept.summary;
5368
6364
 
@@ -6676,6 +7672,95 @@ function initSettings() {
6676
7672
 
6677
7673
  // Set up settings sub-navigation
6678
7674
  setupSettingsNav();
7675
+
7676
+ // Set up doctor health check button
7677
+ setupDoctorPanel();
7678
+ }
7679
+
7680
+ // ── Doctor Health Check ──
7681
+ function setupDoctorPanel() {
7682
+ const runBtn = document.getElementById('doctorRunBtn');
7683
+ if (!runBtn) return;
7684
+ runBtn.addEventListener('click', runDoctorCheck);
7685
+ }
7686
+
7687
+ async function runDoctorCheck() {
7688
+ const btn = document.getElementById('doctorRunBtn');
7689
+ const loading = document.getElementById('doctorLoading');
7690
+ const results = document.getElementById('doctorResults');
7691
+ const empty = document.getElementById('doctorEmpty');
7692
+ const checkList = document.getElementById('doctorCheckList');
7693
+ const summary = document.getElementById('doctorSummary');
7694
+
7695
+ btn.disabled = true;
7696
+ btn.textContent = 'Running...';
7697
+ empty.style.display = 'none';
7698
+ results.style.display = 'none';
7699
+ loading.style.display = 'block';
7700
+
7701
+ try {
7702
+ const res = await fetch('/api/doctor');
7703
+ const data = await res.json();
7704
+
7705
+ checkList.innerHTML = '';
7706
+ let hasError = false;
7707
+ let hasWarning = false;
7708
+
7709
+ for (const [key, check] of Object.entries(data)) {
7710
+ let icon, badgeClass, badgeText;
7711
+ if (check.ok === true) {
7712
+ icon = '&#x2713;';
7713
+ badgeClass = 'pass';
7714
+ badgeText = 'pass';
7715
+ } else if (check.ok === false) {
7716
+ icon = '&#x2717;';
7717
+ if (check.required) { hasError = true; badgeClass = 'fail'; badgeText = 'fail'; }
7718
+ else { hasWarning = true; badgeClass = 'warn'; badgeText = 'warn'; }
7719
+ } else {
7720
+ icon = '&#x26A0;';
7721
+ hasWarning = true;
7722
+ badgeClass = 'optional';
7723
+ badgeText = 'optional';
7724
+ }
7725
+
7726
+ const el = document.createElement('div');
7727
+ el.className = 'doctor-check';
7728
+ el.innerHTML = `
7729
+ <span class="doctor-check-icon" style="color:var(--${badgeClass === 'pass' ? 'success' : badgeClass === 'fail' ? 'error' : 'warning'})">${icon}</span>
7730
+ <div class="doctor-check-body">
7731
+ <div class="doctor-check-name">${check.name}</div>
7732
+ <div class="doctor-check-msg">${check.message || ''}</div>
7733
+ ${check.hint ? `<div class="doctor-check-hint">${check.hint}</div>` : ''}
7734
+ </div>
7735
+ <span class="doctor-check-badge ${badgeClass}">${badgeText}</span>
7736
+ `;
7737
+ checkList.appendChild(el);
7738
+ }
7739
+
7740
+ if (hasError) {
7741
+ summary.style.background = 'rgba(255,105,96,0.08)';
7742
+ summary.style.color = 'var(--error)';
7743
+ summary.textContent = 'Some required checks failed. Fix the issues above to use vai.';
7744
+ } else if (hasWarning) {
7745
+ summary.style.background = 'rgba(255,192,16,0.08)';
7746
+ summary.style.color = 'var(--warning)';
7747
+ summary.textContent = 'All required checks passed. Some optional features are not configured.';
7748
+ } else {
7749
+ summary.style.background = 'rgba(0,212,170,0.08)';
7750
+ summary.style.color = 'var(--success)';
7751
+ summary.textContent = 'All checks passed. vai is ready to use!';
7752
+ }
7753
+
7754
+ loading.style.display = 'none';
7755
+ results.style.display = 'block';
7756
+ } catch (err) {
7757
+ loading.style.display = 'none';
7758
+ empty.style.display = 'block';
7759
+ empty.textContent = 'Health check failed: ' + err.message;
7760
+ }
7761
+
7762
+ btn.disabled = false;
7763
+ btn.textContent = 'Run Health Check';
6679
7764
  }
6680
7765
 
6681
7766
  // ── Update Checker ──
@@ -6867,6 +7952,13 @@ function initOnboarding() {
6867
7952
  body: '<strong>Chat with your knowledge base</strong> using retrieval-augmented generation. Voyage AI finds relevant documents, then your chosen LLM generates grounded answers with source citations.',
6868
7953
  arrow: 'left',
6869
7954
  },
7955
+ {
7956
+ target: '[data-tab="workflows"]',
7957
+ icon: '\u2699\uFE0F',
7958
+ title: 'Workflow Visualizer',
7959
+ body: '<strong>Visualize and execute workflows</strong> as interactive DAGs. Browse built-in templates, inspect step configuration, and watch execution animate in real time.',
7960
+ arrow: 'left',
7961
+ },
6870
7962
  {
6871
7963
  target: '[data-tab="benchmark"]',
6872
7964
  icon: '⏱️',
@@ -8341,6 +9433,145 @@ function renderMarkdown(md) {
8341
9433
  return result.join('\n');
8342
9434
  }
8343
9435
 
9436
+ /**
9437
+ * Tool metadata: icon, label, and a function to summarize the call for the thinking panel.
9438
+ */
9439
+ const TOOL_META = {
9440
+ vai_query: { icon: '\uD83D\uDD0D', label: 'RAG Query', verb: 'Searching', descFn: a => a.query ? `"${a.query}"` : '' },
9441
+ vai_search: { icon: '\uD83D\uDD0E', label: 'Vector Search', verb: 'Searching vectors', descFn: a => a.query ? `"${a.query}"` : '' },
9442
+ vai_rerank: { icon: '\u2195\uFE0F', label: 'Rerank', verb: 'Reranking', descFn: a => a.query ? `${a.documents?.length || '?'} docs for "${a.query}"` : '' },
9443
+ vai_embed: { icon: '\uD83E\uDDE0', label: 'Embed', verb: 'Embedding', descFn: a => a.text ? `"${a.text.slice(0, 60)}${a.text.length > 60 ? '...' : ''}"` : '' },
9444
+ vai_similarity: { icon: '\uD83C\uDFAF', label: 'Similarity', verb: 'Comparing', descFn: a => a.text1 ? `two texts` : '' },
9445
+ vai_collections: { icon: '\uD83D\uDDC4\uFE0F', label: 'Collections', verb: 'Discovering', descFn: a => a.db ? `in ${a.db}` : 'available databases' },
9446
+ vai_models: { icon: '\uD83E\uDD16', label: 'Models', verb: 'Listing', descFn: () => 'available models' },
9447
+ vai_topics: { icon: '\uD83D\uDCDA', label: 'Topics', verb: 'Browsing', descFn: () => 'educational topics' },
9448
+ vai_explain: { icon: '\uD83D\uDCA1', label: 'Explain', verb: 'Explaining', descFn: a => a.topic || '' },
9449
+ vai_estimate: { icon: '\uD83D\uDCB0', label: 'Cost Estimate', verb: 'Estimating', descFn: a => a.docs ? `${a.docs} docs` : '' },
9450
+ vai_ingest: { icon: '\uD83D\uDCE5', label: 'Ingest', verb: 'Ingesting', descFn: a => a.source || 'document' },
9451
+ };
9452
+ const DEFAULT_TOOL_META = { icon: '\u2699\uFE0F', label: '', verb: 'Running', descFn: () => '' };
9453
+
9454
+ /**
9455
+ * Create the thinking panel <details> element.
9456
+ * Returns { panel, timeline, addStep, finalize }.
9457
+ */
9458
+ function createThinkingPanel() {
9459
+ const panel = document.createElement('details');
9460
+ panel.className = 'chat-thinking';
9461
+ panel.open = true;
9462
+
9463
+ const summary = document.createElement('summary');
9464
+ summary.innerHTML =
9465
+ '<span class="thinking-icon">\uD83E\uDDE0</span>' +
9466
+ '<span class="thinking-label">Thinking</span>' +
9467
+ '<span class="thinking-count">0</span>' +
9468
+ '<span class="thinking-elapsed"></span>' +
9469
+ '<span class="thinking-chevron">\u25B6</span>';
9470
+ panel.appendChild(summary);
9471
+
9472
+ const timeline = document.createElement('div');
9473
+ timeline.className = 'thinking-timeline';
9474
+ panel.appendChild(timeline);
9475
+
9476
+ let stepCount = 0;
9477
+ let activeStep = null;
9478
+ const startTime = Date.now();
9479
+ let elapsedTimer = null;
9480
+
9481
+ // Update the elapsed time in the summary
9482
+ function tickElapsed() {
9483
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
9484
+ summary.querySelector('.thinking-elapsed').textContent = elapsed + 's';
9485
+ }
9486
+ elapsedTimer = setInterval(tickElapsed, 200);
9487
+ tickElapsed();
9488
+
9489
+ function addStep(data) {
9490
+ const meta = TOOL_META[data.name] || DEFAULT_TOOL_META;
9491
+ const desc = meta.descFn(data.args || {});
9492
+
9493
+ // Mark previous active step as done
9494
+ if (activeStep) {
9495
+ activeStep.classList.remove('active');
9496
+ activeStep.classList.add('done');
9497
+ }
9498
+
9499
+ stepCount++;
9500
+ summary.querySelector('.thinking-count').textContent = stepCount;
9501
+
9502
+ const step = document.createElement('div');
9503
+ step.className = 'thinking-step active';
9504
+ if (data.error) step.className = 'thinking-step error';
9505
+
9506
+ const iconDiv = document.createElement('div');
9507
+ iconDiv.className = 'thinking-step-icon';
9508
+ iconDiv.textContent = meta.icon;
9509
+ step.appendChild(iconDiv);
9510
+
9511
+ const body = document.createElement('div');
9512
+ body.className = 'thinking-step-body';
9513
+
9514
+ const header = document.createElement('div');
9515
+ header.className = 'thinking-step-header';
9516
+ const nameSpan = document.createElement('span');
9517
+ nameSpan.className = 'thinking-step-name';
9518
+ nameSpan.textContent = meta.verb + (desc ? ' ' : '') + (meta.label || data.name);
9519
+ header.appendChild(nameSpan);
9520
+
9521
+ if (data.timeMs !== undefined) {
9522
+ const timeSpan = document.createElement('span');
9523
+ timeSpan.className = 'thinking-step-time';
9524
+ timeSpan.textContent = data.timeMs + 'ms';
9525
+ header.appendChild(timeSpan);
9526
+ }
9527
+ body.appendChild(header);
9528
+
9529
+ if (desc) {
9530
+ const descDiv = document.createElement('div');
9531
+ descDiv.className = 'thinking-step-desc';
9532
+ descDiv.textContent = desc;
9533
+ body.appendChild(descDiv);
9534
+ }
9535
+
9536
+ if (data.error) {
9537
+ const errDiv = document.createElement('div');
9538
+ errDiv.className = 'thinking-step-detail';
9539
+ errDiv.style.color = '#e74c3c';
9540
+ errDiv.textContent = data.error;
9541
+ body.appendChild(errDiv);
9542
+ } else if (data.resultSummary) {
9543
+ const detailDiv = document.createElement('div');
9544
+ detailDiv.className = 'thinking-step-detail';
9545
+ detailDiv.innerHTML = data.resultSummary;
9546
+ body.appendChild(detailDiv);
9547
+ }
9548
+
9549
+ step.appendChild(body);
9550
+ timeline.appendChild(step);
9551
+
9552
+ if (!data.error) activeStep = step;
9553
+
9554
+ return step;
9555
+ }
9556
+
9557
+ function finalize() {
9558
+ clearInterval(elapsedTimer);
9559
+ // Final elapsed
9560
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
9561
+ summary.querySelector('.thinking-elapsed').textContent = elapsed + 's';
9562
+ summary.querySelector('.thinking-label').textContent = 'Thought for ' + elapsed + 's';
9563
+ summary.querySelector('.thinking-icon').textContent = '\u2728';
9564
+ // Mark last step done and collapse
9565
+ if (activeStep) {
9566
+ activeStep.classList.remove('active');
9567
+ activeStep.classList.add('done');
9568
+ }
9569
+ panel.open = false;
9570
+ }
9571
+
9572
+ return { panel, addStep, finalize };
9573
+ }
9574
+
8344
9575
  function addChatMessage(role, content, sources) {
8345
9576
  const container = document.getElementById('chatMessages');
8346
9577
  const div = document.createElement('div');
@@ -8350,6 +9581,26 @@ function addChatMessage(role, content, sources) {
8350
9581
  contentSpan.textContent = content;
8351
9582
  div.appendChild(contentSpan);
8352
9583
 
9584
+ // Add copy button for assistant messages
9585
+ if (role === 'assistant') {
9586
+ const copyBtn = document.createElement('button');
9587
+ copyBtn.className = 'chat-copy-btn';
9588
+ copyBtn.title = 'Copy to clipboard';
9589
+ copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M5 11H3.5A1.5 1.5 0 0 1 2 9.5V3.5A1.5 1.5 0 0 1 3.5 2h6A1.5 1.5 0 0 1 11 3.5V5"/></svg>';
9590
+ copyBtn.addEventListener('click', () => {
9591
+ const text = contentSpan.textContent || contentSpan.innerText;
9592
+ navigator.clipboard.writeText(text).then(() => {
9593
+ copyBtn.classList.add('copied');
9594
+ copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 8.5l3 3 7-7"/></svg>';
9595
+ setTimeout(() => {
9596
+ copyBtn.classList.remove('copied');
9597
+ copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5" y="5" width="9" height="9" rx="1.5"/><path d="M5 11H3.5A1.5 1.5 0 0 1 2 9.5V3.5A1.5 1.5 0 0 1 3.5 2h6A1.5 1.5 0 0 1 11 3.5V5"/></svg>';
9598
+ }, 2000);
9599
+ });
9600
+ });
9601
+ div.appendChild(copyBtn);
9602
+ }
9603
+
8353
9604
  if (sources && sources.length > 0) {
8354
9605
  const details = document.createElement('details');
8355
9606
  details.className = 'chat-sources';
@@ -8433,7 +9684,7 @@ async function sendChatMessage() {
8433
9684
  let assistantDiv = null;
8434
9685
  let fullText = '';
8435
9686
  let sources = [];
8436
- let toolCallsDiv = null;
9687
+ let thinkingPanel = null;
8437
9688
 
8438
9689
  while (true) {
8439
9690
  const { done, value } = await reader.read();
@@ -8457,33 +9708,13 @@ async function sendChatMessage() {
8457
9708
  }
8458
9709
 
8459
9710
  if (currentEvent === 'tool_call') {
8460
- if (!toolCallsDiv) {
8461
- toolCallsDiv = document.createElement('div');
8462
- toolCallsDiv.className = 'chat-tool-calls';
8463
- document.getElementById('chatMessages').appendChild(toolCallsDiv);
8464
- }
8465
- const tc = document.createElement('div');
8466
- tc.className = 'chat-tool-call' + (data.error ? ' error' : '');
8467
- const icon = document.createElement('span');
8468
- icon.className = 'tool-icon';
8469
- icon.textContent = '\u2699';
8470
- tc.appendChild(icon);
8471
- const nameSpan = document.createElement('span');
8472
- nameSpan.className = 'tool-name';
8473
- nameSpan.textContent = data.name;
8474
- tc.appendChild(nameSpan);
8475
- const timeSpan = document.createElement('span');
8476
- timeSpan.className = 'tool-time';
8477
- timeSpan.textContent = (data.timeMs || 0) + 'ms';
8478
- tc.appendChild(timeSpan);
8479
- if (data.error) {
8480
- const errSpan = document.createElement('span');
8481
- errSpan.className = 'tool-error';
8482
- errSpan.textContent = data.error;
8483
- tc.appendChild(errSpan);
9711
+ // Create thinking panel on first tool call
9712
+ if (!thinkingPanel) {
9713
+ typing.remove();
9714
+ thinkingPanel = createThinkingPanel();
9715
+ document.getElementById('chatMessages').appendChild(thinkingPanel.panel);
8484
9716
  }
8485
- toolCallsDiv.appendChild(tc);
8486
- typing.textContent = 'Calling ' + data.name + '...';
9717
+ thinkingPanel.addStep(data);
8487
9718
  document.getElementById('chatMessages').scrollTop = document.getElementById('chatMessages').scrollHeight;
8488
9719
  }
8489
9720
 
@@ -8498,6 +9729,8 @@ async function sendChatMessage() {
8498
9729
  }
8499
9730
 
8500
9731
  if (currentEvent === 'done') {
9732
+ // Finalize the thinking panel (collapse, show elapsed)
9733
+ if (thinkingPanel) thinkingPanel.finalize();
8501
9734
  // Render accumulated text as markdown for assistant messages
8502
9735
  if (assistantDiv && fullText) {
8503
9736
  const contentEl = assistantDiv.querySelector('.chat-message-content');
@@ -8537,6 +9770,7 @@ async function sendChatMessage() {
8537
9770
 
8538
9771
  } catch (err) {
8539
9772
  if (typing.parentNode) typing.remove();
9773
+ if (thinkingPanel) thinkingPanel.finalize();
8540
9774
  addChatMessage('system-msg', `Error: ${err.message}`);
8541
9775
  } finally {
8542
9776
  sendBtn.disabled = false;
@@ -8751,6 +9985,1981 @@ init();
8751
9985
  </div>
8752
9986
  </div>
8753
9987
 
9988
+ <!-- ========== WORKFLOW VISUALIZER JS ========== -->
9989
+ <script>
9990
+ // ── Workflow Visualizer ──
9991
+
9992
+ // escapeHtml is defined in the main IIFE scope and not accessible here — redeclare it
9993
+ function escapeHtml(str) {
9994
+ if (str == null) return '';
9995
+ const s = typeof str === 'string' ? str : String(str);
9996
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
9997
+ }
9998
+
9999
+ const WF_NODE_META = {
10000
+ query: { icon: '\u{1F50D}', label: 'RAG Query', color: '#40E0FF', category: 'retrieval' },
10001
+ search: { icon: '\u{1F50E}', label: 'Vector Search', color: '#40E0FF', category: 'retrieval' },
10002
+ rerank: { icon: '\u{1F3C6}', label: 'Rerank', color: '#40E0FF', category: 'retrieval' },
10003
+ ingest: { icon: '\u{1F4E5}', label: 'Ingest', color: '#40E0FF', category: 'retrieval' },
10004
+ embed: { icon: 'M13 2 3 14h9l-1 8 10-12h-9l1-8z', label: 'Embed', color: '#B388FF', category: 'embedding' },
10005
+ similarity: { icon: 'M8 3 4 7l4 4M4 7h16M16 21l4-4-4-4M20 17H4', label: 'Similarity', color: '#B388FF', category: 'embedding' },
10006
+ collections: { icon: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z', label: 'Collections', color: '#00D4AA', category: 'management' },
10007
+ models: { icon: 'M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2M9 5h6M9 14l2 2 4-4', label: 'Models', color: '#00D4AA', category: 'management' },
10008
+ estimate: { icon: 'M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6', label: 'Cost Estimate', color: '#FFB74D', category: 'utility' },
10009
+ explain: { icon: 'M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z', label: 'Explain', color: '#FFB74D', category: 'utility' },
10010
+ topics: { icon: 'M12 6V2M8 18H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-4M12 18v4M8 22h8', label: 'Topics', color: '#FFB74D', category: 'utility' },
10011
+ merge: { icon: 'M18 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 9v6M18 15l-6-6-6 6', label: 'Merge', color: '#90A4AE', category: 'control' },
10012
+ filter: { icon: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3z', label: 'Filter', color: '#90A4AE', category: 'control' },
10013
+ transform: { icon: 'M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16', label: 'Transform', color: '#90A4AE', category: 'control' },
10014
+ generate: { icon: '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', label: 'Generate', color: '#69F0AE', category: 'generation' },
10015
+ query: { icon: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z', label: 'Query', color: '#64B5F6', category: 'retrieval' },
10016
+ rerank: { icon: 'M3 6h18M7 12h10M10 18h4', label: 'Rerank', color: '#CE93D8', category: 'retrieval' },
10017
+ search: { icon: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z', label: 'Search', color: '#64B5F6', category: 'retrieval' },
10018
+ ingest: { icon: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3', label: 'Ingest', color: '#4DB6AC', category: 'management' },
10019
+ };
10020
+
10021
+ // Fallback icon (gear) for unknown workflow node types
10022
+ const WF_FALLBACK_ICON = 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2zM12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0';
10023
+
10024
+ const WF_NODE_W = 180;
10025
+ const WF_NODE_H = 64;
10026
+ const WF_LAYER_GAP = 260;
10027
+ const WF_NODE_GAP = 100;
10028
+ const WF_PAD = 80;
10029
+ const WF_PORT_R = 5; // Port circle radius
10030
+
10031
+ let wfState = {
10032
+ workflows: [],
10033
+ examples: [],
10034
+ activeWorkflow: null,
10035
+ selectedNodeId: null,
10036
+ executionState: {},
10037
+ executionResults: {},
10038
+ nodePositions: {},
10039
+ layers: [],
10040
+ graph: {},
10041
+ zoom: 1,
10042
+ panX: 0,
10043
+ panY: 0,
10044
+ isPanning: false,
10045
+ panStart: { x: 0, y: 0 },
10046
+ executing: false,
10047
+ // Builder state
10048
+ builderMode: false,
10049
+ draggingEdge: null,
10050
+ dragNode: null,
10051
+ dirtyFlag: false,
10052
+ };
10053
+
10054
+ // ── Library ──
10055
+ async function wfLoadLibrary() {
10056
+ try {
10057
+ const [wfRes, exRes] = await Promise.all([
10058
+ fetch('/api/workflows'),
10059
+ fetch('/api/workflows/examples'),
10060
+ ]);
10061
+ const wfData = await wfRes.json();
10062
+ const exData = await exRes.json();
10063
+ wfState.workflows = wfData.workflows || [];
10064
+ wfState.examples = exData.examples || [];
10065
+ wfRenderLibrary();
10066
+ } catch (err) {
10067
+ const list = document.getElementById('wfLibraryList');
10068
+ if (list) list.innerHTML = '<div style="padding:16px;color:var(--text-muted);font-size:12px;">Failed to load workflows</div>';
10069
+ }
10070
+ }
10071
+
10072
+ function wfRenderLibrary() {
10073
+ const list = document.getElementById('wfLibraryList');
10074
+ if (!list) return;
10075
+ if (wfState.workflows.length === 0 && (!wfState.examples || wfState.examples.length === 0)) {
10076
+ list.innerHTML = '<div style="padding:16px;color:var(--text-muted);font-size:12px;">No workflows found</div>';
10077
+ return;
10078
+ }
10079
+
10080
+ // Built-in templates
10081
+ let html = wfState.workflows.map(w => {
10082
+ const displayName = w.name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
10083
+ return `<div class="wf-library-item" data-wf-name="${w.name}" onclick="wfSelectWorkflow('${w.name}')">
10084
+ <div class="wf-library-item-name">${displayName}</div>
10085
+ <div class="wf-library-item-desc">${w.description || ''}</div>
10086
+ </div>`;
10087
+ }).join('');
10088
+
10089
+ // Collapsible examples section
10090
+ const examples = wfState.examples || [];
10091
+ if (examples.length > 0) {
10092
+ html += `<div class="wf-library-section">
10093
+ <button class="wf-library-section-toggle" onclick="wfToggleExamples(this)">
10094
+ <span class="arrow">&#9654;</span> Examples (${examples.length})
10095
+ </button>
10096
+ <div class="wf-examples-content" style="display:none;">`;
10097
+
10098
+ const categories = ['Retrieval', 'RAG', 'Ingestion', 'Analysis', 'Other'];
10099
+ for (const cat of categories) {
10100
+ const items = examples.filter(e => e.category === cat);
10101
+ if (items.length === 0) continue;
10102
+ html += `<div class="wf-library-category">${cat}</div>`;
10103
+ html += items.map(w => {
10104
+ const displayName = w.name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
10105
+ return `<div class="wf-library-item" data-wf-name="${w.name}" onclick="wfSelectWorkflow('${w.name}')">
10106
+ <div class="wf-library-item-name">${displayName}</div>
10107
+ <div class="wf-library-item-desc">${w.description || ''}</div>
10108
+ </div>`;
10109
+ }).join('');
10110
+ }
10111
+ html += '</div></div>';
10112
+ }
10113
+
10114
+ list.innerHTML = html;
10115
+ }
10116
+
10117
+ function wfToggleExamples(btn) {
10118
+ const content = btn.nextElementSibling;
10119
+ const isOpen = content.style.display !== 'none';
10120
+ content.style.display = isOpen ? 'none' : '';
10121
+ btn.classList.toggle('open', !isOpen);
10122
+ }
10123
+
10124
+ async function wfSelectWorkflow(name) {
10125
+ // Highlight in library
10126
+ document.querySelectorAll('.wf-library-item').forEach(el => {
10127
+ el.classList.toggle('active', el.dataset.wfName === name);
10128
+ });
10129
+
10130
+ try {
10131
+ const res = await fetch('/api/workflows/' + encodeURIComponent(name));
10132
+ const data = await res.json();
10133
+ wfState.activeWorkflow = data.definition;
10134
+ wfState.builderMode = false;
10135
+ wfState.selectedNodeId = null;
10136
+ wfState.executionState = {};
10137
+ wfState.executionResults = {};
10138
+ wfSetToolbarEnabled(true);
10139
+ document.getElementById('wfCanvasEmpty').style.display = 'none';
10140
+ await wfRenderWorkflow(data.definition);
10141
+ wfSwitchLibTab('library');
10142
+ wfOpenInspector();
10143
+ wfUpdateInspector();
10144
+ } catch (err) {
10145
+ console.error('Failed to load workflow:', err);
10146
+ }
10147
+ }
10148
+
10149
+ // ── Load workflow from file ──
10150
+ function wfLoadFromFile() {
10151
+ document.getElementById('wfFileInput').click();
10152
+ }
10153
+
10154
+ function wfHandleFileLoad(event) {
10155
+ const file = event.target.files[0];
10156
+ if (!file) return;
10157
+ const reader = new FileReader();
10158
+ reader.onload = async (e) => {
10159
+ try {
10160
+ const definition = JSON.parse(e.target.result);
10161
+ if (!definition.steps || !Array.isArray(definition.steps)) {
10162
+ alert('Invalid workflow: missing "steps" array');
10163
+ return;
10164
+ }
10165
+ // Validate via server
10166
+ const valRes = await fetch('/api/workflows/validate', {
10167
+ method: 'POST',
10168
+ headers: { 'Content-Type': 'application/json' },
10169
+ body: JSON.stringify({ definition }),
10170
+ });
10171
+ const valData = await valRes.json();
10172
+ if (!valData.valid) {
10173
+ alert('Workflow validation failed:\n' + (valData.errors || []).join('\n'));
10174
+ return;
10175
+ }
10176
+ // Deselect any library item
10177
+ document.querySelectorAll('.wf-library-item.active').forEach(el => el.classList.remove('active'));
10178
+ // Load the workflow
10179
+ wfState.activeWorkflow = definition;
10180
+ wfState.selectedNodeId = null;
10181
+ wfState.executionState = {};
10182
+ wfState.executionResults = {};
10183
+ wfSetToolbarEnabled(true);
10184
+ document.getElementById('wfCanvasEmpty').style.display = 'none';
10185
+ wfHideExecStatus();
10186
+ await wfRenderWorkflow(definition);
10187
+ wfOpenInspector();
10188
+ wfUpdateInspector();
10189
+ } catch (err) {
10190
+ alert('Failed to parse workflow file: ' + err.message);
10191
+ }
10192
+ };
10193
+ reader.readAsText(file);
10194
+ // Reset file input so the same file can be re-loaded
10195
+ event.target.value = '';
10196
+ }
10197
+
10198
+ // ── DAG Layout + SVG Rendering ──
10199
+ async function wfRenderWorkflow(definition) {
10200
+ const svg = document.getElementById('wf-canvas');
10201
+ // Clear previous nodes and edges (keep defs)
10202
+ svg.querySelectorAll('.wf-node, .wf-edge-group').forEach(el => el.remove());
10203
+
10204
+ // Get execution plan from server
10205
+ let layers, graph;
10206
+ try {
10207
+ const res = await fetch('/api/workflows/plan', {
10208
+ method: 'POST',
10209
+ headers: { 'Content-Type': 'application/json' },
10210
+ body: JSON.stringify({ definition }),
10211
+ });
10212
+ const data = await res.json();
10213
+ layers = data.layers;
10214
+ graph = data.graph;
10215
+ } catch (err) {
10216
+ console.error('Failed to get execution plan:', err);
10217
+ return;
10218
+ }
10219
+
10220
+ wfState.layers = layers;
10221
+ wfState.graph = graph;
10222
+
10223
+ // Build step lookup
10224
+ const stepMap = {};
10225
+ definition.steps.forEach(s => { stepMap[s.id] = s; });
10226
+
10227
+ // Calculate positions
10228
+ const positions = {};
10229
+ const maxLayerSize = Math.max(...layers.map(l => l.length));
10230
+ const totalW = layers.length * WF_LAYER_GAP;
10231
+ const totalH = maxLayerSize * (WF_NODE_H + WF_NODE_GAP);
10232
+
10233
+ layers.forEach((layer, li) => {
10234
+ const x = WF_PAD + li * WF_LAYER_GAP;
10235
+ const layerH = layer.length * WF_NODE_H + (layer.length - 1) * WF_NODE_GAP;
10236
+ const startY = WF_PAD + (totalH - layerH) / 2;
10237
+ layer.forEach((stepId, ni) => {
10238
+ positions[stepId] = {
10239
+ x,
10240
+ y: startY + ni * (WF_NODE_H + WF_NODE_GAP),
10241
+ };
10242
+ });
10243
+ });
10244
+ wfState.nodePositions = positions;
10245
+
10246
+ // Build port-visibility maps: which nodes have input deps, which have dependents
10247
+ const nodeHasDeps = {}; // node has incoming edges (show input port)
10248
+ const nodeHasDependents = {}; // node has outgoing edges (show output port)
10249
+ for (const [stepId, deps] of Object.entries(graph)) {
10250
+ if (!deps || !Array.isArray(deps)) continue;
10251
+ deps.forEach(rawDepId => {
10252
+ const depId = rawDepId.replace(/^!/, '');
10253
+ if (positions[depId] && positions[stepId]) {
10254
+ nodeHasDeps[stepId] = true;
10255
+ nodeHasDependents[depId] = true;
10256
+ }
10257
+ });
10258
+ }
10259
+
10260
+ // Draw edges first (behind nodes)
10261
+ const edgeGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
10262
+ edgeGroup.classList.add('wf-edge-group');
10263
+ for (const [stepId, deps] of Object.entries(graph)) {
10264
+ if (!deps || !Array.isArray(deps)) continue;
10265
+ deps.forEach(rawDepId => {
10266
+ // Strip negation prefix (e.g., "!similarity_check" -> "similarity_check")
10267
+ const depId = rawDepId.replace(/^!/, '');
10268
+ if (positions[depId] && positions[stepId]) {
10269
+ const edge = wfDrawEdge(depId, stepId, positions);
10270
+ edgeGroup.appendChild(edge);
10271
+ }
10272
+ });
10273
+ }
10274
+ svg.appendChild(edgeGroup);
10275
+
10276
+ // Draw nodes
10277
+ for (const step of definition.steps) {
10278
+ const pos = positions[step.id];
10279
+ if (!pos) continue;
10280
+ const state = wfState.executionState[step.id] || 'idle';
10281
+ const hasDeps = !!nodeHasDeps[step.id];
10282
+ const hasDependents = !!nodeHasDependents[step.id];
10283
+ const nodeGroup = wfDrawNode(step, pos.x, pos.y, state, hasDeps, hasDependents);
10284
+ svg.appendChild(nodeGroup);
10285
+ }
10286
+
10287
+ // Set viewBox to fit
10288
+ wfFitToView();
10289
+ }
10290
+
10291
+ function wfDrawNode(step, x, y, state, hasDeps, hasDependents) {
10292
+ const meta = WF_NODE_META[step.tool] || { icon: WF_FALLBACK_ICON, label: step.tool, color: '#666', category: 'unknown' };
10293
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
10294
+ g.classList.add('wf-node');
10295
+ if (state !== 'idle') g.classList.add('wf-node--' + state);
10296
+ if (wfState.selectedNodeId === step.id) g.classList.add('selected');
10297
+ g.dataset.stepId = step.id;
10298
+ g.setAttribute('transform', `translate(${x}, ${y})`);
10299
+ g.addEventListener('click', (e) => {
10300
+ e.stopPropagation();
10301
+ wfSelectNode(step.id);
10302
+ });
10303
+
10304
+ // Builder: node drag
10305
+ if (wfState.builderMode) {
10306
+ g.style.cursor = 'move';
10307
+ g.addEventListener('mousedown', (e) => {
10308
+ // Don't drag if clicking a port
10309
+ if (e.target.classList.contains('wf-port')) return;
10310
+ e.stopPropagation();
10311
+ const svg = document.getElementById('wf-canvas');
10312
+ const rect = svg.getBoundingClientRect();
10313
+ const startSvgX = (e.clientX - rect.left) / wfState.zoom + wfState.panX;
10314
+ const startSvgY = (e.clientY - rect.top) / wfState.zoom + wfState.panY;
10315
+ const pos = wfState.nodePositions[step.id];
10316
+ if (!pos) return;
10317
+ const offX = startSvgX - pos.x;
10318
+ const offY = startSvgY - pos.y;
10319
+ wfState.dragNode = step.id;
10320
+
10321
+ function onMove(ev) {
10322
+ const mx = (ev.clientX - rect.left) / wfState.zoom + wfState.panX;
10323
+ const my = (ev.clientY - rect.top) / wfState.zoom + wfState.panY;
10324
+ wfState.nodePositions[step.id] = { x: mx - offX, y: my - offY };
10325
+ wfRefreshNodes();
10326
+ }
10327
+ function onUp() {
10328
+ wfState.dragNode = null;
10329
+ document.removeEventListener('mousemove', onMove);
10330
+ document.removeEventListener('mouseup', onUp);
10331
+ }
10332
+ document.addEventListener('mousemove', onMove);
10333
+ document.addEventListener('mouseup', onUp);
10334
+ });
10335
+ }
10336
+
10337
+ // Background rect
10338
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
10339
+ rect.setAttribute('width', WF_NODE_W);
10340
+ rect.setAttribute('height', WF_NODE_H);
10341
+ rect.setAttribute('fill', meta.color);
10342
+ rect.setAttribute('stroke', meta.color);
10343
+ rect.setAttribute('opacity', '0.85');
10344
+ g.appendChild(rect);
10345
+
10346
+ // Input port (left side): show in builder mode always, otherwise only if has deps
10347
+ const showInPort = wfState.builderMode || hasDeps;
10348
+ if (showInPort) {
10349
+ const inPort = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
10350
+ inPort.classList.add('wf-port', 'wf-port-in');
10351
+ if (wfState.builderMode) inPort.classList.add('wf-port-builder');
10352
+ inPort.setAttribute('cx', 0);
10353
+ inPort.setAttribute('cy', WF_NODE_H / 2);
10354
+ inPort.setAttribute('r', wfState.builderMode ? 7 : WF_PORT_R);
10355
+ inPort.setAttribute('stroke', meta.color);
10356
+ inPort.setAttribute('stroke-width', '2');
10357
+ inPort.setAttribute('pointer-events', 'all');
10358
+ if (wfState.builderMode) {
10359
+ inPort.addEventListener('mouseup', () => {
10360
+ if (wfState.draggingEdge) wfEdgeDropOnInput(step.id);
10361
+ });
10362
+ }
10363
+ g.appendChild(inPort);
10364
+ }
10365
+
10366
+ // Output port (right side): show in builder mode always, otherwise only if has dependents
10367
+ const showOutPort = wfState.builderMode || hasDependents;
10368
+ if (showOutPort) {
10369
+ const outPort = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
10370
+ outPort.classList.add('wf-port', 'wf-port-out');
10371
+ if (wfState.builderMode) outPort.classList.add('wf-port-builder');
10372
+ outPort.setAttribute('cx', WF_NODE_W);
10373
+ outPort.setAttribute('cy', WF_NODE_H / 2);
10374
+ outPort.setAttribute('r', wfState.builderMode ? 7 : WF_PORT_R);
10375
+ outPort.setAttribute('fill', meta.color);
10376
+ outPort.setAttribute('stroke-width', '2');
10377
+ outPort.setAttribute('pointer-events', 'all');
10378
+ if (wfState.builderMode) {
10379
+ outPort.addEventListener('mousedown', (e) => {
10380
+ e.stopPropagation();
10381
+ const pos = wfState.nodePositions[step.id];
10382
+ if (!pos) return;
10383
+ wfEdgeDragStart(step.id, pos.x + WF_NODE_W, pos.y + WF_NODE_H / 2);
10384
+ });
10385
+ }
10386
+ g.appendChild(outPort);
10387
+ }
10388
+
10389
+ // Icon (Lucide SVG)
10390
+ const iconG = document.createElementNS('http://www.w3.org/2000/svg', 'g');
10391
+ iconG.classList.add('wf-node-icon');
10392
+ const iconSize = 18;
10393
+ const ix = 22 - iconSize / 2;
10394
+ const iy = WF_NODE_H / 2 - iconSize / 2;
10395
+ iconG.setAttribute('transform', `translate(${ix},${iy}) scale(${iconSize / 24})`);
10396
+ const fullD = meta.icon || WF_FALLBACK_ICON;
10397
+ const iconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
10398
+ iconPath.setAttribute('d', fullD);
10399
+ iconPath.setAttribute('fill', 'none');
10400
+ iconPath.setAttribute('stroke', 'currentColor');
10401
+ iconPath.setAttribute('stroke-width', '1.75');
10402
+ iconPath.setAttribute('stroke-linecap', 'round');
10403
+ iconPath.setAttribute('stroke-linejoin', 'round');
10404
+ iconG.appendChild(iconPath);
10405
+ g.appendChild(iconG);
10406
+
10407
+ // Label (step name, truncated)
10408
+ const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10409
+ label.classList.add('wf-node-label');
10410
+ label.setAttribute('x', WF_NODE_W / 2 + 10);
10411
+ label.setAttribute('y', WF_NODE_H / 2 - 7);
10412
+ const maxChars = 20;
10413
+ const displayName = (step.name || step.id).length > maxChars
10414
+ ? (step.name || step.id).slice(0, maxChars - 2) + '..'
10415
+ : (step.name || step.id);
10416
+ label.textContent = displayName;
10417
+ g.appendChild(label);
10418
+
10419
+ // Tool badge
10420
+ const badge = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10421
+ badge.classList.add('wf-node-badge');
10422
+ badge.setAttribute('x', WF_NODE_W / 2 + 10);
10423
+ badge.setAttribute('y', WF_NODE_H / 2 + 10);
10424
+ badge.textContent = meta.label;
10425
+ g.appendChild(badge);
10426
+
10427
+ // Condition indicator
10428
+ if (step.condition) {
10429
+ const cond = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10430
+ cond.classList.add('wf-node-condition');
10431
+ cond.setAttribute('x', WF_NODE_W - 8);
10432
+ cond.setAttribute('y', 14);
10433
+ cond.textContent = '\u26A1';
10434
+ g.appendChild(cond);
10435
+ }
10436
+
10437
+ // Status overlay (for execution)
10438
+ if (state === 'completed') {
10439
+ const check = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10440
+ check.classList.add('wf-node-status');
10441
+ check.setAttribute('x', WF_NODE_W - 18);
10442
+ check.setAttribute('y', WF_NODE_H / 2);
10443
+ check.textContent = '\u2713';
10444
+ g.appendChild(check);
10445
+ } else if (state === 'error') {
10446
+ const errIcon = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10447
+ errIcon.classList.add('wf-node-status');
10448
+ errIcon.setAttribute('x', WF_NODE_W - 18);
10449
+ errIcon.setAttribute('y', WF_NODE_H / 2);
10450
+ errIcon.textContent = '\u2717';
10451
+ g.appendChild(errIcon);
10452
+ } else if (state === 'skipped') {
10453
+ const skip = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10454
+ skip.classList.add('wf-node-status');
10455
+ skip.setAttribute('x', WF_NODE_W - 18);
10456
+ skip.setAttribute('y', WF_NODE_H / 2);
10457
+ skip.textContent = '\u2500';
10458
+ g.appendChild(skip);
10459
+ }
10460
+
10461
+ // Time badge (if completed)
10462
+ const result = wfState.executionResults[step.id];
10463
+ if (result && result.timeMs !== undefined) {
10464
+ const time = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10465
+ time.classList.add('wf-node-time');
10466
+ time.setAttribute('x', WF_NODE_W / 2);
10467
+ time.setAttribute('y', WF_NODE_H + 16);
10468
+ time.textContent = result.timeMs + 'ms';
10469
+ g.appendChild(time);
10470
+ }
10471
+
10472
+ return g;
10473
+ }
10474
+
10475
+ function wfDrawEdge(fromId, toId, positions) {
10476
+ const from = positions[fromId];
10477
+ const to = positions[toId];
10478
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
10479
+ g.classList.add('wf-edge-group-item');
10480
+ g.dataset.from = fromId;
10481
+ g.dataset.to = toId;
10482
+
10483
+ // Port positions: output port on right side of source, input port on left side of target
10484
+ const x1 = from.x + WF_NODE_W;
10485
+ const y1 = from.y + WF_NODE_H / 2;
10486
+ const x2 = to.x;
10487
+ const y2 = to.y + WF_NODE_H / 2;
10488
+
10489
+ // Detect backward edges (target is same column or to the left)
10490
+ const isBackward = x2 <= x1;
10491
+
10492
+ let d;
10493
+ if (isBackward) {
10494
+ // Route backward edges: go down from output, loop under/over, come in from left of target
10495
+ const midY = Math.max(from.y + WF_NODE_H, to.y + WF_NODE_H) + 40;
10496
+ d = `M ${x1} ${y1} C ${x1 + 50} ${y1}, ${x1 + 50} ${midY}, ${(x1 + x2) / 2} ${midY} C ${x2 - 50} ${midY}, ${x2 - 50} ${y2}, ${x2} ${y2}`;
10497
+ } else {
10498
+ // Normal left-to-right bezier
10499
+ const dx = Math.abs(x2 - x1);
10500
+ const tension = Math.max(dx * 0.4, 40);
10501
+ d = `M ${x1} ${y1} C ${x1 + tension} ${y1}, ${x2 - tension} ${y2}, ${x2} ${y2}`;
10502
+ }
10503
+
10504
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
10505
+ path.classList.add('wf-edge');
10506
+ if (isBackward) path.classList.add('wf-edge--backward');
10507
+ path.setAttribute('d', d);
10508
+
10509
+ // Set state-based classes
10510
+ const fromState = wfState.executionState[fromId];
10511
+ const toState = wfState.executionState[toId];
10512
+ if (toState === 'running') path.classList.add('wf-edge--active');
10513
+ else if (fromState === 'completed' && (toState === 'completed' || toState === 'skipped')) path.classList.add('wf-edge--complete');
10514
+
10515
+ g.appendChild(path);
10516
+ return g;
10517
+ }
10518
+
10519
+ // ── Inspector Toggle ──
10520
+ function wfToggleInspector() {
10521
+ const panel = document.getElementById('wfInspector');
10522
+ const btn = document.getElementById('wfInspectorToggle');
10523
+ if (!panel) return;
10524
+ panel.classList.toggle('collapsed');
10525
+ if (btn) btn.innerHTML = panel.classList.contains('collapsed') ? '&lsaquo;' : '&rsaquo;';
10526
+ }
10527
+
10528
+ function wfOpenInspector() {
10529
+ const panel = document.getElementById('wfInspector');
10530
+ const btn = document.getElementById('wfInspectorToggle');
10531
+ if (!panel || !panel.classList.contains('collapsed')) return;
10532
+ panel.classList.remove('collapsed');
10533
+ if (btn) btn.innerHTML = '&rsaquo;';
10534
+ }
10535
+
10536
+ // ── Node Selection ──
10537
+ function wfSelectNode(stepId) {
10538
+ wfState.selectedNodeId = stepId;
10539
+ // Update visual selection
10540
+ document.querySelectorAll('.wf-node').forEach(n => {
10541
+ n.classList.toggle('selected', n.dataset.stepId === stepId);
10542
+ });
10543
+ wfOpenInspector();
10544
+ wfUpdateInspector();
10545
+ }
10546
+
10547
+ function wfDeselectNode() {
10548
+ wfState.selectedNodeId = null;
10549
+ document.querySelectorAll('.wf-node.selected').forEach(n => n.classList.remove('selected'));
10550
+ wfUpdateInspector();
10551
+ }
10552
+
10553
+ // ── Inspector ──
10554
+ function wfUpdateInspector() {
10555
+ const body = document.getElementById('wfInspectorBody');
10556
+ const header = document.getElementById('wfInspectorHeader');
10557
+ if (!body || !header) return;
10558
+
10559
+ const def = wfState.activeWorkflow;
10560
+ if (!def) {
10561
+ header.textContent = 'Inspector';
10562
+ body.innerHTML = '<div class="wf-inspector-empty">Select a workflow to view details</div>';
10563
+ return;
10564
+ }
10565
+
10566
+ try {
10567
+ if (!wfState.selectedNodeId) {
10568
+ // Show workflow-level info
10569
+ header.textContent = def.name || 'Workflow';
10570
+ let html = '';
10571
+
10572
+ if (wfState.builderMode) {
10573
+ // Builder: editable workflow fields
10574
+ html += `<div class="wf-inspector-section">
10575
+ <div class="wf-inspector-section-title">Name</div>
10576
+ <input class="wf-inspector-input" value="${escapeHtml(def.name || '')}" onchange="wfEditWorkflowField('name', this.value); document.getElementById('wfInspectorHeader').textContent = this.value || 'Workflow';">
10577
+ </div>`;
10578
+ html += `<div class="wf-inspector-section">
10579
+ <div class="wf-inspector-section-title">Description</div>
10580
+ <textarea class="wf-inspector-input" rows="2" style="resize:vertical;" onchange="wfEditWorkflowField('description', this.value)">${escapeHtml(def.description || '')}</textarea>
10581
+ </div>`;
10582
+ html += `<div class="wf-inspector-section">
10583
+ <div class="wf-inspector-section-title">Steps</div>
10584
+ <div style="font-size:12px;color:var(--text);">${def.steps.length} step${def.steps.length !== 1 ? 's' : ''}</div>
10585
+ </div>`;
10586
+ if (def.steps.length > 0) {
10587
+ html += `<div class="wf-inspector-section">
10588
+ <div class="wf-inspector-section-title">Output Mapping</div>
10589
+ <textarea class="wf-inspector-input" rows="3" style="resize:vertical;font-family:monospace;font-size:11px;" onchange="try { wfEditWorkflowField('output', JSON.parse(this.value)); } catch(e) {}">${escapeHtml(JSON.stringify(def.output || {}, null, 2))}</textarea>
10590
+ </div>`;
10591
+ }
10592
+ html += `<div class="wf-inspector-section" style="margin-top:8px;">
10593
+ <div style="font-size:10px;color:var(--text-muted);">Add steps from the Palette tab, then drag between ports to connect them.</div>
10594
+ </div>`;
10595
+ } else {
10596
+ // Read-only: Description
10597
+ if (def.description) {
10598
+ html += `<div class="wf-inspector-section">
10599
+ <div class="wf-inspector-section-title">Description</div>
10600
+ <div style="font-size:12px;color:var(--text);line-height:1.4;">${escapeHtml(def.description)}</div>
10601
+ </div>`;
10602
+ }
10603
+
10604
+ // Read-only: Inputs
10605
+ if (def.inputs && Object.keys(def.inputs).length > 0) {
10606
+ html += '<div class="wf-inspector-section"><div class="wf-inspector-section-title">Inputs</div>';
10607
+ for (const [key, spec] of Object.entries(def.inputs)) {
10608
+ const req = spec.required ? ' <span style="color:#e74c3c">*</span>' : '';
10609
+ const defVal = spec.default !== undefined ? ` (default: ${spec.default})` : '';
10610
+ html += `<div style="margin-bottom:8px;">
10611
+ <div style="font-size:12px;font-weight:600;color:var(--text);">${escapeHtml(key)}${req}</div>
10612
+ <div style="font-size:11px;color:var(--text-muted);">${escapeHtml(spec.description || spec.type || '')}${defVal}</div>
10613
+ <input class="wf-inspector-input" id="wf-input-${key}" placeholder="${escapeHtml(key)}" value="${spec.default !== undefined ? spec.default : ''}">
10614
+ </div>`;
10615
+ }
10616
+ html += '</div>';
10617
+ }
10618
+
10619
+ // Steps summary
10620
+ html += `<div class="wf-inspector-section">
10621
+ <div class="wf-inspector-section-title">Steps</div>
10622
+ <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>
10623
+ </div>`;
10624
+
10625
+ // Output mapping
10626
+ if (def.output) {
10627
+ html += `<div class="wf-inspector-section">
10628
+ <div class="wf-inspector-section-title">Output</div>
10629
+ <div class="wf-inspector-code">${escapeHtml(JSON.stringify(def.output, null, 2))}</div>
10630
+ </div>`;
10631
+ }
10632
+ }
10633
+
10634
+ // Execution result (shown in both modes)
10635
+ if (wfState.executionResults._done) {
10636
+ const r = wfState.executionResults._done;
10637
+ const doneJson = JSON.stringify(r.output, null, 2);
10638
+ html += `<div class="wf-inspector-section">
10639
+ <div class="wf-inspector-section-title">Result</div>
10640
+ <div class="wf-inspector-result success">
10641
+ <div style="font-weight:600;margin-bottom:4px;">Completed in ${r.totalTimeMs}ms</div>
10642
+ <div class="wf-inspector-code" style="max-height:150px;overflow:auto;">${escapeHtml(doneJson)}</div>
10643
+ </div>
10644
+ <button class="wf-output-expand-btn" data-expand-step="_done">&#x2922; Expand</button>
10645
+ </div>`;
10646
+ }
10647
+
10648
+ body.innerHTML = html;
10649
+ wfBindExpandButtons(body);
10650
+ return;
10651
+ }
10652
+
10653
+ // Show step details
10654
+ const step = def.steps.find(s => s.id === wfState.selectedNodeId);
10655
+ if (!step) {
10656
+ body.innerHTML = '<div class="wf-inspector-empty">Step not found: ' + escapeHtml(wfState.selectedNodeId) + '</div>';
10657
+ return;
10658
+ }
10659
+
10660
+ const meta = WF_NODE_META[step.tool] || { icon: WF_FALLBACK_ICON, label: step.tool, color: '#666' };
10661
+ header.textContent = step.name || step.id;
10662
+
10663
+ let html = '';
10664
+
10665
+ // Tool badge (always shown)
10666
+ html += `<div class="wf-inspector-section">
10667
+ <div class="wf-inspector-section-title">Tool</div>
10668
+ <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>
10669
+ </div>`;
10670
+
10671
+ if (wfState.builderMode) {
10672
+ // Builder: editable step fields
10673
+ const sid = escapeHtml(step.id);
10674
+ html += `<div class="wf-inspector-section">
10675
+ <div class="wf-inspector-section-title">Step ID</div>
10676
+ <input class="wf-inspector-input" value="${sid}" style="font-family:monospace;" onchange="wfEditStepId('${sid}', this.value)">
10677
+ </div>`;
10678
+ html += `<div class="wf-inspector-section">
10679
+ <div class="wf-inspector-section-title">Name</div>
10680
+ <input class="wf-inspector-input" value="${escapeHtml(step.name || '')}" onchange="wfEditStepField('${sid}', 'name', this.value)">
10681
+ </div>`;
10682
+
10683
+ // Inputs from WF_INPUT_DEFS
10684
+ const inputDefs = WF_INPUT_DEFS[step.tool] || [];
10685
+ if (inputDefs.length > 0) {
10686
+ html += `<div class="wf-inspector-section"><div class="wf-inspector-section-title">Inputs</div>`;
10687
+ for (const d of inputDefs) {
10688
+ const val = step.inputs?.[d.key] ?? '';
10689
+ const display = typeof val === 'string' ? val : JSON.stringify(val);
10690
+ const req = d.required ? ' <span style="color:#e74c3c;font-size:10px;">required</span>' : '';
10691
+ html += `<div style="margin-bottom:8px;">
10692
+ <div style="font-size:11px;font-weight:600;color:var(--text);">${escapeHtml(d.key)}${req}</div>`;
10693
+
10694
+ if (d.type === 'textarea' || d.type === 'json') {
10695
+ html += `<textarea class="wf-inspector-input" rows="2" style="resize:vertical;font-family:monospace;font-size:11px;" placeholder="${escapeHtml(d.placeholder || '')}" onchange="wfEditStepInput('${sid}','${d.key}',this.value)">${escapeHtml(display)}</textarea>`;
10696
+ } else if (d.type === 'select' && d.options) {
10697
+ html += `<select class="wf-inspector-input" onchange="wfEditStepInput('${sid}','${d.key}',this.value)">
10698
+ <option value="">--</option>`;
10699
+ for (const opt of d.options) {
10700
+ html += `<option value="${escapeHtml(opt)}" ${val === opt ? 'selected' : ''}>${escapeHtml(opt)}</option>`;
10701
+ }
10702
+ html += `</select>`;
10703
+ } else if (d.type === 'number') {
10704
+ html += `<input class="wf-inspector-input" type="number" value="${escapeHtml(String(val))}" placeholder="${escapeHtml(d.placeholder || '')}" onchange="wfEditStepInput('${sid}','${d.key}',this.value)">`;
10705
+ } else {
10706
+ html += `<input class="wf-inspector-input" value="${escapeHtml(display)}" placeholder="${escapeHtml(d.placeholder || '')}" onchange="wfEditStepInput('${sid}','${d.key}',this.value)">`;
10707
+ }
10708
+ html += `</div>`;
10709
+ }
10710
+ html += '</div>';
10711
+ }
10712
+
10713
+ // Condition
10714
+ html += `<div class="wf-inspector-section">
10715
+ <div class="wf-inspector-section-title">Condition <span style="font-size:10px;color:var(--text-muted)">(optional)</span></div>
10716
+ <input class="wf-inspector-input" value="${escapeHtml(step.condition || '')}" placeholder="e.g. results.length > 0" onchange="wfEditStepField('${sid}', 'condition', this.value || undefined)">
10717
+ </div>`;
10718
+
10719
+ // continueOnError toggle
10720
+ html += `<div class="wf-inspector-section" style="display:flex;align-items:center;gap:8px;">
10721
+ <input type="checkbox" id="wf-coe-${sid}" ${step.continueOnError ? 'checked' : ''} onchange="wfEditStepField('${sid}', 'continueOnError', this.checked)">
10722
+ <label for="wf-coe-${sid}" style="font-size:11px;color:var(--text);cursor:pointer;">Continue on error</label>
10723
+ </div>`;
10724
+
10725
+ // Delete button
10726
+ html += `<button class="wf-inspector-delete-btn" onclick="if(confirm('Delete step ${sid}?')) wfDeleteStep('${sid}')">Delete Step</button>`;
10727
+ } else {
10728
+ // Read-only step view
10729
+ html += `<div class="wf-inspector-section">
10730
+ <div class="wf-inspector-section-title">ID</div>
10731
+ <div style="font-size:12px;color:var(--text);font-family:monospace;">${escapeHtml(step.id)}</div>
10732
+ </div>`;
10733
+
10734
+ if (step.inputs) {
10735
+ html += `<div class="wf-inspector-section">
10736
+ <div class="wf-inspector-section-title">Inputs</div>`;
10737
+ for (const [key, val] of Object.entries(step.inputs)) {
10738
+ const display = typeof val === 'string' ? val : JSON.stringify(val);
10739
+ html += `<div class="wf-inspector-field">
10740
+ <span class="wf-inspector-field-label">${escapeHtml(key)}</span>
10741
+ <span class="wf-inspector-field-value" style="font-family:monospace;font-size:11px;">${escapeHtml(display)}</span>
10742
+ </div>`;
10743
+ }
10744
+ html += '</div>';
10745
+ }
10746
+
10747
+ if (step.condition) {
10748
+ html += `<div class="wf-inspector-section">
10749
+ <div class="wf-inspector-section-title">Condition \u26A1</div>
10750
+ <div class="wf-inspector-code">${escapeHtml(step.condition)}</div>
10751
+ </div>`;
10752
+ }
10753
+
10754
+ if (step.forEach) {
10755
+ html += `<div class="wf-inspector-section">
10756
+ <div class="wf-inspector-section-title">ForEach</div>
10757
+ <div class="wf-inspector-code">${escapeHtml(JSON.stringify(step.forEach, null, 2))}</div>
10758
+ </div>`;
10759
+ }
10760
+ }
10761
+
10762
+ // Execution result for this step (shown in both modes)
10763
+ const result = wfState.executionResults[step.id];
10764
+ const state = wfState.executionState[step.id];
10765
+ if (state === 'completed' && result) {
10766
+ const outputJson = result.output ? JSON.stringify(result.output, null, 2) : '';
10767
+ const stepTitle = step.name || step.id;
10768
+ html += `<div class="wf-inspector-section">
10769
+ <div class="wf-inspector-section-title">Result</div>
10770
+ <div class="wf-inspector-result success">
10771
+ <div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;">${result.timeMs}ms${result.summary ? ', ' + result.summary : ''}</div>
10772
+ ${outputJson ? '<div class="wf-inspector-code" style="max-height:120px;overflow:auto;">' + escapeHtml(outputJson) + '</div>' : ''}
10773
+ </div>
10774
+ ${outputJson ? '<button class="wf-output-expand-btn" data-expand-step="' + escapeHtml(step.id) + '">&#x2922; Expand</button>' : ''}
10775
+ </div>`;
10776
+ } else if (state === 'error' && result) {
10777
+ html += `<div class="wf-inspector-section">
10778
+ <div class="wf-inspector-section-title">Error</div>
10779
+ <div class="wf-inspector-result error">
10780
+ <div style="font-size:12px;margin-bottom:4px;">${escapeHtml(result.error || 'Unknown error')}</div>
10781
+ </div>
10782
+ </div>`;
10783
+ } else if (state === 'skipped' && result) {
10784
+ html += `<div class="wf-inspector-section">
10785
+ <div class="wf-inspector-section-title">Skipped</div>
10786
+ <div style="font-size:12px;color:var(--text-muted);">${escapeHtml(result.reason || 'Condition not met')}</div>
10787
+ </div>`;
10788
+ }
10789
+
10790
+ body.innerHTML = html;
10791
+ wfBindExpandButtons(body);
10792
+ } catch (err) {
10793
+ console.error('Inspector render error:', err);
10794
+ body.innerHTML = '<div class="wf-inspector-empty" style="color:#e74c3c;">Error rendering inspector: ' + escapeHtml(err.message) + '</div>';
10795
+ }
10796
+ }
10797
+
10798
+ function wfBindExpandButtons(container) {
10799
+ container.querySelectorAll('.wf-output-expand-btn[data-expand-step]').forEach(btn => {
10800
+ btn.addEventListener('click', () => {
10801
+ const stepId = btn.dataset.expandStep;
10802
+ const result = wfState.executionResults[stepId];
10803
+ if (!result) return;
10804
+ let title, content;
10805
+ if (stepId === '_done') {
10806
+ title = 'Workflow Output';
10807
+ content = JSON.stringify(result.output, null, 2);
10808
+ } else {
10809
+ const def = wfState.activeWorkflow;
10810
+ const step = def ? def.steps.find(s => s.id === stepId) : null;
10811
+ title = (step ? step.name || step.id : stepId) + ' Output';
10812
+ content = JSON.stringify(result.output, null, 2);
10813
+ }
10814
+ wfOpenOutputModal(title, content);
10815
+ });
10816
+ });
10817
+ }
10818
+
10819
+ // escapeHtml is already defined globally — reuse it
10820
+
10821
+ // ── Toolbar helpers ──
10822
+ function wfSetToolbarEnabled(enabled) {
10823
+ document.getElementById('wfRunBtn').disabled = !enabled;
10824
+ document.getElementById('wfDryRunBtn').disabled = !enabled;
10825
+ document.getElementById('wfExportBtn').disabled = !enabled;
10826
+ document.getElementById('wfEditBtn').disabled = !enabled;
10827
+ }
10828
+
10829
+ // ── Export workflow JSON ──
10830
+ function wfExportJson() {
10831
+ const def = wfState.activeWorkflow;
10832
+ if (!def) return;
10833
+ const json = JSON.stringify(def, null, 2);
10834
+ const name = (def.name || 'workflow').replace(/\s+/g, '-').toLowerCase();
10835
+ const blob = new Blob([json], { type: 'application/json' });
10836
+ const url = URL.createObjectURL(blob);
10837
+ const a = document.createElement('a');
10838
+ a.href = url;
10839
+ a.download = name + '.vai-workflow.json';
10840
+ document.body.appendChild(a);
10841
+ a.click();
10842
+ document.body.removeChild(a);
10843
+ URL.revokeObjectURL(url);
10844
+ }
10845
+
10846
+ // ── Dry Run ──
10847
+ function wfDryRun() {
10848
+ const def = wfState.activeWorkflow;
10849
+ if (!def || !wfState.layers) return;
10850
+
10851
+ const layers = wfState.layers;
10852
+ const stepMap = {};
10853
+ def.steps.forEach(s => { stepMap[s.id] = s; });
10854
+
10855
+ // Build layer HTML
10856
+ let layersHtml = '';
10857
+ let totalSteps = 0;
10858
+ let conditionalSteps = 0;
10859
+
10860
+ layers.forEach((layer, i) => {
10861
+ const parallel = layer.length > 1 ? ' (parallel)' : '';
10862
+ layersHtml += '<div class="wf-dryrun-layer">';
10863
+ layersHtml += '<div class="wf-dryrun-layer-title">';
10864
+ layersHtml += '<span class="wf-dryrun-layer-badge">' + (i + 1) + '</span>';
10865
+ layersHtml += ' Layer ' + (i + 1) + parallel;
10866
+ layersHtml += '</div>';
10867
+
10868
+ layer.forEach(stepId => {
10869
+ const step = stepMap[stepId];
10870
+ if (!step) return;
10871
+ totalSteps++;
10872
+ const meta = WF_NODE_META[step.tool] || { icon: WF_FALLBACK_ICON, label: step.tool, color: '#666' };
10873
+ layersHtml += '<div class="wf-dryrun-step">';
10874
+ layersHtml += '<span class="wf-dryrun-step-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="' + (meta.icon || WF_FALLBACK_ICON) + '"/></svg></span>';
10875
+ layersHtml += '<div class="wf-dryrun-step-info">';
10876
+ layersHtml += '<div class="wf-dryrun-step-name">' + escapeHtml(step.name || step.id) + '</div>';
10877
+ layersHtml += '<div class="wf-dryrun-step-tool">' + escapeHtml(meta.label) + ' (' + escapeHtml(step.id) + ')</div>';
10878
+ if (step.condition) {
10879
+ conditionalSteps++;
10880
+ layersHtml += '<div class="wf-dryrun-step-cond">\u26A1 ' + escapeHtml(step.condition) + '</div>';
10881
+ }
10882
+ layersHtml += '</div></div>';
10883
+ });
10884
+
10885
+ layersHtml += '</div>';
10886
+ });
10887
+
10888
+ // Summary stats
10889
+ const parallelLayers = layers.filter(l => l.length > 1).length;
10890
+ let summaryHtml = '<div class="wf-dryrun-summary">';
10891
+ summaryHtml += '<div class="wf-dryrun-stat"><div class="wf-dryrun-stat-value">' + totalSteps + '</div><div class="wf-dryrun-stat-label">Steps</div></div>';
10892
+ summaryHtml += '<div class="wf-dryrun-stat"><div class="wf-dryrun-stat-value">' + layers.length + '</div><div class="wf-dryrun-stat-label">Layers</div></div>';
10893
+ summaryHtml += '<div class="wf-dryrun-stat"><div class="wf-dryrun-stat-value">' + parallelLayers + '</div><div class="wf-dryrun-stat-label">Parallel</div></div>';
10894
+ if (conditionalSteps > 0) {
10895
+ summaryHtml += '<div class="wf-dryrun-stat"><div class="wf-dryrun-stat-value">' + conditionalSteps + '</div><div class="wf-dryrun-stat-label">Conditional</div></div>';
10896
+ }
10897
+ summaryHtml += '</div>';
10898
+
10899
+ // Inputs summary
10900
+ let inputsHtml = '';
10901
+ if (def.inputs && Object.keys(def.inputs).length > 0) {
10902
+ inputsHtml += '<div style="margin-bottom:12px;font-size:12px;">';
10903
+ inputsHtml += '<div style="font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);margin-bottom:6px;">Required Inputs</div>';
10904
+ for (const [key, spec] of Object.entries(def.inputs)) {
10905
+ const req = spec.required ? ' *' : '';
10906
+ inputsHtml += '<div style="margin-bottom:2px;"><span style="font-weight:600;color:var(--text);">' + escapeHtml(key) + req + '</span>';
10907
+ inputsHtml += ' <span style="color:var(--text-muted);">(' + escapeHtml(spec.type || 'string') + ')</span></div>';
10908
+ }
10909
+ inputsHtml += '</div>';
10910
+ }
10911
+
10912
+ // Create overlay
10913
+ const canvasArea = document.querySelector('.wf-canvas-area');
10914
+ // Remove any existing overlay
10915
+ canvasArea.querySelectorAll('.wf-dryrun-overlay').forEach(el => el.remove());
10916
+
10917
+ const overlay = document.createElement('div');
10918
+ overlay.className = 'wf-dryrun-overlay';
10919
+ overlay.innerHTML = '<div class="wf-dryrun-panel">' +
10920
+ '<div class="wf-dryrun-header">' +
10921
+ '<span class="wf-dryrun-title">Execution Plan: ' + escapeHtml(def.name || 'Workflow') + '</span>' +
10922
+ '<button class="wf-dryrun-close" onclick="wfCloseDryRun()" title="Close">&times;</button>' +
10923
+ '</div>' +
10924
+ '<div class="wf-dryrun-body">' +
10925
+ inputsHtml +
10926
+ summaryHtml +
10927
+ '<div style="margin-top:16px;">' + layersHtml + '</div>' +
10928
+ '</div>' +
10929
+ '</div>';
10930
+
10931
+ // Click backdrop to close
10932
+ overlay.addEventListener('click', (e) => {
10933
+ if (e.target === overlay) wfCloseDryRun();
10934
+ });
10935
+
10936
+ canvasArea.appendChild(overlay);
10937
+ }
10938
+
10939
+ function wfCloseDryRun() {
10940
+ document.querySelectorAll('.wf-dryrun-overlay').forEach(el => el.remove());
10941
+ }
10942
+
10943
+ // ── Execution ──
10944
+ let wfAbortController = null;
10945
+ let wfExecTimer = null;
10946
+ let wfExecStartTime = 0;
10947
+ const WF_EXEC_TIMEOUT_MS = 120000; // 2 minute total timeout
10948
+
10949
+ function wfShowExecStatus(text, state) {
10950
+ const bar = document.getElementById('wfExecStatus');
10951
+ const textEl = document.getElementById('wfExecStatusText');
10952
+ if (!bar || !textEl) return;
10953
+ bar.style.display = 'flex';
10954
+ bar.className = 'wf-exec-status' + (state ? ' ' + state : '');
10955
+ textEl.textContent = text;
10956
+ }
10957
+
10958
+ function wfHideExecStatus() {
10959
+ const bar = document.getElementById('wfExecStatus');
10960
+ if (bar) bar.style.display = 'none';
10961
+ }
10962
+
10963
+ function wfUpdateExecTimer() {
10964
+ const timeEl = document.getElementById('wfExecStatusTime');
10965
+ if (!timeEl || !wfState.executing) return;
10966
+ const elapsed = Date.now() - wfExecStartTime;
10967
+ const secs = (elapsed / 1000).toFixed(1);
10968
+ timeEl.textContent = secs + 's';
10969
+
10970
+ // Check timeout
10971
+ if (elapsed > WF_EXEC_TIMEOUT_MS) {
10972
+ wfStopExecution('Execution timed out after ' + (WF_EXEC_TIMEOUT_MS / 1000) + 's');
10973
+ return;
10974
+ }
10975
+ wfExecTimer = requestAnimationFrame(wfUpdateExecTimer);
10976
+ }
10977
+
10978
+ function wfStopExecution(reason) {
10979
+ if (!wfState.executing) return;
10980
+ if (wfAbortController) {
10981
+ wfAbortController.abort();
10982
+ wfAbortController = null;
10983
+ }
10984
+ if (wfExecTimer) {
10985
+ cancelAnimationFrame(wfExecTimer);
10986
+ wfExecTimer = null;
10987
+ }
10988
+
10989
+ // Mark any still-running nodes as error
10990
+ const def = wfState.activeWorkflow;
10991
+ if (def) {
10992
+ def.steps.forEach(s => {
10993
+ if (wfState.executionState[s.id] === 'running') {
10994
+ wfState.executionState[s.id] = 'error';
10995
+ wfState.executionResults[s.id] = { error: reason || 'Stopped by user' };
10996
+ } else if (wfState.executionState[s.id] === 'pending') {
10997
+ wfState.executionState[s.id] = 'skipped';
10998
+ wfState.executionResults[s.id] = { reason: reason || 'Stopped by user' };
10999
+ }
11000
+ });
11001
+ wfRefreshNodes();
11002
+ }
11003
+
11004
+ const stopReason = reason || 'Stopped by user';
11005
+ wfShowExecStatus(stopReason, reason && reason.includes('timed out') ? 'error' : 'stopped');
11006
+ wfState.executing = false;
11007
+ wfSetToolbarEnabled(true);
11008
+ document.getElementById('wfStopBtn').style.display = 'none';
11009
+ wfUpdateInspector();
11010
+ }
11011
+
11012
+ // ── Input Modal (pre-execution) ──
11013
+
11014
+ function wfShowInputModal() {
11015
+ const def = wfState.activeWorkflow;
11016
+ if (!def || !def.inputs) return;
11017
+ const entries = Object.entries(def.inputs);
11018
+ if (entries.length === 0) { wfExecuteWithInputs({}); return; }
11019
+
11020
+ document.getElementById('wfInputModalTitle').textContent = (def.name || 'Workflow') + ' Inputs';
11021
+ let html = '';
11022
+ for (const [key, spec] of entries) {
11023
+ const req = spec.required ? ' <span style="color:#e74c3c">*</span>' : '';
11024
+ const desc = spec.description ? `<div class="wf-input-modal-desc">${escapeHtml(spec.description)}</div>` : '';
11025
+ // Pre-fill from inspector fields if available, then from defaults
11026
+ const inspectorEl = document.getElementById('wf-input-' + key);
11027
+ let prefill = inspectorEl ? inspectorEl.value : '';
11028
+ if (!prefill && spec.default !== undefined) prefill = String(spec.default);
11029
+ const placeholder = spec.type === 'number' ? 'number' : (spec.type || 'string');
11030
+ html += `<div class="wf-input-modal-field">
11031
+ <div class="wf-input-modal-label">${escapeHtml(key)}${req}</div>
11032
+ ${desc}
11033
+ <input class="wf-input-modal-input" id="wf-modal-input-${key}" placeholder="${escapeHtml(placeholder)}" value="${escapeHtml(prefill)}" data-key="${escapeHtml(key)}" data-type="${spec.type || 'string'}" data-required="${!!spec.required}">
11034
+ <div class="wf-input-modal-error" id="wf-modal-err-${key}">This field is required</div>
11035
+ </div>`;
11036
+ }
11037
+ document.getElementById('wfInputModalBody').innerHTML = html;
11038
+ document.getElementById('wfInputModalBackdrop').style.display = '';
11039
+
11040
+ // Focus first empty required field, or first field
11041
+ const firstEmpty = entries.find(([k, s]) => {
11042
+ const el = document.getElementById('wf-modal-input-' + k);
11043
+ return s.required && el && !el.value;
11044
+ });
11045
+ const focusKey = firstEmpty ? firstEmpty[0] : entries[0][0];
11046
+ const focusEl = document.getElementById('wf-modal-input-' + focusKey);
11047
+ if (focusEl) setTimeout(() => focusEl.focus(), 50);
11048
+ }
11049
+
11050
+ function wfCloseInputModal() {
11051
+ document.getElementById('wfInputModalBackdrop').style.display = 'none';
11052
+ }
11053
+
11054
+ function wfInputModalSubmit() {
11055
+ const def = wfState.activeWorkflow;
11056
+ if (!def || !def.inputs) return;
11057
+
11058
+ const inputs = {};
11059
+ let hasError = false;
11060
+
11061
+ for (const [key, spec] of Object.entries(def.inputs)) {
11062
+ const el = document.getElementById('wf-modal-input-' + key);
11063
+ const errEl = document.getElementById('wf-modal-err-' + key);
11064
+ if (!el) continue;
11065
+
11066
+ let val = el.value.trim();
11067
+
11068
+ // Clear previous error
11069
+ el.classList.remove('error');
11070
+ if (errEl) errEl.style.display = 'none';
11071
+
11072
+ // Required check
11073
+ if (!val && spec.required && spec.default === undefined) {
11074
+ el.classList.add('error');
11075
+ if (errEl) { errEl.textContent = 'This field is required'; errEl.style.display = ''; }
11076
+ hasError = true;
11077
+ continue;
11078
+ }
11079
+
11080
+ // Use default if empty
11081
+ if (!val && spec.default !== undefined) val = String(spec.default);
11082
+
11083
+ // Type validation
11084
+ if (spec.type === 'number' && val && isNaN(Number(val))) {
11085
+ el.classList.add('error');
11086
+ if (errEl) { errEl.textContent = 'Must be a number'; errEl.style.display = ''; }
11087
+ hasError = true;
11088
+ continue;
11089
+ }
11090
+
11091
+ // Coerce
11092
+ if (spec.type === 'number' && val) val = Number(val);
11093
+ if (val !== undefined && val !== '') inputs[key] = val;
11094
+ }
11095
+
11096
+ if (hasError) return;
11097
+
11098
+ // Also update inspector fields to keep them in sync
11099
+ for (const [key, val] of Object.entries(inputs)) {
11100
+ const inspEl = document.getElementById('wf-input-' + key);
11101
+ if (inspEl) inspEl.value = typeof val === 'number' ? String(val) : val;
11102
+ }
11103
+
11104
+ wfCloseInputModal();
11105
+ wfExecuteWithInputs(inputs);
11106
+ }
11107
+
11108
+ // Keyboard handler for input modal
11109
+ document.addEventListener('keydown', (e) => {
11110
+ const backdrop = document.getElementById('wfInputModalBackdrop');
11111
+ if (!backdrop || backdrop.style.display === 'none') return;
11112
+ if (e.key === 'Escape') { wfCloseInputModal(); e.preventDefault(); }
11113
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { wfInputModalSubmit(); e.preventDefault(); }
11114
+ });
11115
+
11116
+ async function wfExecute() {
11117
+ const def = wfState.activeWorkflow;
11118
+ if (!def || wfState.executing) return;
11119
+
11120
+ // If workflow has inputs, show the input modal instead of executing directly
11121
+ if (def.inputs && Object.keys(def.inputs).length > 0) {
11122
+ wfShowInputModal();
11123
+ return;
11124
+ }
11125
+
11126
+ // No inputs needed, execute directly
11127
+ wfExecuteWithInputs({});
11128
+ }
11129
+
11130
+ async function wfExecuteWithInputs(inputs) {
11131
+ const def = wfState.activeWorkflow;
11132
+ if (!def || wfState.executing) return;
11133
+
11134
+ wfState.executing = true;
11135
+ wfState.executionResults = {};
11136
+ wfAbortController = new AbortController();
11137
+ wfExecStartTime = Date.now();
11138
+ wfSetToolbarEnabled(false);
11139
+ document.getElementById('wfStopBtn').style.display = '';
11140
+ wfShowExecStatus('Running...', '');
11141
+ wfExecTimer = requestAnimationFrame(wfUpdateExecTimer);
11142
+
11143
+ // Reset all nodes to pending
11144
+ def.steps.forEach(s => { wfState.executionState[s.id] = 'pending'; });
11145
+ wfRefreshNodes();
11146
+
11147
+ let hasError = false;
11148
+ let errorMessage = '';
11149
+
11150
+ try {
11151
+ const res = await fetch('/api/workflows/execute', {
11152
+ method: 'POST',
11153
+ headers: { 'Content-Type': 'application/json' },
11154
+ body: JSON.stringify({ definition: def, inputs }),
11155
+ signal: wfAbortController.signal,
11156
+ });
11157
+
11158
+ if (!res.ok) {
11159
+ throw new Error('Server returned ' + res.status + ': ' + (await res.text()));
11160
+ }
11161
+
11162
+ const reader = res.body.getReader();
11163
+ const decoder = new TextDecoder();
11164
+ let buffer = '';
11165
+ let currentEvent = '';
11166
+
11167
+ while (true) {
11168
+ const { done, value } = await reader.read();
11169
+ if (done) break;
11170
+ buffer += decoder.decode(value, { stream: true });
11171
+
11172
+ const lines = buffer.split('\n');
11173
+ buffer = lines.pop() || '';
11174
+
11175
+ for (const line of lines) {
11176
+ if (line.startsWith('event: ')) {
11177
+ currentEvent = line.slice(7).trim();
11178
+ } else if (line.startsWith('data: ') && currentEvent) {
11179
+ let data;
11180
+ try { data = JSON.parse(line.slice(6)); } catch { continue; }
11181
+
11182
+ if (currentEvent === 'step_start') {
11183
+ wfState.executionState[data.stepId] = 'running';
11184
+ const stepName = (def.steps.find(s => s.id === data.stepId) || {}).name || data.stepId;
11185
+ wfShowExecStatus('Running: ' + stepName, '');
11186
+ wfRefreshNodes();
11187
+ } else if (currentEvent === 'step_complete') {
11188
+ wfState.executionState[data.stepId] = 'completed';
11189
+ wfState.executionResults[data.stepId] = {
11190
+ output: data.output,
11191
+ timeMs: data.timeMs,
11192
+ summary: data.summary || '',
11193
+ };
11194
+ wfRefreshNodes();
11195
+ if (wfState.selectedNodeId === data.stepId) wfUpdateInspector();
11196
+ } else if (currentEvent === 'step_skip') {
11197
+ wfState.executionState[data.stepId] = 'skipped';
11198
+ wfState.executionResults[data.stepId] = { reason: data.reason };
11199
+ wfRefreshNodes();
11200
+ if (wfState.selectedNodeId === data.stepId) wfUpdateInspector();
11201
+ } else if (currentEvent === 'step_error') {
11202
+ wfState.executionState[data.stepId] = 'error';
11203
+ wfState.executionResults[data.stepId] = { error: data.error };
11204
+ hasError = true;
11205
+ errorMessage = data.error || 'Step failed';
11206
+ wfRefreshNodes();
11207
+ if (wfState.selectedNodeId === data.stepId) wfUpdateInspector();
11208
+ } else if (currentEvent === 'done') {
11209
+ wfState.executionResults._done = data;
11210
+ if (!wfState.selectedNodeId) wfUpdateInspector();
11211
+ } else if (currentEvent === 'error') {
11212
+ hasError = true;
11213
+ errorMessage = data.error || 'Workflow error';
11214
+ console.error('Workflow execution error:', data.error);
11215
+ }
11216
+ currentEvent = '';
11217
+ }
11218
+ }
11219
+ }
11220
+ } catch (err) {
11221
+ if (err.name === 'AbortError') {
11222
+ // Handled by wfStopExecution
11223
+ return;
11224
+ }
11225
+ hasError = true;
11226
+ errorMessage = err.message || 'Execution failed';
11227
+ console.error('Workflow execution failed:', err);
11228
+ // Mark running nodes as error
11229
+ def.steps.forEach(s => {
11230
+ if (wfState.executionState[s.id] === 'running') {
11231
+ wfState.executionState[s.id] = 'error';
11232
+ wfState.executionResults[s.id] = { error: errorMessage };
11233
+ }
11234
+ });
11235
+ wfRefreshNodes();
11236
+ }
11237
+
11238
+ if (wfExecTimer) { cancelAnimationFrame(wfExecTimer); wfExecTimer = null; }
11239
+ wfAbortController = null;
11240
+ wfState.executing = false;
11241
+ wfSetToolbarEnabled(true);
11242
+ document.getElementById('wfStopBtn').style.display = 'none';
11243
+
11244
+ const elapsed = ((Date.now() - wfExecStartTime) / 1000).toFixed(1);
11245
+ if (hasError) {
11246
+ wfShowExecStatus('Failed: ' + errorMessage, 'error');
11247
+ } else {
11248
+ wfShowExecStatus('Completed in ' + elapsed + 's', 'done');
11249
+ }
11250
+ wfUpdateInspector();
11251
+ }
11252
+
11253
+ function wfRefreshNodes() {
11254
+ // Re-render the full SVG (simple approach: redraw)
11255
+ if (wfState.activeWorkflow) {
11256
+ const svg = document.getElementById('wf-canvas');
11257
+ const def = wfState.activeWorkflow;
11258
+ const positions = wfState.nodePositions;
11259
+
11260
+ // Remove existing nodes and edges
11261
+ svg.querySelectorAll('.wf-node, .wf-edge-group').forEach(el => el.remove());
11262
+
11263
+ // Redraw edges
11264
+ const edgeGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
11265
+ edgeGroup.classList.add('wf-edge-group');
11266
+ for (const [stepId, deps] of Object.entries(wfState.graph)) {
11267
+ if (!deps || !Array.isArray(deps)) continue;
11268
+ deps.forEach(rawDepId => {
11269
+ const depId = rawDepId.replace(/^!/, '');
11270
+ if (positions[depId] && positions[stepId]) {
11271
+ edgeGroup.appendChild(wfDrawEdge(depId, stepId, positions));
11272
+ }
11273
+ });
11274
+ }
11275
+ svg.appendChild(edgeGroup);
11276
+
11277
+ // Redraw nodes
11278
+ for (const step of def.steps) {
11279
+ const pos = positions[step.id];
11280
+ if (!pos) continue;
11281
+ const state = wfState.executionState[step.id] || 'idle';
11282
+ svg.appendChild(wfDrawNode(step, pos.x, pos.y, state));
11283
+ }
11284
+ }
11285
+ }
11286
+
11287
+ function wfResetExecution() {
11288
+ if (wfState.executing) return;
11289
+ wfState.executionState = {};
11290
+ wfState.executionResults = {};
11291
+ wfHideExecStatus();
11292
+ wfRefreshNodes();
11293
+ wfUpdateInspector();
11294
+ }
11295
+
11296
+ // ── Output Modal ──
11297
+ let wfOutputModalData = '';
11298
+
11299
+ function wfOpenOutputModal(title, content) {
11300
+ wfOutputModalData = content;
11301
+ const backdrop = document.getElementById('wfOutputModalBackdrop');
11302
+ const titleEl = document.getElementById('wfOutputModalTitle');
11303
+ const bodyEl = document.getElementById('wfOutputModalBody');
11304
+ const copyLabel = document.getElementById('wfOutputCopyLabel');
11305
+ if (!backdrop || !bodyEl) return;
11306
+ titleEl.textContent = title || 'Output';
11307
+ bodyEl.textContent = content;
11308
+ copyLabel.textContent = 'Copy';
11309
+ backdrop.style.display = 'flex';
11310
+ }
11311
+
11312
+ function wfCloseOutputModal() {
11313
+ const backdrop = document.getElementById('wfOutputModalBackdrop');
11314
+ if (backdrop) backdrop.style.display = 'none';
11315
+ }
11316
+
11317
+ function wfCopyOutput() {
11318
+ const label = document.getElementById('wfOutputCopyLabel');
11319
+ navigator.clipboard.writeText(wfOutputModalData).then(() => {
11320
+ if (label) { label.textContent = 'Copied!'; setTimeout(() => { label.textContent = 'Copy'; }, 2000); }
11321
+ }).catch(() => {
11322
+ if (label) label.textContent = 'Failed';
11323
+ });
11324
+ }
11325
+
11326
+ // Close modal on Escape
11327
+ document.addEventListener('keydown', (e) => {
11328
+ if (e.key === 'Escape') {
11329
+ const backdrop = document.getElementById('wfOutputModalBackdrop');
11330
+ if (backdrop && backdrop.style.display !== 'none') {
11331
+ wfCloseOutputModal();
11332
+ e.preventDefault();
11333
+ }
11334
+ }
11335
+ });
11336
+
11337
+ // ── Zoom & Pan ──
11338
+ function wfZoom(direction) {
11339
+ // Multiplicative zoom: feels consistent at any zoom level
11340
+ const factor = direction > 0 ? 1.1 : 1 / 1.1;
11341
+ wfState.zoom = Math.max(0.2, Math.min(5, wfState.zoom * factor));
11342
+ wfApplyViewBox();
11343
+ }
11344
+
11345
+ function wfFitToView() {
11346
+ const svg = document.getElementById('wf-canvas');
11347
+ if (!svg) return;
11348
+ const positions = wfState.nodePositions;
11349
+ const ids = Object.keys(positions);
11350
+ if (ids.length === 0) return;
11351
+
11352
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
11353
+ ids.forEach(id => {
11354
+ const p = positions[id];
11355
+ minX = Math.min(minX, p.x);
11356
+ minY = Math.min(minY, p.y);
11357
+ maxX = Math.max(maxX, p.x + WF_NODE_W);
11358
+ maxY = Math.max(maxY, p.y + WF_NODE_H + 50); // extra for time label + backward edge loops
11359
+ });
11360
+
11361
+ const pad = 40;
11362
+ const vbW = maxX - minX + pad * 2;
11363
+ const vbH = maxY - minY + pad * 2;
11364
+ wfState.panX = minX - pad;
11365
+ wfState.panY = minY - pad;
11366
+ // Calculate zoom to fit
11367
+ const rect = svg.parentElement.getBoundingClientRect();
11368
+ const scaleX = rect.width / vbW;
11369
+ const scaleY = rect.height / vbH;
11370
+ wfState.zoom = Math.min(scaleX, scaleY, 1.5);
11371
+ wfApplyViewBox();
11372
+ }
11373
+
11374
+ function wfApplyViewBox() {
11375
+ const svg = document.getElementById('wf-canvas');
11376
+ if (!svg) return;
11377
+ const rect = svg.parentElement.getBoundingClientRect();
11378
+ const w = rect.width / wfState.zoom;
11379
+ const h = rect.height / wfState.zoom;
11380
+ svg.setAttribute('viewBox', `${wfState.panX} ${wfState.panY} ${w} ${h}`);
11381
+ }
11382
+
11383
+ // Pan via mouse drag
11384
+ function wfInitPan() {
11385
+ const svg = document.getElementById('wf-canvas');
11386
+ if (!svg) return;
11387
+
11388
+ svg.addEventListener('mousedown', (e) => {
11389
+ if (e.target === svg || e.target.tagName === 'svg') {
11390
+ wfState.isPanning = true;
11391
+ wfState.panStart = { x: e.clientX, y: e.clientY };
11392
+ svg.style.cursor = 'grabbing';
11393
+ }
11394
+ });
11395
+
11396
+ svg.addEventListener('mousemove', (e) => {
11397
+ if (!wfState.isPanning) return;
11398
+ const dx = (e.clientX - wfState.panStart.x) / wfState.zoom;
11399
+ const dy = (e.clientY - wfState.panStart.y) / wfState.zoom;
11400
+ wfState.panX -= dx;
11401
+ wfState.panY -= dy;
11402
+ wfState.panStart = { x: e.clientX, y: e.clientY };
11403
+ wfApplyViewBox();
11404
+ });
11405
+
11406
+ svg.addEventListener('mouseup', () => {
11407
+ wfState.isPanning = false;
11408
+ svg.style.cursor = '';
11409
+ });
11410
+
11411
+ svg.addEventListener('mouseleave', () => {
11412
+ wfState.isPanning = false;
11413
+ svg.style.cursor = '';
11414
+ });
11415
+
11416
+ // Scroll wheel zoom, centered on cursor position
11417
+ svg.addEventListener('wheel', (e) => {
11418
+ e.preventDefault();
11419
+ const factor = e.deltaY < 0 ? 1.03 : 1 / 1.03;
11420
+ const oldZoom = wfState.zoom;
11421
+ const newZoom = Math.max(0.2, Math.min(5, oldZoom * factor));
11422
+ // Zoom toward cursor: keep the SVG point under the mouse fixed
11423
+ const rect = svg.getBoundingClientRect();
11424
+ const mx = (e.clientX - rect.left) / oldZoom + wfState.panX;
11425
+ const my = (e.clientY - rect.top) / oldZoom + wfState.panY;
11426
+ wfState.zoom = newZoom;
11427
+ wfState.panX = mx - (e.clientX - rect.left) / newZoom;
11428
+ wfState.panY = my - (e.clientY - rect.top) / newZoom;
11429
+ wfApplyViewBox();
11430
+ }, { passive: false });
11431
+
11432
+ // Click on canvas background to deselect
11433
+ svg.addEventListener('click', (e) => {
11434
+ if (e.target === svg || e.target.tagName === 'svg') {
11435
+ wfDeselectNode();
11436
+ }
11437
+ });
11438
+ }
11439
+
11440
+ // ── Builder: Input Definitions ──
11441
+ const WF_INPUT_DEFS = {
11442
+ query: [{ key: 'query', type: 'text', required: true, placeholder: 'Search query' }, { key: 'collection', type: 'text', required: false, placeholder: 'Collection name' }, { key: 'db', type: 'text', required: false, placeholder: 'Database name' }, { key: 'limit', type: 'number', required: false, placeholder: '5' }, { key: 'filter', type: 'json', required: false, placeholder: '{}' }],
11443
+ search: [{ key: 'query', type: 'text', required: true, placeholder: 'Search query' }, { key: 'collection', type: 'text', required: false }, { key: 'db', type: 'text', required: false }, { key: 'limit', type: 'number', required: false, placeholder: '10' }, { key: 'filter', type: 'json', required: false, placeholder: '{}' }],
11444
+ rerank: [{ key: 'query', type: 'text', required: true }, { key: 'documents', type: 'json', required: true, placeholder: '["doc1","doc2"]' }, { key: 'model', type: 'text', required: false, placeholder: 'rerank-2.5' }],
11445
+ ingest: [{ key: 'text', type: 'textarea', required: true }, { key: 'collection', type: 'text', required: false }, { key: 'db', type: 'text', required: false }, { key: 'source', type: 'text', required: false }, { key: 'chunkSize', type: 'number', required: false, placeholder: '512' }, { key: 'chunkStrategy', type: 'select', required: false, options: ['fixed','sentence','paragraph','recursive','markdown'] }],
11446
+ embed: [{ key: 'text', type: 'text', required: true, placeholder: 'Text to embed' }, { key: 'model', type: 'text', required: false, placeholder: 'voyage-3-large' }, { key: 'inputType', type: 'select', required: false, options: ['document','query'] }],
11447
+ similarity: [{ key: 'text1', type: 'text', required: true }, { key: 'text2', type: 'text', required: true }, { key: 'model', type: 'text', required: false }],
11448
+ collections: [{ key: 'db', type: 'text', required: false }],
11449
+ models: [{ key: 'category', type: 'select', required: false, options: ['embedding','rerank','all'] }],
11450
+ estimate: [{ key: 'docs', type: 'number', required: true, placeholder: '1000' }, { key: 'queries', type: 'number', required: false, placeholder: '0' }, { key: 'months', type: 'number', required: false, placeholder: '12' }],
11451
+ explain: [{ key: 'topic', type: 'text', required: true }],
11452
+ topics: [{ key: 'search', type: 'text', required: false }],
11453
+ merge: [{ key: 'sources', type: 'json', required: true, placeholder: '["step1.output","step2.output"]' }, { key: 'strategy', type: 'select', required: false, options: ['concat','interleave','unique'] }],
11454
+ filter: [{ key: 'input', type: 'text', required: true, placeholder: '{{ step.output }}' }, { key: 'condition', type: 'text', required: true, placeholder: 'item.score > 0.5' }],
11455
+ transform: [{ key: 'input', type: 'text', required: true, placeholder: '{{ step.output }}' }, { key: 'expression', type: 'text', required: true, placeholder: 'item.text' }],
11456
+ generate: [{ key: 'prompt', type: 'textarea', required: true, placeholder: 'Generate a summary of...' }, { key: 'context', type: 'text', required: false, placeholder: '{{ step.output }}' }],
11457
+ };
11458
+
11459
+ const WF_CATEGORY_ORDER = ['retrieval', 'embedding', 'management', 'utility', 'control', 'generation'];
11460
+ const WF_CATEGORY_LABELS = { retrieval: 'Retrieval', embedding: 'Embedding', management: 'Management', utility: 'Utility', control: 'Control Flow', generation: 'Generation' };
11461
+
11462
+ // ── Builder: Library/Palette tab toggle ──
11463
+ function wfSwitchLibTab(tab) {
11464
+ const libraryList = document.getElementById('wfLibraryList');
11465
+ const paletteList = document.getElementById('wfPaletteList');
11466
+ document.querySelectorAll('.wf-lib-tab').forEach(b => b.classList.toggle('active', b.dataset.libTab === tab));
11467
+ if (libraryList) libraryList.style.display = tab === 'library' ? '' : 'none';
11468
+ if (paletteList) paletteList.style.display = tab === 'palette' ? '' : 'none';
11469
+ if (tab === 'palette') wfRenderPalette();
11470
+ }
11471
+
11472
+ // ── Builder: Palette rendering ──
11473
+ function wfRenderPalette() {
11474
+ const container = document.getElementById('wfPaletteList');
11475
+ if (!container) return;
11476
+ const grouped = {};
11477
+ for (const [tool, meta] of Object.entries(WF_NODE_META)) {
11478
+ const cat = meta.category || 'unknown';
11479
+ if (!grouped[cat]) grouped[cat] = [];
11480
+ grouped[cat].push({ tool, ...meta });
11481
+ }
11482
+ let html = '';
11483
+ for (const cat of WF_CATEGORY_ORDER) {
11484
+ const items = grouped[cat];
11485
+ if (!items) continue;
11486
+ html += `<div class="wf-palette-category"><div class="wf-palette-category-title">${WF_CATEGORY_LABELS[cat] || cat}</div>`;
11487
+ for (const item of items) {
11488
+ html += `<div class="wf-palette-item" draggable="true" ondragstart="event.dataTransfer.setData('text/plain','${item.tool}')" onclick="wfAddNodeFromPalette('${item.tool}')">
11489
+ <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>
11490
+ <span class="wf-palette-label">${item.label}</span>
11491
+ </div>`;
11492
+ }
11493
+ html += '</div>';
11494
+ }
11495
+ container.innerHTML = html;
11496
+ }
11497
+
11498
+ // ── Builder: New Workflow ──
11499
+ function wfNewWorkflow() {
11500
+ wfState.activeWorkflow = {
11501
+ name: 'Untitled Workflow',
11502
+ description: '',
11503
+ steps: [],
11504
+ inputs: {},
11505
+ defaults: {},
11506
+ output: {}
11507
+ };
11508
+ wfState.builderMode = true;
11509
+ wfState.dirtyFlag = false;
11510
+ wfState.selectedNodeId = null;
11511
+ wfState.executionState = {};
11512
+ wfState.executionResults = {};
11513
+ wfState.nodePositions = {};
11514
+ wfState.layers = [];
11515
+ wfState.graph = {};
11516
+ wfState.zoom = 1;
11517
+ wfState.panX = 0;
11518
+ wfState.panY = 0;
11519
+ wfState.draggingEdge = null;
11520
+ wfState.dragNode = null;
11521
+
11522
+ // Clear canvas
11523
+ const svg = document.getElementById('wf-canvas');
11524
+ if (svg) svg.querySelectorAll('.wf-node, .wf-edge-group').forEach(el => el.remove());
11525
+ document.getElementById('wfCanvasEmpty').style.display = 'none';
11526
+ svg.style.display = '';
11527
+
11528
+ // Enable toolbar buttons
11529
+ document.getElementById('wfDryRunBtn').disabled = false;
11530
+ document.getElementById('wfRunBtn').disabled = false;
11531
+ document.getElementById('wfExportBtn').disabled = false;
11532
+
11533
+ // Switch to palette tab
11534
+ wfSwitchLibTab('palette');
11535
+
11536
+ // Open inspector for workflow-level editing
11537
+ wfOpenInspector();
11538
+ wfUpdateInspector();
11539
+
11540
+ // Set viewBox
11541
+ wfApplyViewBox();
11542
+ }
11543
+
11544
+ // ── Builder: Edit current workflow ──
11545
+ function wfEditWorkflow() {
11546
+ if (!wfState.activeWorkflow) return;
11547
+ wfState.builderMode = true;
11548
+ wfState.dirtyFlag = false;
11549
+ wfState.selectedNodeId = null;
11550
+ wfState.draggingEdge = null;
11551
+ wfState.dragNode = null;
11552
+
11553
+ // Re-render nodes with builder ports and drag handles
11554
+ wfRefreshNodes();
11555
+
11556
+ // Switch to palette tab and open inspector for workflow-level editing
11557
+ wfSwitchLibTab('palette');
11558
+ wfOpenInspector();
11559
+ wfUpdateInspector();
11560
+ }
11561
+
11562
+ // ── Builder: Add node from palette ──
11563
+ function wfAddNodeFromPalette(tool) {
11564
+ if (!wfState.activeWorkflow) wfNewWorkflow();
11565
+ wfState.builderMode = true;
11566
+ const def = wfState.activeWorkflow;
11567
+
11568
+ // Generate unique step ID
11569
+ let baseId = tool;
11570
+ let id = baseId;
11571
+ let counter = 2;
11572
+ const existingIds = new Set(def.steps.map(s => s.id));
11573
+ while (existingIds.has(id)) { id = baseId + '_' + counter; counter++; }
11574
+
11575
+ // Build default inputs
11576
+ const inputDefs = WF_INPUT_DEFS[tool] || [];
11577
+ const inputs = {};
11578
+ for (const d of inputDefs) {
11579
+ if (d.required) inputs[d.key] = '';
11580
+ }
11581
+
11582
+ const meta = WF_NODE_META[tool] || {};
11583
+ const step = {
11584
+ id,
11585
+ name: meta.label || tool,
11586
+ tool,
11587
+ inputs,
11588
+ };
11589
+ def.steps.push(step);
11590
+
11591
+ // Position: place to the right of all existing nodes
11592
+ let maxX = WF_PAD;
11593
+ for (const pos of Object.values(wfState.nodePositions)) {
11594
+ if (pos.x + WF_NODE_W + WF_LAYER_GAP > maxX) maxX = pos.x + WF_NODE_W + WF_LAYER_GAP;
11595
+ }
11596
+ let y = WF_PAD;
11597
+ // Stack vertically if there are nodes in the same column
11598
+ const nodesAtX = Object.values(wfState.nodePositions).filter(p => Math.abs(p.x - maxX) < 20);
11599
+ if (nodesAtX.length > 0) {
11600
+ y = Math.max(...nodesAtX.map(p => p.y)) + WF_NODE_H + WF_NODE_GAP;
11601
+ }
11602
+ wfState.nodePositions[id] = { x: maxX, y };
11603
+
11604
+ // Rebuild graph
11605
+ wfBuildGraph();
11606
+ wfRefreshNodes();
11607
+ wfSelectNode(id);
11608
+ wfState.dirtyFlag = true;
11609
+ }
11610
+
11611
+ // ── Builder: Build graph from step inputs (template references) ──
11612
+ function wfBuildGraph() {
11613
+ const def = wfState.activeWorkflow;
11614
+ if (!def) return;
11615
+ const graph = {};
11616
+ const stepIds = new Set(def.steps.map(s => s.id));
11617
+ for (const step of def.steps) {
11618
+ const deps = new Set();
11619
+ // Scan all input values for {{ stepId.xxx }} references
11620
+ for (const val of Object.values(step.inputs || {})) {
11621
+ const str = typeof val === 'string' ? val : JSON.stringify(val);
11622
+ const matches = str.matchAll(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\./g);
11623
+ for (const m of matches) {
11624
+ if (stepIds.has(m[1]) && m[1] !== step.id) deps.add(m[1]);
11625
+ }
11626
+ }
11627
+ graph[step.id] = Array.from(deps);
11628
+ }
11629
+ wfState.graph = graph;
11630
+ }
11631
+
11632
+ // ── Builder: Canvas drop (from palette drag) ──
11633
+ function wfCanvasDrop(e) {
11634
+ e.preventDefault();
11635
+ const tool = e.dataTransfer.getData('text/plain');
11636
+ if (!tool || !WF_NODE_META[tool]) return;
11637
+ if (!wfState.activeWorkflow) wfNewWorkflow();
11638
+ wfState.builderMode = true;
11639
+ const def = wfState.activeWorkflow;
11640
+
11641
+ // Generate unique ID
11642
+ let id = tool;
11643
+ let counter = 2;
11644
+ const existingIds = new Set(def.steps.map(s => s.id));
11645
+ while (existingIds.has(id)) { id = tool + '_' + counter; counter++; }
11646
+
11647
+ // Inputs
11648
+ const inputDefs = WF_INPUT_DEFS[tool] || [];
11649
+ const inputs = {};
11650
+ for (const d of inputDefs) { if (d.required) inputs[d.key] = ''; }
11651
+
11652
+ const meta = WF_NODE_META[tool] || {};
11653
+ const step = { id, name: meta.label || tool, tool, inputs };
11654
+ def.steps.push(step);
11655
+
11656
+ // Convert drop coordinates to SVG space
11657
+ const svg = document.getElementById('wf-canvas');
11658
+ const rect = svg.getBoundingClientRect();
11659
+ const svgX = (e.clientX - rect.left) / wfState.zoom + wfState.panX;
11660
+ const svgY = (e.clientY - rect.top) / wfState.zoom + wfState.panY;
11661
+ wfState.nodePositions[id] = { x: svgX, y: svgY };
11662
+
11663
+ wfBuildGraph();
11664
+ wfRefreshNodes();
11665
+ wfSelectNode(id);
11666
+ wfState.dirtyFlag = true;
11667
+ }
11668
+
11669
+ // ── Builder: Mutation helpers ──
11670
+ function wfEditStepField(stepId, field, value) {
11671
+ const step = wfState.activeWorkflow?.steps.find(s => s.id === stepId);
11672
+ if (!step) return;
11673
+ step[field] = value;
11674
+ wfState.dirtyFlag = true;
11675
+ if (field === 'name') wfRefreshNodes();
11676
+ }
11677
+
11678
+ function wfEditStepInput(stepId, key, value) {
11679
+ const step = wfState.activeWorkflow?.steps.find(s => s.id === stepId);
11680
+ if (!step) return;
11681
+ if (!step.inputs) step.inputs = {};
11682
+ step.inputs[key] = value;
11683
+ wfState.dirtyFlag = true;
11684
+ // Rebuild graph in case template refs changed
11685
+ wfBuildGraph();
11686
+ wfRefreshNodes();
11687
+ }
11688
+
11689
+ function wfEditStepId(oldId, newId) {
11690
+ const def = wfState.activeWorkflow;
11691
+ if (!def) return;
11692
+ newId = newId.trim().replace(/[^a-zA-Z0-9_]/g, '_');
11693
+ if (!newId || newId === oldId) return;
11694
+ if (def.steps.some(s => s.id === newId)) return; // duplicate
11695
+
11696
+ const step = def.steps.find(s => s.id === oldId);
11697
+ if (!step) return;
11698
+ step.id = newId;
11699
+
11700
+ // Update position map
11701
+ if (wfState.nodePositions[oldId]) {
11702
+ wfState.nodePositions[newId] = wfState.nodePositions[oldId];
11703
+ delete wfState.nodePositions[oldId];
11704
+ }
11705
+ // Update template references in other steps
11706
+ for (const s of def.steps) {
11707
+ for (const [k, v] of Object.entries(s.inputs || {})) {
11708
+ if (typeof v === 'string' && v.includes('{{ ' + oldId + '.')) {
11709
+ s.inputs[k] = v.replaceAll('{{ ' + oldId + '.', '{{ ' + newId + '.');
11710
+ }
11711
+ }
11712
+ }
11713
+ if (wfState.selectedNodeId === oldId) wfState.selectedNodeId = newId;
11714
+ wfState.dirtyFlag = true;
11715
+ wfBuildGraph();
11716
+ wfRefreshNodes();
11717
+ wfUpdateInspector();
11718
+ }
11719
+
11720
+ function wfDeleteStep(stepId) {
11721
+ const def = wfState.activeWorkflow;
11722
+ if (!def) return;
11723
+ def.steps = def.steps.filter(s => s.id !== stepId);
11724
+ delete wfState.nodePositions[stepId];
11725
+ if (wfState.selectedNodeId === stepId) wfState.selectedNodeId = null;
11726
+ wfState.dirtyFlag = true;
11727
+ wfBuildGraph();
11728
+ wfRefreshNodes();
11729
+ wfUpdateInspector();
11730
+ }
11731
+
11732
+ function wfEditWorkflowField(field, value) {
11733
+ if (!wfState.activeWorkflow) return;
11734
+ wfState.activeWorkflow[field] = value;
11735
+ wfState.dirtyFlag = true;
11736
+ }
11737
+
11738
+ // ── Builder: Validate ──
11739
+ async function wfValidateBuilder() {
11740
+ const def = wfState.activeWorkflow;
11741
+ if (!def) return null;
11742
+ try {
11743
+ const res = await fetch('/api/workflows/validate', {
11744
+ method: 'POST',
11745
+ headers: { 'Content-Type': 'application/json' },
11746
+ body: JSON.stringify(def),
11747
+ });
11748
+ return await res.json();
11749
+ } catch (err) {
11750
+ return { valid: false, errors: [err.message] };
11751
+ }
11752
+ }
11753
+
11754
+ // ── Builder: Edge drag ──
11755
+ function wfEdgeDragStart(fromId, fromX, fromY) {
11756
+ const svg = document.getElementById('wf-canvas');
11757
+ if (!svg) return;
11758
+ wfState.draggingEdge = { fromId, fromX, fromY };
11759
+
11760
+ // Create temp edge (dashed bezier), pointer-events: none so it doesn't block port hit-testing
11761
+ const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
11762
+ tempPath.setAttribute('id', 'wf-temp-edge');
11763
+ tempPath.setAttribute('fill', 'none');
11764
+ tempPath.setAttribute('stroke', 'var(--accent)');
11765
+ tempPath.setAttribute('stroke-width', '2');
11766
+ tempPath.setAttribute('stroke-dasharray', '6 4');
11767
+ tempPath.setAttribute('pointer-events', 'none');
11768
+ tempPath.setAttribute('d', `M${fromX},${fromY} L${fromX},${fromY}`);
11769
+ svg.appendChild(tempPath);
11770
+
11771
+ function onMove(e) {
11772
+ const rect = svg.getBoundingClientRect();
11773
+ const mx = (e.clientX - rect.left) / wfState.zoom + wfState.panX;
11774
+ const my = (e.clientY - rect.top) / wfState.zoom + wfState.panY;
11775
+ const dx = Math.abs(mx - fromX) * 0.5;
11776
+ tempPath.setAttribute('d', `M${fromX},${fromY} C${fromX + dx},${fromY} ${mx - dx},${my} ${mx},${my}`);
11777
+ }
11778
+
11779
+ function onUp() {
11780
+ document.removeEventListener('mousemove', onMove);
11781
+ document.removeEventListener('mouseup', onUp);
11782
+ const el = document.getElementById('wf-temp-edge');
11783
+ if (el) el.remove();
11784
+ wfState.draggingEdge = null;
11785
+ }
11786
+
11787
+ document.addEventListener('mousemove', onMove);
11788
+ document.addEventListener('mouseup', onUp);
11789
+ }
11790
+
11791
+ function wfEdgeDropOnInput(toId) {
11792
+ if (!wfState.draggingEdge) return;
11793
+ const fromId = wfState.draggingEdge.fromId;
11794
+ if (fromId === toId) return; // no self-connections
11795
+
11796
+ // Add template reference to the target step's first empty required input
11797
+ const def = wfState.activeWorkflow;
11798
+ if (!def) return;
11799
+ const targetStep = def.steps.find(s => s.id === toId);
11800
+ if (!targetStep) return;
11801
+
11802
+ const inputDefs = WF_INPUT_DEFS[targetStep.tool] || [];
11803
+ let connected = false;
11804
+
11805
+ // Try to fill the first empty required input with a template reference
11806
+ for (const d of inputDefs) {
11807
+ if (!targetStep.inputs) targetStep.inputs = {};
11808
+ const current = targetStep.inputs[d.key];
11809
+ if (!current || current === '') {
11810
+ targetStep.inputs[d.key] = `{{ ${fromId}.output }}`;
11811
+ connected = true;
11812
+ break;
11813
+ }
11814
+ }
11815
+
11816
+ // If no empty required input, try first empty optional input
11817
+ if (!connected) {
11818
+ for (const d of inputDefs) {
11819
+ const current = targetStep.inputs?.[d.key];
11820
+ if (!current || current === '') {
11821
+ if (!targetStep.inputs) targetStep.inputs = {};
11822
+ targetStep.inputs[d.key] = `{{ ${fromId}.output }}`;
11823
+ connected = true;
11824
+ break;
11825
+ }
11826
+ }
11827
+ }
11828
+
11829
+ if (connected) {
11830
+ wfState.dirtyFlag = true;
11831
+ wfBuildGraph();
11832
+ wfRelayout();
11833
+ wfRefreshNodes();
11834
+ if (wfState.selectedNodeId === toId) wfUpdateInspector();
11835
+ }
11836
+
11837
+ // Clean up drag state
11838
+ wfState.draggingEdge = null;
11839
+ const el = document.getElementById('wf-temp-edge');
11840
+ if (el) el.remove();
11841
+ }
11842
+
11843
+ // ── Builder: Relayout via topological sort ──
11844
+ async function wfRelayout() {
11845
+ const def = wfState.activeWorkflow;
11846
+ if (!def || def.steps.length === 0) return;
11847
+
11848
+ try {
11849
+ const res = await fetch('/api/workflows/plan', {
11850
+ method: 'POST',
11851
+ headers: { 'Content-Type': 'application/json' },
11852
+ body: JSON.stringify(def),
11853
+ });
11854
+ const data = await res.json();
11855
+ if (data.layers && data.layers.length > 0) {
11856
+ wfState.layers = data.layers;
11857
+ // Reposition nodes based on layers
11858
+ const positions = {};
11859
+ data.layers.forEach((layer, li) => {
11860
+ layer.forEach((stepId, ni) => {
11861
+ positions[stepId] = {
11862
+ x: WF_PAD + li * WF_LAYER_GAP,
11863
+ y: WF_PAD + ni * (WF_NODE_H + WF_NODE_GAP),
11864
+ };
11865
+ });
11866
+ });
11867
+ // Keep orphan nodes (not in any layer) at their current position
11868
+ for (const step of def.steps) {
11869
+ if (!positions[step.id] && wfState.nodePositions[step.id]) {
11870
+ positions[step.id] = wfState.nodePositions[step.id];
11871
+ }
11872
+ }
11873
+ wfState.nodePositions = positions;
11874
+ }
11875
+ } catch (err) {
11876
+ console.warn('Relayout failed:', err.message);
11877
+ }
11878
+ }
11879
+
11880
+ // ── Docs shortcut (F1) ──
11881
+ const DOCS_URLS = {
11882
+ embed: 'https://docs.vaicli.com/docs/commands/embeddings/embed',
11883
+ compare: 'https://docs.vaicli.com/docs/commands/embeddings/similarity',
11884
+ search: 'https://docs.vaicli.com/docs/commands/embeddings/rerank',
11885
+ multimodal: 'https://docs.vaicli.com/docs/commands/embeddings/embed',
11886
+ generate: 'https://docs.vaicli.com/docs/commands/project-setup/generate',
11887
+ chat: 'https://docs.vaicli.com/docs/commands/advanced/chat',
11888
+ workflows: 'https://docs.vaicli.com/docs/commands/advanced/workflow-run',
11889
+ benchmark: 'https://docs.vaicli.com/docs/commands/evaluation/benchmark',
11890
+ explore: 'https://docs.vaicli.com/docs/commands/tools-and-learning/explain',
11891
+ about: 'https://docs.vaicli.com/docs/',
11892
+ settings: 'https://docs.vaicli.com/docs/commands/tools-and-learning/config',
11893
+ };
11894
+ document.addEventListener('keydown', (e) => {
11895
+ if (e.key === 'F1') {
11896
+ e.preventDefault();
11897
+ const activeTab = document.querySelector('.tab-btn.active');
11898
+ const tabName = activeTab ? activeTab.dataset.tab : '';
11899
+ const url = DOCS_URLS[tabName] || 'https://docs.vaicli.com';
11900
+ window.open(url, '_blank', 'noopener');
11901
+ }
11902
+ });
11903
+
11904
+ // Keyboard shortcuts (when workflows tab is active)
11905
+ document.addEventListener('keydown', (e) => {
11906
+ const activeTab = document.querySelector('.tab-btn.active');
11907
+ if (!activeTab || activeTab.dataset.tab !== 'workflows') return;
11908
+
11909
+ // Ctrl/Cmd+S to save/export in builder mode (works even in inputs)
11910
+ if ((e.ctrlKey || e.metaKey) && e.key === 's' && wfState.builderMode) {
11911
+ e.preventDefault();
11912
+ wfExportJson();
11913
+ return;
11914
+ }
11915
+
11916
+ // Delete/Backspace to remove selected node in builder mode
11917
+ if ((e.key === 'Delete' || e.key === 'Backspace') && wfState.builderMode && wfState.selectedNodeId) {
11918
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
11919
+ e.preventDefault();
11920
+ wfDeleteStep(wfState.selectedNodeId);
11921
+ return;
11922
+ }
11923
+
11924
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
11925
+
11926
+ const PAN_STEP = 40;
11927
+ if (e.key === '+' || e.key === '=') { wfZoom(1); e.preventDefault(); }
11928
+ else if (e.key === '-') { wfZoom(-1); e.preventDefault(); }
11929
+ else if (e.key === '0') { wfFitToView(); e.preventDefault(); }
11930
+ else if (e.key === 'Escape') { wfDeselectNode(); e.preventDefault(); }
11931
+ else if (e.key === 'ArrowLeft') { wfState.panX -= PAN_STEP / wfState.zoom; wfApplyViewBox(); e.preventDefault(); }
11932
+ else if (e.key === 'ArrowRight') { wfState.panX += PAN_STEP / wfState.zoom; wfApplyViewBox(); e.preventDefault(); }
11933
+ else if (e.key === 'ArrowUp') { wfState.panY -= PAN_STEP / wfState.zoom; wfApplyViewBox(); e.preventDefault(); }
11934
+ else if (e.key === 'ArrowDown') { wfState.panY += PAN_STEP / wfState.zoom; wfApplyViewBox(); e.preventDefault(); }
11935
+ });
11936
+
11937
+ // ── Init ──
11938
+ function wfInit() {
11939
+ wfLoadLibrary();
11940
+ wfInitPan();
11941
+ }
11942
+
11943
+ // Auto-init: use MutationObserver on the panel to detect when it becomes visible
11944
+ let wfInitialized = false;
11945
+ (function() {
11946
+ const panel = document.getElementById('tab-workflows');
11947
+ if (!panel) return;
11948
+ const observer = new MutationObserver(() => {
11949
+ if (panel.classList.contains('active') && !wfInitialized) {
11950
+ wfInitialized = true;
11951
+ wfInit();
11952
+ }
11953
+ });
11954
+ observer.observe(panel, { attributes: true, attributeFilter: ['class'] });
11955
+ // Also init immediately if the tab is already active (shouldn't be, but safety)
11956
+ if (panel.classList.contains('active')) {
11957
+ wfInitialized = true;
11958
+ wfInit();
11959
+ }
11960
+ })();
11961
+ </script>
11962
+
8754
11963
  <script>
8755
11964
  (function() {
8756
11965
  const BUG_API = 'https://vaicli.com/api/bugs';