voyageai-cli 1.26.1 → 1.27.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; }
@@ -1825,6 +1825,34 @@ select:focus { outline: none; border-color: var(--accent); }
1825
1825
  line-height: 1.5;
1826
1826
  }
1827
1827
 
1828
+ /* ── Contextual docs link ── */
1829
+ .page-header { position: relative; }
1830
+ .page-header-docs {
1831
+ position: absolute; top: 0; right: 0;
1832
+ display: inline-flex; align-items: center; gap: 5px;
1833
+ font-size: 12px; font-weight: 500;
1834
+ color: var(--text-muted); text-decoration: none;
1835
+ padding: 4px 10px; border-radius: 6px;
1836
+ border: 1px solid var(--border);
1837
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
1838
+ }
1839
+ .page-header-docs:hover {
1840
+ color: var(--accent, #6c63ff); border-color: var(--accent, #6c63ff);
1841
+ background: rgba(108,99,255,0.06);
1842
+ }
1843
+ .page-header-docs svg { opacity: 0.6; }
1844
+ .page-header-docs:hover svg { opacity: 1; }
1845
+ .sidebar-docs-link {
1846
+ display: inline-flex; align-items: center; gap: 4px;
1847
+ font-size: 10px; color: var(--text-muted); text-decoration: none;
1848
+ padding: 2px 6px; border-radius: 4px;
1849
+ transition: color 0.15s, background 0.15s;
1850
+ }
1851
+ .sidebar-docs-link:hover {
1852
+ color: var(--accent, #6c63ff); background: rgba(108,99,255,0.08);
1853
+ }
1854
+ .sidebar-docs-link svg { width: 12px; height: 12px; }
1855
+
1828
1856
  /* ── Settings page ── */
1829
1857
  .settings-container { max-width: 640px; margin: 0 auto; }
1830
1858
  .settings-section {
@@ -2052,6 +2080,34 @@ select:focus { outline: none; border-color: var(--accent); }
2052
2080
  transition: opacity 0.3s;
2053
2081
  }
2054
2082
  .settings-saved.show { opacity: 1; }
2083
+ .doctor-check {
2084
+ display: flex;
2085
+ align-items: flex-start;
2086
+ gap: 10px;
2087
+ padding: 10px 14px;
2088
+ border-radius: var(--radius);
2089
+ background: var(--bg-input);
2090
+ border: 1px solid var(--border);
2091
+ }
2092
+ .doctor-check-icon { font-size: 15px; flex-shrink: 0; line-height: 1.4; }
2093
+ .doctor-check-body { flex: 1; min-width: 0; }
2094
+ .doctor-check-name { font-size: 13px; font-weight: 600; color: var(--text); }
2095
+ .doctor-check-msg { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
2096
+ .doctor-check-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; font-family: var(--mono); }
2097
+ .doctor-check-badge {
2098
+ font-size: 10px;
2099
+ padding: 2px 8px;
2100
+ border-radius: 10px;
2101
+ font-weight: 600;
2102
+ text-transform: uppercase;
2103
+ letter-spacing: 0.5px;
2104
+ flex-shrink: 0;
2105
+ align-self: center;
2106
+ }
2107
+ .doctor-check-badge.pass { background: rgba(0,212,170,0.12); color: var(--success); }
2108
+ .doctor-check-badge.fail { background: rgba(255,105,96,0.12); color: var(--error); }
2109
+ .doctor-check-badge.warn { background: rgba(255,192,16,0.12); color: var(--warning); }
2110
+ .doctor-check-badge.optional { background: rgba(136,147,151,0.12); color: var(--text-muted); }
2055
2111
  .settings-reset-btn {
2056
2112
  background: transparent;
2057
2113
  border: 1px solid var(--border);
@@ -2316,6 +2372,25 @@ select:focus { outline: none; border-color: var(--accent); }
2316
2372
  background: var(--bg-card);
2317
2373
  border: 1px solid var(--border);
2318
2374
  border-bottom-left-radius: 4px;
2375
+ position: relative;
2376
+ }
2377
+ .chat-copy-btn {
2378
+ position: absolute; top: 6px; right: 6px;
2379
+ width: 28px; height: 28px; border-radius: 6px;
2380
+ border: 1px solid var(--border); background: var(--bg);
2381
+ color: var(--text-muted); cursor: pointer;
2382
+ display: flex; align-items: center; justify-content: center;
2383
+ opacity: 0; transition: opacity 0.15s, background 0.15s, color 0.15s;
2384
+ pointer-events: none; z-index: 1;
2385
+ }
2386
+ .chat-message.assistant:hover .chat-copy-btn {
2387
+ opacity: 1; pointer-events: auto;
2388
+ }
2389
+ .chat-copy-btn:hover {
2390
+ background: var(--bg-card); color: var(--text); border-color: var(--text-muted);
2391
+ }
2392
+ .chat-copy-btn.copied {
2393
+ color: #69F0AE; border-color: #69F0AE;
2319
2394
  }
2320
2395
  .chat-message.system-msg {
2321
2396
  align-self: center;
@@ -2421,21 +2496,160 @@ select:focus { outline: none; border-color: var(--accent); }
2421
2496
  margin: 12px 0;
2422
2497
  }
2423
2498
 
2424
- .chat-tool-calls {
2425
- display: flex; flex-direction: column; gap: 4px;
2426
- margin-bottom: 4px; align-self: flex-start; max-width: 85%;
2499
+ /* Agent thinking panel */
2500
+ .chat-thinking {
2501
+ align-self: flex-start;
2502
+ max-width: 85%;
2503
+ margin-bottom: 2px;
2504
+ font-size: 13px;
2505
+ }
2506
+ .chat-thinking summary {
2507
+ cursor: pointer;
2508
+ list-style: none;
2509
+ display: flex;
2510
+ align-items: center;
2511
+ gap: 6px;
2512
+ padding: 8px 14px;
2513
+ border-radius: 10px;
2514
+ background: var(--bg-card);
2515
+ border: 1px solid var(--border);
2516
+ color: var(--text-muted);
2517
+ font-size: 13px;
2518
+ transition: background 0.15s, border-color 0.15s;
2519
+ user-select: none;
2520
+ }
2521
+ .chat-thinking summary::-webkit-details-marker { display: none; }
2522
+ .chat-thinking summary:hover {
2523
+ background: var(--bg-surface);
2524
+ border-color: var(--accent-dim, #009E80);
2525
+ }
2526
+ .chat-thinking[open] summary {
2527
+ border-radius: 10px 10px 0 0;
2528
+ border-bottom-color: transparent;
2529
+ }
2530
+ .chat-thinking .thinking-icon {
2531
+ font-size: 14px;
2532
+ line-height: 1;
2533
+ }
2534
+ .chat-thinking .thinking-label {
2535
+ font-weight: 500;
2536
+ }
2537
+ .chat-thinking .thinking-chevron {
2538
+ margin-left: auto;
2539
+ font-size: 10px;
2540
+ opacity: 0.5;
2541
+ transition: transform 0.15s;
2542
+ }
2543
+ .chat-thinking[open] .thinking-chevron {
2544
+ transform: rotate(90deg);
2545
+ }
2546
+ .chat-thinking .thinking-count {
2547
+ background: var(--bg-input, #112733);
2548
+ color: var(--text-muted);
2549
+ font-size: 11px;
2550
+ padding: 1px 7px;
2551
+ border-radius: 10px;
2552
+ font-weight: 600;
2553
+ }
2554
+ .chat-thinking .thinking-elapsed {
2555
+ font-size: 11px;
2556
+ opacity: 0.45;
2557
+ }
2558
+ .chat-thinking .thinking-timeline {
2559
+ border: 1px solid var(--border);
2560
+ border-top: none;
2561
+ border-radius: 0 0 10px 10px;
2562
+ padding: 10px 14px;
2563
+ background: var(--bg-card);
2564
+ }
2565
+ .thinking-step {
2566
+ display: flex;
2567
+ gap: 10px;
2568
+ padding: 7px 0;
2569
+ position: relative;
2570
+ animation: thinkingSlideIn 0.25s ease-out;
2571
+ }
2572
+ @keyframes thinkingSlideIn {
2573
+ from { opacity: 0; transform: translateY(-4px); }
2574
+ to { opacity: 1; transform: translateY(0); }
2575
+ }
2576
+ .thinking-step + .thinking-step {
2577
+ border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
2578
+ }
2579
+ .thinking-step-icon {
2580
+ width: 28px;
2581
+ height: 28px;
2582
+ border-radius: 50%;
2583
+ display: flex;
2584
+ align-items: center;
2585
+ justify-content: center;
2586
+ font-size: 14px;
2587
+ flex-shrink: 0;
2588
+ background: var(--bg-input, #112733);
2589
+ border: 1px solid var(--border);
2590
+ }
2591
+ .thinking-step.active .thinking-step-icon {
2592
+ border-color: var(--accent);
2593
+ animation: thinkingPulse 1.2s ease-in-out infinite;
2594
+ }
2595
+ @keyframes thinkingPulse {
2596
+ 0%, 100% { box-shadow: 0 0 0 0 rgba(0, 212, 170, 0.25); }
2597
+ 50% { box-shadow: 0 0 0 6px rgba(0, 212, 170, 0); }
2598
+ }
2599
+ .thinking-step.error .thinking-step-icon {
2600
+ border-color: #e74c3c;
2601
+ background: rgba(231, 76, 60, 0.08);
2602
+ }
2603
+ .thinking-step.done .thinking-step-icon {
2604
+ border-color: var(--accent-dim, #009E80);
2605
+ opacity: 0.7;
2606
+ }
2607
+ .thinking-step-body {
2608
+ flex: 1;
2609
+ min-width: 0;
2610
+ display: flex;
2611
+ flex-direction: column;
2612
+ justify-content: center;
2613
+ gap: 2px;
2614
+ }
2615
+ .thinking-step-header {
2616
+ display: flex;
2617
+ align-items: center;
2618
+ gap: 6px;
2619
+ }
2620
+ .thinking-step-name {
2621
+ font-weight: 600;
2622
+ color: var(--text);
2623
+ font-size: 12.5px;
2624
+ }
2625
+ .thinking-step-desc {
2626
+ font-size: 12px;
2627
+ color: var(--text-muted);
2628
+ opacity: 0.7;
2629
+ }
2630
+ .thinking-step-time {
2631
+ font-size: 11px;
2632
+ opacity: 0.45;
2633
+ margin-left: auto;
2634
+ white-space: nowrap;
2635
+ }
2636
+ .thinking-step-detail {
2637
+ font-size: 11.5px;
2638
+ color: var(--text-muted);
2639
+ margin-top: 2px;
2640
+ line-height: 1.4;
2641
+ overflow: hidden;
2642
+ text-overflow: ellipsis;
2643
+ display: -webkit-box;
2644
+ -webkit-line-clamp: 2;
2645
+ -webkit-box-orient: vertical;
2646
+ }
2647
+ .thinking-step-detail code {
2648
+ font-size: 11px;
2649
+ background: var(--bg-input, #112733);
2650
+ padding: 1px 4px;
2651
+ border-radius: 3px;
2427
2652
  }
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;
2433
- }
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
2653
  .chat-input-area {
2440
2654
  padding: 12px 16px;
2441
2655
  border-top: 1px solid var(--border);
@@ -2750,6 +2964,450 @@ select:focus { outline: none; border-color: var(--accent); }
2750
2964
  .settings-row { flex-direction: column; align-items: flex-start; gap: 8px; }
2751
2965
  .settings-select, .settings-input { min-width: 100%; }
2752
2966
  }
2967
+
2968
+ /* ========== WORKFLOW VISUALIZER ========== */
2969
+ /* Expand .main when workflows tab is active to fill viewport */
2970
+ .main:has(#tab-workflows.active) {
2971
+ max-width: none; padding: 0; overflow: hidden;
2972
+ }
2973
+ #tab-workflows.tab-panel.active {
2974
+ display: flex; flex-direction: column;
2975
+ height: 100%;
2976
+ /* Fill entire .main which is flex:1 inside the 100vh app-shell */
2977
+ }
2978
+ .wf-container {
2979
+ display: flex; flex: 1; gap: 0; overflow: hidden;
2980
+ border-radius: 0;
2981
+ background: var(--bg-card);
2982
+ min-height: 0;
2983
+ }
2984
+ .wf-library {
2985
+ width: 220px; min-width: 180px; flex-shrink: 0;
2986
+ border-right: 1px solid var(--border);
2987
+ display: flex; flex-direction: column;
2988
+ background: var(--bg);
2989
+ }
2990
+ .wf-library-header {
2991
+ padding: 14px 16px 10px; font-weight: 700; font-size: 13px;
2992
+ color: var(--text); border-bottom: 1px solid var(--border);
2993
+ display: flex; align-items: center; justify-content: space-between;
2994
+ }
2995
+ .wf-library-list {
2996
+ flex: 1; overflow-y: auto; padding: 8px;
2997
+ }
2998
+ .wf-library-footer {
2999
+ padding: 8px; border-top: 1px solid var(--border);
3000
+ }
3001
+ .wf-load-file-btn {
3002
+ width: 100%; padding: 7px 12px; border-radius: 6px;
3003
+ border: 1px dashed var(--border); background: transparent;
3004
+ color: var(--text-muted); cursor: pointer; font-size: 12px;
3005
+ display: flex; align-items: center; justify-content: center; gap: 6px;
3006
+ transition: color 0.15s, border-color 0.15s, background 0.15s;
3007
+ }
3008
+ .wf-load-file-btn:hover {
3009
+ color: var(--text); border-color: var(--text-muted); background: var(--bg-card);
3010
+ }
3011
+ .wf-library-item {
3012
+ padding: 10px 12px; border-radius: 8px; cursor: pointer;
3013
+ margin-bottom: 4px; transition: background 0.15s;
3014
+ border: 1px solid transparent;
3015
+ }
3016
+ .wf-library-item:hover { background: var(--bg-card); }
3017
+ .wf-library-item.active {
3018
+ background: var(--bg-card); border-color: var(--accent, #6c63ff);
3019
+ }
3020
+ .wf-library-item-name {
3021
+ font-weight: 600; font-size: 13px; color: var(--text);
3022
+ margin-bottom: 2px;
3023
+ }
3024
+ .wf-library-item-desc {
3025
+ font-size: 11px; color: var(--text-muted);
3026
+ line-height: 1.3; display: -webkit-box;
3027
+ -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
3028
+ }
3029
+ .wf-library-item-meta {
3030
+ font-size: 10px; color: var(--text-muted); margin-top: 4px;
3031
+ display: flex; gap: 8px;
3032
+ }
3033
+ .wf-canvas-area {
3034
+ flex: 1; position: relative; overflow: hidden;
3035
+ background: var(--bg);
3036
+ background-image: radial-gradient(circle, var(--border) 1px, transparent 1px);
3037
+ background-size: 20px 20px;
3038
+ }
3039
+ .wf-canvas-toolbar {
3040
+ position: absolute; top: 12px; right: 12px; z-index: 10;
3041
+ display: flex; gap: 4px;
3042
+ }
3043
+ .wf-canvas-toolbar button {
3044
+ width: 32px; height: 32px; border-radius: 8px;
3045
+ border: 1px solid var(--border); background: var(--bg-card);
3046
+ color: var(--text); cursor: pointer; font-size: 14px;
3047
+ display: flex; align-items: center; justify-content: center;
3048
+ transition: background 0.15s;
3049
+ }
3050
+ .wf-canvas-toolbar button:hover { background: var(--bg); border-color: var(--text-muted); }
3051
+ .wf-canvas-toolbar .wf-plan-btn {
3052
+ width: auto; padding: 0 12px; gap: 4px;
3053
+ font-weight: 600; font-size: 12px;
3054
+ }
3055
+ .wf-canvas-toolbar .wf-plan-btn:disabled { opacity: 0.5; cursor: not-allowed; }
3056
+ .wf-canvas-toolbar .wf-run-btn {
3057
+ width: auto; padding: 0 12px; gap: 4px;
3058
+ background: var(--accent, #6c63ff); color: #fff; border-color: transparent;
3059
+ font-weight: 600; font-size: 12px;
3060
+ }
3061
+ .wf-canvas-toolbar .wf-run-btn:hover { opacity: 0.9; }
3062
+ .wf-canvas-toolbar .wf-run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
3063
+ .wf-toolbar-sep {
3064
+ width: 1px; height: 20px; background: var(--border); margin: 0 2px;
3065
+ }
3066
+ /* Dry-run plan overlay */
3067
+ .wf-dryrun-overlay {
3068
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0;
3069
+ z-index: 8; background: rgba(0,0,0,0.5);
3070
+ display: flex; align-items: center; justify-content: center;
3071
+ animation: wf-modal-fade-in 0.15s ease;
3072
+ }
3073
+ .wf-dryrun-panel {
3074
+ width: 90%; max-width: 520px; max-height: 70vh;
3075
+ background: var(--bg); border: 1px solid var(--border);
3076
+ border-radius: 12px; display: flex; flex-direction: column;
3077
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
3078
+ animation: wf-modal-slide-up 0.2s ease;
3079
+ }
3080
+ .wf-dryrun-header {
3081
+ display: flex; align-items: center; justify-content: space-between;
3082
+ padding: 14px 20px; border-bottom: 1px solid var(--border);
3083
+ }
3084
+ .wf-dryrun-title { font-weight: 700; font-size: 14px; color: var(--text); }
3085
+ .wf-dryrun-close {
3086
+ border: none; background: none; color: var(--text-muted);
3087
+ cursor: pointer; font-size: 20px; padding: 2px 8px;
3088
+ }
3089
+ .wf-dryrun-close:hover { color: var(--text); }
3090
+ .wf-dryrun-body {
3091
+ flex: 1; overflow-y: auto; padding: 16px 20px;
3092
+ }
3093
+ .wf-dryrun-layer {
3094
+ margin-bottom: 16px;
3095
+ }
3096
+ .wf-dryrun-layer-title {
3097
+ font-size: 11px; font-weight: 700; text-transform: uppercase;
3098
+ letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 8px;
3099
+ display: flex; align-items: center; gap: 8px;
3100
+ }
3101
+ .wf-dryrun-layer-badge {
3102
+ font-size: 10px; padding: 1px 6px; border-radius: 10px;
3103
+ background: var(--accent); color: #fff; font-weight: 600;
3104
+ }
3105
+ .wf-dryrun-step {
3106
+ padding: 8px 12px; border-radius: 6px; margin-bottom: 4px;
3107
+ background: var(--bg-card); border: 1px solid var(--border);
3108
+ display: flex; align-items: center; gap: 10px; font-size: 12px;
3109
+ }
3110
+ .wf-dryrun-step-icon { font-size: 16px; flex-shrink: 0; }
3111
+ .wf-dryrun-step-info { flex: 1; min-width: 0; }
3112
+ .wf-dryrun-step-name { font-weight: 600; color: var(--text); }
3113
+ .wf-dryrun-step-tool { color: var(--text-muted); font-size: 11px; }
3114
+ .wf-dryrun-step-cond {
3115
+ font-size: 10px; color: #FFD54F; margin-top: 2px;
3116
+ font-family: 'SF Mono', 'Fira Code', monospace;
3117
+ }
3118
+ .wf-dryrun-summary {
3119
+ padding: 12px 16px; border-radius: 8px; margin-top: 8px;
3120
+ background: var(--bg-card); border: 1px solid var(--border);
3121
+ font-size: 12px; color: var(--text-muted);
3122
+ display: flex; gap: 16px;
3123
+ }
3124
+ .wf-dryrun-stat { text-align: center; }
3125
+ .wf-dryrun-stat-value { font-size: 20px; font-weight: 700; color: var(--text); }
3126
+ .wf-dryrun-stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.3px; }
3127
+ [data-theme="light"] .wf-dryrun-panel { box-shadow: 0 8px 32px rgba(0,30,43,0.15); }
3128
+ [data-theme="light"] .wf-dryrun-step-cond { color: #944F01; }
3129
+ .wf-canvas-toolbar .wf-stop-btn {
3130
+ width: auto; padding: 0 12px; gap: 4px;
3131
+ background: #e74c3c; color: #fff; border-color: transparent;
3132
+ font-weight: 600; font-size: 12px;
3133
+ }
3134
+ .wf-canvas-toolbar .wf-stop-btn:hover { opacity: 0.9; background: #c0392b; }
3135
+ /* Execution status bar */
3136
+ .wf-exec-status {
3137
+ position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
3138
+ z-index: 10; display: flex; align-items: center; gap: 8px;
3139
+ padding: 6px 16px; border-radius: 20px;
3140
+ background: var(--bg-card); border: 1px solid var(--border);
3141
+ font-size: 12px; color: var(--text); box-shadow: 0 2px 8px rgba(0,0,0,0.15);
3142
+ }
3143
+ .wf-exec-status-dot {
3144
+ width: 8px; height: 8px; border-radius: 50%;
3145
+ background: #69F0AE; animation: wf-status-blink 1s ease-in-out infinite;
3146
+ }
3147
+ .wf-exec-status.error .wf-exec-status-dot { background: #e74c3c; animation: none; }
3148
+ .wf-exec-status.stopped .wf-exec-status-dot { background: #FFB74D; animation: none; }
3149
+ .wf-exec-status.done .wf-exec-status-dot { background: #69F0AE; animation: none; }
3150
+ @keyframes wf-status-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
3151
+ .wf-exec-status-time { color: var(--text-muted); font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; }
3152
+ /* Output modal */
3153
+ .wf-output-modal-backdrop {
3154
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
3155
+ background: rgba(0,0,0,0.6); z-index: 9999;
3156
+ display: flex; align-items: center; justify-content: center;
3157
+ animation: wf-modal-fade-in 0.15s ease;
3158
+ }
3159
+ @keyframes wf-modal-fade-in { from { opacity: 0; } to { opacity: 1; } }
3160
+ .wf-output-modal {
3161
+ width: 90%; max-width: 800px; max-height: 80vh;
3162
+ background: var(--bg); border: 1px solid var(--border);
3163
+ border-radius: 12px; display: flex; flex-direction: column;
3164
+ box-shadow: 0 8px 32px rgba(0,0,0,0.3);
3165
+ animation: wf-modal-slide-up 0.2s ease;
3166
+ }
3167
+ @keyframes wf-modal-slide-up { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
3168
+ .wf-output-modal-header {
3169
+ display: flex; align-items: center; justify-content: space-between;
3170
+ padding: 14px 20px; border-bottom: 1px solid var(--border);
3171
+ }
3172
+ .wf-output-modal-title { font-weight: 700; font-size: 14px; color: var(--text); }
3173
+ .wf-output-modal-actions { display: flex; align-items: center; gap: 6px; }
3174
+ .wf-output-modal-btn {
3175
+ display: inline-flex; align-items: center; gap: 4px;
3176
+ padding: 5px 10px; border-radius: 6px;
3177
+ border: 1px solid var(--border); background: var(--bg-card);
3178
+ color: var(--text); cursor: pointer; font-size: 12px;
3179
+ transition: background 0.15s, border-color 0.15s;
3180
+ }
3181
+ .wf-output-modal-btn:hover { background: var(--bg); border-color: var(--text-muted); }
3182
+ .wf-output-modal-btn.close { border: none; font-size: 20px; padding: 2px 8px; }
3183
+ .wf-output-modal-body {
3184
+ flex: 1; overflow: auto; padding: 16px 20px;
3185
+ margin: 0; font-family: 'SF Mono', 'Fira Code', monospace;
3186
+ font-size: 12px; line-height: 1.5; color: var(--text);
3187
+ white-space: pre-wrap; word-break: break-word;
3188
+ }
3189
+ /* Expand button for output sections */
3190
+ .wf-output-expand-btn {
3191
+ display: inline-flex; align-items: center; gap: 4px;
3192
+ padding: 3px 8px; border-radius: 4px; margin-top: 6px;
3193
+ border: 1px solid var(--border); background: transparent;
3194
+ color: var(--text-muted); cursor: pointer; font-size: 10px;
3195
+ transition: color 0.15s, border-color 0.15s;
3196
+ }
3197
+ .wf-output-expand-btn:hover { color: var(--text); border-color: var(--text-muted); }
3198
+ #wf-canvas {
3199
+ width: 100%; height: 100%; display: block;
3200
+ }
3201
+ /* SVG node styles */
3202
+ .wf-node { cursor: pointer; }
3203
+ .wf-node rect {
3204
+ rx: 10; ry: 10; stroke-width: 2;
3205
+ transition: stroke 0.2s, filter 0.2s;
3206
+ }
3207
+ .wf-node:hover rect { filter: brightness(1.1); }
3208
+ .wf-node.selected rect { stroke-width: 3; stroke: var(--accent, #6c63ff); }
3209
+ .wf-node-label {
3210
+ font-size: 12px; font-weight: 600; fill: #fff;
3211
+ pointer-events: none; text-anchor: middle; dominant-baseline: central;
3212
+ }
3213
+ .wf-node-icon {
3214
+ font-size: 16px; pointer-events: none;
3215
+ text-anchor: middle; dominant-baseline: central;
3216
+ }
3217
+ .wf-node-badge {
3218
+ font-size: 10px; fill: rgba(255,255,255,0.7);
3219
+ pointer-events: none; text-anchor: middle; dominant-baseline: central;
3220
+ }
3221
+ .wf-node-condition {
3222
+ font-size: 10px; fill: #FFD54F;
3223
+ pointer-events: none; text-anchor: end;
3224
+ }
3225
+ /* Execution states */
3226
+ .wf-node--pending rect { opacity: 0.5; }
3227
+ .wf-node--running rect {
3228
+ animation: wf-pulse 1.2s ease-in-out infinite;
3229
+ }
3230
+ @keyframes wf-pulse {
3231
+ 0%, 100% { filter: drop-shadow(0 0 4px rgba(108,99,255,0.4)); }
3232
+ 50% { filter: drop-shadow(0 0 12px rgba(108,99,255,0.8)); }
3233
+ }
3234
+ .wf-node--completed rect { opacity: 1; }
3235
+ .wf-node--skipped rect { opacity: 0.3; }
3236
+ .wf-node--error rect { stroke: #e74c3c !important; stroke-width: 3; }
3237
+ .wf-node-status {
3238
+ font-size: 12px; pointer-events: none; fill: #fff;
3239
+ text-anchor: middle; dominant-baseline: central;
3240
+ }
3241
+ .wf-node--error .wf-node-status { fill: #e74c3c; }
3242
+ .wf-node--skipped .wf-node-status { fill: rgba(255,255,255,0.5); }
3243
+ .wf-node-time {
3244
+ font-size: 9px; fill: rgba(255,255,255,0.6);
3245
+ pointer-events: none; text-anchor: middle;
3246
+ }
3247
+ /* Port styles */
3248
+ .wf-port { pointer-events: none; transition: fill 0.2s, stroke 0.2s; }
3249
+ .wf-port-in { fill: var(--bg-card); }
3250
+ .wf-port-out { fill: currentColor; stroke: rgba(255,255,255,0.8); }
3251
+ .wf-node:hover .wf-port-out { filter: brightness(1.3); }
3252
+ .wf-node--completed .wf-port-out { fill: #69F0AE; stroke: #fff; }
3253
+ .wf-node--error .wf-port-in { fill: #e74c3c; }
3254
+ /* Edge styles */
3255
+ .wf-edge {
3256
+ fill: none; stroke: #6b7b8d; stroke-width: 2;
3257
+ opacity: 0.5; transition: opacity 0.3s, stroke 0.3s;
3258
+ stroke-linecap: round;
3259
+ }
3260
+ .wf-edge--backward {
3261
+ stroke-dasharray: 6 4; opacity: 0.35;
3262
+ }
3263
+ .wf-edge--active {
3264
+ stroke: var(--accent, #6c63ff); opacity: 0.85;
3265
+ stroke-dasharray: 8 4;
3266
+ animation: wf-flow 0.6s linear infinite;
3267
+ }
3268
+ @keyframes wf-flow { to { stroke-dashoffset: -12; } }
3269
+ .wf-edge--complete { stroke: var(--accent, #6c63ff); opacity: 0.6; stroke-dasharray: none; }
3270
+ /* Inspector */
3271
+ .wf-inspector {
3272
+ flex-shrink: 0; position: relative;
3273
+ display: flex; flex-direction: row;
3274
+ background: var(--bg);
3275
+ transition: width 0.25s ease;
3276
+ width: 300px;
3277
+ overflow: hidden;
3278
+ align-self: stretch;
3279
+ }
3280
+ .wf-inspector.collapsed {
3281
+ width: 28px;
3282
+ }
3283
+ .wf-inspector-toggle {
3284
+ width: 28px; flex-shrink: 0; align-self: stretch;
3285
+ border: none; border-left: 1px solid var(--border);
3286
+ background: var(--bg); color: var(--text-muted);
3287
+ cursor: pointer; font-size: 14px; padding: 0;
3288
+ display: flex; align-items: center; justify-content: center;
3289
+ transition: color 0.15s, background 0.15s;
3290
+ }
3291
+ .wf-inspector-toggle:hover {
3292
+ color: var(--text); background: var(--bg-card);
3293
+ }
3294
+ .wf-inspector.collapsed .wf-inspector-toggle {
3295
+ border-left: 1px solid var(--border);
3296
+ }
3297
+ .wf-inspector.collapsed .wf-inspector-content {
3298
+ display: none;
3299
+ }
3300
+ .wf-inspector-content {
3301
+ flex: 1; display: flex; flex-direction: column;
3302
+ border-left: 1px solid var(--border);
3303
+ overflow-y: auto; min-width: 0; height: 100%;
3304
+ }
3305
+ .wf-inspector-header {
3306
+ padding: 14px 16px 10px; font-weight: 700; font-size: 13px;
3307
+ color: var(--text); border-bottom: 1px solid var(--border);
3308
+ }
3309
+ .wf-inspector-body {
3310
+ padding: 16px; flex: 1; overflow-y: auto;
3311
+ }
3312
+ .wf-inspector-empty {
3313
+ color: var(--text-muted); font-size: 13px; text-align: center;
3314
+ padding: 40px 16px;
3315
+ }
3316
+ .wf-inspector-section {
3317
+ margin-bottom: 16px;
3318
+ }
3319
+ .wf-inspector-section-title {
3320
+ font-size: 11px; font-weight: 700; text-transform: uppercase;
3321
+ letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 6px;
3322
+ }
3323
+ .wf-inspector-field {
3324
+ font-size: 12px; color: var(--text); margin-bottom: 4px;
3325
+ display: flex; gap: 6px;
3326
+ }
3327
+ .wf-inspector-field-label {
3328
+ font-weight: 600; min-width: 60px; color: var(--text-muted);
3329
+ }
3330
+ .wf-inspector-field-value {
3331
+ word-break: break-all;
3332
+ }
3333
+ .wf-inspector-code {
3334
+ font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px;
3335
+ background: var(--bg-card); border: 1px solid var(--border);
3336
+ border-radius: 6px; padding: 8px; overflow-x: auto;
3337
+ white-space: pre-wrap; color: var(--text); margin-top: 4px;
3338
+ }
3339
+ .wf-tool-badge {
3340
+ display: inline-flex; align-items: center; gap: 4px;
3341
+ padding: 2px 8px; border-radius: 10px; font-size: 11px;
3342
+ font-weight: 600; color: #fff;
3343
+ }
3344
+ .wf-inspector-input {
3345
+ width: 100%; padding: 6px 10px; border-radius: 6px;
3346
+ border: 1px solid var(--border); background: var(--bg-card);
3347
+ color: var(--text); font-size: 12px; margin-top: 4px;
3348
+ }
3349
+ .wf-inspector-input:focus {
3350
+ outline: none; border-color: var(--accent, #6c63ff);
3351
+ }
3352
+ .wf-inspector-btn {
3353
+ width: 100%; padding: 8px 16px; border-radius: 8px;
3354
+ border: none; background: var(--accent, #6c63ff);
3355
+ color: #fff; font-weight: 600; font-size: 13px;
3356
+ cursor: pointer; margin-top: 12px;
3357
+ }
3358
+ .wf-inspector-btn:hover { opacity: 0.9; }
3359
+ .wf-inspector-btn:disabled { opacity: 0.5; cursor: not-allowed; }
3360
+ .wf-inspector-result {
3361
+ margin-top: 8px; padding: 8px; border-radius: 6px;
3362
+ background: var(--bg-card); border: 1px solid var(--border);
3363
+ font-size: 11px; max-height: 200px; overflow-y: auto;
3364
+ }
3365
+ .wf-inspector-result.success { border-color: #69F0AE; }
3366
+ .wf-inspector-result.error { border-color: #e74c3c; }
3367
+ /* Responsive */
3368
+ @media (max-width: 900px) {
3369
+ .wf-library { display: none; }
3370
+ .wf-inspector:not(.collapsed) { width: 240px; }
3371
+ }
3372
+ @media (max-width: 600px) {
3373
+ .wf-inspector { display: none; }
3374
+ }
3375
+ /* Workflow visualizer light mode */
3376
+ [data-theme="light"] .wf-node-label { fill: #001E2B; }
3377
+ [data-theme="light"] .wf-node-icon { fill: #001E2B; }
3378
+ [data-theme="light"] .wf-node-badge { fill: rgba(0,30,43,0.55); }
3379
+ [data-theme="light"] .wf-node-time { fill: rgba(0,30,43,0.5); }
3380
+ [data-theme="light"] .wf-node-condition { fill: #944F01; }
3381
+ [data-theme="light"] .wf-node-status { fill: #001E2B; }
3382
+ [data-theme="light"] .wf-node--skipped .wf-node-status { fill: rgba(0,30,43,0.4); }
3383
+ [data-theme="light"] .wf-node--error .wf-node-status { fill: #DB3030; }
3384
+ [data-theme="light"] .wf-node rect { stroke-opacity: 0.7; }
3385
+ [data-theme="light"] .wf-node.selected rect { stroke: var(--accent); }
3386
+ [data-theme="light"] .wf-edge { stroke: #889397; opacity: 0.45; }
3387
+ [data-theme="light"] .wf-edge--backward { opacity: 0.3; }
3388
+ [data-theme="light"] .wf-edge--active { stroke: var(--accent); opacity: 0.7; }
3389
+ [data-theme="light"] .wf-edge--complete { stroke: var(--accent); opacity: 0.5; }
3390
+ [data-theme="light"] .wf-port-in { fill: var(--bg); }
3391
+ [data-theme="light"] .wf-port-out { stroke: rgba(0,30,43,0.3); }
3392
+ [data-theme="light"] .wf-node--completed .wf-port-out { fill: #009E80; stroke: var(--bg); }
3393
+ [data-theme="light"] .wf-canvas-area { background: var(--bg-surface); }
3394
+ [data-theme="light"] .wf-inspector-result.success { border-color: #009E80; }
3395
+ [data-theme="light"] .wf-inspector-result.error { border-color: #DB3030; }
3396
+ [data-theme="light"] .wf-exec-status { box-shadow: 0 2px 8px rgba(0,30,43,0.08); }
3397
+ [data-theme="light"] .wf-run-btn { background: var(--accent); }
3398
+ [data-theme="light"] .wf-output-modal { box-shadow: 0 8px 32px rgba(0,30,43,0.15); }
3399
+ @keyframes wf-pulse-light {
3400
+ 0%, 100% { filter: drop-shadow(0 0 4px rgba(0,158,128,0.3)); }
3401
+ 50% { filter: drop-shadow(0 0 12px rgba(0,158,128,0.6)); }
3402
+ }
3403
+ [data-theme="light"] .wf-node--running rect { animation-name: wf-pulse-light; }
3404
+ /* Empty canvas state */
3405
+ .wf-canvas-empty {
3406
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
3407
+ text-align: center; color: var(--text-muted); pointer-events: none;
3408
+ }
3409
+ .wf-canvas-empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
3410
+ .wf-canvas-empty-text { font-size: 14px; }
2753
3411
  </style>
2754
3412
  </head>
2755
3413
  <body>
@@ -2801,6 +3459,9 @@ select:focus { outline: none; border-color: var(--accent); }
2801
3459
  <symbol id="lg-shield" viewBox="0 0 16 16">
2802
3460
  <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"/>
2803
3461
  </symbol>
3462
+ <symbol id="lg-pulse" viewBox="0 0 16 16">
3463
+ <path d="M1 8h3l2-5 2 10 2-5h5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
3464
+ </symbol>
2804
3465
  </svg>
2805
3466
 
2806
3467
  <div class="app-shell">
@@ -2821,6 +3482,7 @@ select:focus { outline: none; border-color: var(--accent); }
2821
3482
  <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
3483
  <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
3484
  <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>
3485
+ <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 width="16" height="16" viewBox="0 0 16 16"><circle cx="3" cy="8" r="2" fill="none" stroke="currentColor" stroke-width="1.3"/><circle cx="13" cy="4" r="2" fill="none" stroke="currentColor" stroke-width="1.3"/><circle cx="13" cy="12" r="2" fill="none" stroke="currentColor" stroke-width="1.3"/><line x1="5" y1="7" x2="11" y2="4.5" stroke="currentColor" stroke-width="1.3"/><line x1="5" y1="9" x2="11" y2="11.5" stroke="currentColor" stroke-width="1.3"/></svg></span><span>Workflows</span></button>
2824
3486
  </div>
2825
3487
  <div class="sidebar-nav-divider"></div>
2826
3488
  <div class="sidebar-nav-group" role="tablist" aria-label="Learn">
@@ -2840,10 +3502,16 @@ select:focus { outline: none; border-color: var(--accent); }
2840
3502
  </div>
2841
3503
  <div style="display:flex;align-items:center;justify-content:space-between;">
2842
3504
  <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>
3505
+ <div style="display:flex;align-items:center;gap:6px;">
3506
+ <a class="sidebar-docs-link" href="https://docs.vaicli.com" target="_blank" rel="noopener" title="Documentation (F1)">
3507
+ <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>
3508
+ <span>Docs</span>
3509
+ </a>
3510
+ <button class="sidebar-bug-link" id="bugButton" title="Report a Bug">
3511
+ <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>
3512
+ <span class="sidebar-bug-label">Bug</span>
3513
+ </button>
3514
+ </div>
2847
3515
  </div>
2848
3516
  </div>
2849
3517
  </aside>
@@ -2873,6 +3541,7 @@ select:focus { outline: none; border-color: var(--accent); }
2873
3541
  <h2 class="page-header-title">Embed</h2>
2874
3542
  <p class="page-header-subtitle">Generate vector embeddings for text</p>
2875
3543
  <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>
3544
+ <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
3545
  </div>
2877
3546
  <div class="card">
2878
3547
  <div class="card-title">Input Text</div>
@@ -2936,7 +3605,8 @@ select:focus { outline: none; border-color: var(--accent); }
2936
3605
  <div class="page-header">
2937
3606
  <h2 class="page-header-title">Compare</h2>
2938
3607
  <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>
3608
+ <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>
3609
+ <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
3610
  </div>
2941
3611
  <div class="compare-grid">
2942
3612
  <div class="card">
@@ -2990,7 +3660,8 @@ select:focus { outline: none; border-color: var(--accent); }
2990
3660
  <div class="page-header">
2991
3661
  <h2 class="page-header-title">Rerank</h2>
2992
3662
  <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>
3663
+ <p class="page-header-hint">Enter a search query and a set of documents: the reranker scores and sorts them by semantic relevance.</p>
3664
+ <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
3665
  </div>
2995
3666
  <div class="card">
2996
3667
  <div class="card-title">Query</div>
@@ -3041,7 +3712,8 @@ Semantic search understands meaning beyond keyword matching</textarea>
3041
3712
  <div class="page-header">
3042
3713
  <h2 class="page-header-title">Multimodal</h2>
3043
3714
  <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>
3715
+ <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>
3716
+ <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
3717
  </div>
3046
3718
 
3047
3719
  <!-- Section A: Image ↔ Text Similarity -->
@@ -3051,7 +3723,7 @@ Semantic search understands meaning beyond keyword matching</textarea>
3051
3723
  <div class="mm-drop-zone" id="mmDropZone">
3052
3724
  <div class="mm-drop-icon">🖼️</div>
3053
3725
  <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>
3726
+ <div class="mm-drop-hint">PNG, JPEG, WebP, GIF, max 20 MB. Paste from clipboard (⌘V)</div>
3055
3727
  </div>
3056
3728
  <input type="file" id="mmFileInput" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
3057
3729
  <div class="mm-preview" id="mmPreview">
@@ -3157,6 +3829,7 @@ Semantic search understands meaning beyond keyword matching</textarea>
3157
3829
  <h2 class="page-header-title">Benchmark</h2>
3158
3830
  <p class="page-header-subtitle">Compare model speed, cost, and quality</p>
3159
3831
  <p class="page-header-hint">Run latency tests, compare ranking accuracy, analyze quantization trade-offs, and estimate costs across models.</p>
3832
+ <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
3833
  </div>
3161
3834
 
3162
3835
  <!-- Sub-panel switcher -->
@@ -3664,6 +4337,80 @@ Reranking models rescore initial search results to improve relevance ordering.</
3664
4337
  </div>
3665
4338
  </div>
3666
4339
 
4340
+ <!-- ========== WORKFLOWS TAB ========== -->
4341
+ <div class="tab-panel" id="tab-workflows" role="tabpanel" aria-labelledby="tab-btn-workflows" tabindex="0">
4342
+ <div class="wf-container">
4343
+ <div class="wf-library">
4344
+ <div class="wf-library-header">
4345
+ <span>Workflows</span>
4346
+ </div>
4347
+ <div class="wf-library-list" id="wfLibraryList">
4348
+ <div style="padding: 16px; color: var(--text-muted); font-size: 12px;">Loading...</div>
4349
+ </div>
4350
+ <div class="wf-library-footer">
4351
+ <button class="wf-load-file-btn" onclick="wfLoadFromFile()" title="Load workflow JSON from file">
4352
+ <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>
4353
+ Load File
4354
+ </button>
4355
+ <input type="file" id="wfFileInput" accept=".json" style="display:none;" onchange="wfHandleFileLoad(event)">
4356
+ </div>
4357
+ </div>
4358
+ <div class="wf-canvas-area">
4359
+ <div class="wf-canvas-toolbar" id="wfToolbar">
4360
+ <button onclick="wfZoom(1)" title="Zoom in">+</button>
4361
+ <button onclick="wfZoom(-1)" title="Zoom out">&minus;</button>
4362
+ <button onclick="wfFitToView()" title="Fit to view">&#8862;</button>
4363
+ <button onclick="wfResetExecution()" title="Reset">&#8635;</button>
4364
+ <span class="wf-toolbar-sep"></span>
4365
+ <button class="wf-plan-btn" onclick="wfDryRun()" id="wfDryRunBtn" disabled title="Dry run: show execution plan">&#9881; Plan</button>
4366
+ <button class="wf-run-btn" id="wfRunBtn" onclick="wfExecute()" disabled title="Run workflow">&#9654; Run</button>
4367
+ <button class="wf-stop-btn" id="wfStopBtn" onclick="wfStopExecution()" style="display:none;" title="Stop workflow">&#9632; Stop</button>
4368
+ <span class="wf-toolbar-sep"></span>
4369
+ <button onclick="wfExportJson()" id="wfExportBtn" disabled title="Export workflow JSON">
4370
+ <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>
4371
+ </button>
4372
+ </div>
4373
+ <!-- Execution status bar -->
4374
+ <div class="wf-exec-status" id="wfExecStatus" style="display:none;">
4375
+ <span class="wf-exec-status-dot"></span>
4376
+ <span class="wf-exec-status-text" id="wfExecStatusText">Running...</span>
4377
+ <span class="wf-exec-status-time" id="wfExecStatusTime"></span>
4378
+ </div>
4379
+ <div class="wf-canvas-empty" id="wfCanvasEmpty">
4380
+ <div class="wf-canvas-empty-icon">&#9881;</div>
4381
+ <div class="wf-canvas-empty-text">Select a workflow from the library</div>
4382
+ </div>
4383
+ <svg id="wf-canvas" xmlns="http://www.w3.org/2000/svg"></svg>
4384
+ </div>
4385
+ <div class="wf-inspector collapsed" id="wfInspector">
4386
+ <button class="wf-inspector-toggle" id="wfInspectorToggle" onclick="wfToggleInspector()" title="Toggle inspector">&lsaquo;</button>
4387
+ <div class="wf-inspector-content">
4388
+ <div class="wf-inspector-header" id="wfInspectorHeader">Inspector</div>
4389
+ <div class="wf-inspector-body" id="wfInspectorBody">
4390
+ <div class="wf-inspector-empty">Click a node to inspect</div>
4391
+ </div>
4392
+ </div>
4393
+ </div>
4394
+ </div>
4395
+ </div>
4396
+
4397
+ <!-- ── Workflow Output Modal ── -->
4398
+ <div class="wf-output-modal-backdrop" id="wfOutputModalBackdrop" style="display:none;" onclick="wfCloseOutputModal()">
4399
+ <div class="wf-output-modal" onclick="event.stopPropagation()">
4400
+ <div class="wf-output-modal-header">
4401
+ <span class="wf-output-modal-title" id="wfOutputModalTitle">Output</span>
4402
+ <div class="wf-output-modal-actions">
4403
+ <button class="wf-output-modal-btn" onclick="wfCopyOutput()" title="Copy to clipboard" id="wfOutputCopyBtn">
4404
+ <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>
4405
+ <span id="wfOutputCopyLabel">Copy</span>
4406
+ </button>
4407
+ <button class="wf-output-modal-btn close" onclick="wfCloseOutputModal()" title="Close">&times;</button>
4408
+ </div>
4409
+ </div>
4410
+ <pre class="wf-output-modal-body" id="wfOutputModalBody"></pre>
4411
+ </div>
4412
+ </div>
4413
+
3667
4414
  <!-- ========== ABOUT TAB ========== -->
3668
4415
  <div class="tab-panel" id="tab-about" role="tabpanel" aria-labelledby="tab-btn-about" tabindex="0">
3669
4416
  <div class="about-container">
@@ -3709,8 +4456,11 @@ Reranking models rescore initial search results to improve relevance ordering.</
3709
4456
  <strong>⚖️ Compare</strong> — Measure similarity with cosine, dot product &amp; euclidean distance<br>
3710
4457
  <strong>🔍 Search</strong> — Semantic search with optional reranking<br>
3711
4458
  <strong>🔮 Multimodal</strong> — Compare images and text in the same vector space with voyage-multimodal-3.5<br>
4459
+ <strong>🛠️ Generate</strong> — Generate code snippets and scaffold full projects with templates<br>
4460
+ <strong>💬 Chat</strong> — RAG-powered chat with your documents, configurable system prompts<br>
4461
+ <strong>🔄 Workflows</strong> — Multi-step agent workflows with thinking panels and tool orchestration<br>
3712
4462
  <strong>⏱ Benchmark</strong> — Compare model latency, ranking quality, and costs<br>
3713
- <strong>📚 Explore</strong> — Learn about embeddings, vector search, multimodal, RAG, and more
4463
+ <strong>📚 Explore</strong> — 22 interactive concepts covering embeddings, vector search, multimodal, RAG, and more
3714
4464
  </div>
3715
4465
  </div>
3716
4466
 
@@ -3741,9 +4491,12 @@ Reranking models rescore initial search results to improve relevance ordering.</
3741
4491
  <div class="about-section" style="padding-bottom:0;">
3742
4492
  <div class="about-section-title">What's New</div>
3743
4493
  <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 🕹️
4494
+ <strong>v1.26</strong> — Agent workflows with thinking panel &amp; markdown rendering, multi-step tool orchestration<br>
4495
+ <strong>v1.25</strong> Code generation &amp; project scaffolding tabs<br>
4496
+ <strong>v1.24</strong> MCP server install/uninstall/status commands, Electron app v1.5<br>
4497
+ <strong>v1.23</strong> — MCP server (expose vai tools to AI agents), HTTP transport, bearer auth, 71+ MCP tests<br>
4498
+ <strong>v1.22</strong> — RAG chat with smart source labels, configurable system prompts, streaming responses<br>
4499
+ <strong>v1.2</strong> — Multimodal tab, 4 new Explore concepts, auto-update, hidden easter egg 🕹️
3747
4500
  </div>
3748
4501
  </div>
3749
4502
  </div>
@@ -3760,6 +4513,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
3760
4513
  <div class="page-header">
3761
4514
  <h2 class="page-header-title">Generate</h2>
3762
4515
  <p class="page-header-subtitle">Generate code and scaffold projects</p>
4516
+ <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
4517
  </div>
3764
4518
 
3765
4519
  <!-- Mode Toggle -->
@@ -3933,7 +4687,8 @@ Reranking models rescore initial search results to improve relevance ordering.</
3933
4687
  <div class="page-header">
3934
4688
  <h2 class="page-header-title">Explore</h2>
3935
4689
  <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>
4690
+ <p class="page-header-hint">Browse interactive explanations of key topics, from cosine similarity to quantization to RAG pipelines.</p>
4691
+ <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
4692
  </div>
3938
4693
  <div style="margin-bottom:16px;">
3939
4694
  <input type="text" id="exploreSearch" placeholder="🔍 Search concepts..." oninput="filterExplore()" style="max-width:400px;" aria-label="Search concepts">
@@ -3990,6 +4745,10 @@ Reranking models rescore initial search results to improve relevance ordering.</
3990
4745
  <svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-shield"/></svg>
3991
4746
  <span>Data &amp; Privacy</span>
3992
4747
  </button>
4748
+ <button class="settings-nav-item" data-settings-section="health">
4749
+ <svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-pulse"/></svg>
4750
+ <span>Health Check</span>
4751
+ </button>
3993
4752
  </nav>
3994
4753
 
3995
4754
  <!-- Settings content panels -->
@@ -4349,6 +5108,30 @@ Reranking models rescore initial search results to improve relevance ordering.</
4349
5108
  </div>
4350
5109
  </div>
4351
5110
 
5111
+ <!-- ── Health Check ── -->
5112
+ <div class="settings-panel" id="settings-health">
5113
+ <div class="settings-panel-header">
5114
+ <h3 class="settings-panel-title">Health Check</h3>
5115
+ <p class="settings-panel-subtitle">Validate your vai setup and connectivity</p>
5116
+ </div>
5117
+ <div class="settings-section">
5118
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
5119
+ <span class="settings-section-title" style="margin:0;">Diagnostics</span>
5120
+ <button class="btn" id="doctorRunBtn" style="padding:6px 16px;font-size:13px;">Run Health Check</button>
5121
+ </div>
5122
+ <div id="doctorResults" style="display:none;">
5123
+ <div id="doctorCheckList" style="display:flex;flex-direction:column;gap:6px;"></div>
5124
+ <div id="doctorSummary" style="margin-top:16px;padding:12px 16px;border-radius:var(--radius);font-size:13px;"></div>
5125
+ </div>
5126
+ <div id="doctorLoading" style="display:none;text-align:center;padding:24px 0;color:var(--text-muted);font-size:13px;">
5127
+ Running health checks...
5128
+ </div>
5129
+ <div id="doctorEmpty" style="text-align:center;padding:24px 0;color:var(--text-muted);font-size:13px;">
5130
+ Click "Run Health Check" to validate your setup.
5131
+ </div>
5132
+ </div>
5133
+ </div>
5134
+
4352
5135
  <div style="text-align:center;padding:8px 0;">
4353
5136
  <span class="settings-saved" id="settingsSavedMsg">✓ Saved</span>
4354
5137
  </div>
@@ -5316,7 +6099,9 @@ async function loadConcepts() {
5316
6099
  }
5317
6100
 
5318
6101
  function escapeHtml(str) {
5319
- return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
6102
+ if (str == null) return '';
6103
+ const s = typeof str === 'string' ? str : String(str);
6104
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
5320
6105
  }
5321
6106
 
5322
6107
  function buildExploreCards() {
@@ -6676,6 +7461,95 @@ function initSettings() {
6676
7461
 
6677
7462
  // Set up settings sub-navigation
6678
7463
  setupSettingsNav();
7464
+
7465
+ // Set up doctor health check button
7466
+ setupDoctorPanel();
7467
+ }
7468
+
7469
+ // ── Doctor Health Check ──
7470
+ function setupDoctorPanel() {
7471
+ const runBtn = document.getElementById('doctorRunBtn');
7472
+ if (!runBtn) return;
7473
+ runBtn.addEventListener('click', runDoctorCheck);
7474
+ }
7475
+
7476
+ async function runDoctorCheck() {
7477
+ const btn = document.getElementById('doctorRunBtn');
7478
+ const loading = document.getElementById('doctorLoading');
7479
+ const results = document.getElementById('doctorResults');
7480
+ const empty = document.getElementById('doctorEmpty');
7481
+ const checkList = document.getElementById('doctorCheckList');
7482
+ const summary = document.getElementById('doctorSummary');
7483
+
7484
+ btn.disabled = true;
7485
+ btn.textContent = 'Running...';
7486
+ empty.style.display = 'none';
7487
+ results.style.display = 'none';
7488
+ loading.style.display = 'block';
7489
+
7490
+ try {
7491
+ const res = await fetch('/api/doctor');
7492
+ const data = await res.json();
7493
+
7494
+ checkList.innerHTML = '';
7495
+ let hasError = false;
7496
+ let hasWarning = false;
7497
+
7498
+ for (const [key, check] of Object.entries(data)) {
7499
+ let icon, badgeClass, badgeText;
7500
+ if (check.ok === true) {
7501
+ icon = '&#x2713;';
7502
+ badgeClass = 'pass';
7503
+ badgeText = 'pass';
7504
+ } else if (check.ok === false) {
7505
+ icon = '&#x2717;';
7506
+ if (check.required) { hasError = true; badgeClass = 'fail'; badgeText = 'fail'; }
7507
+ else { hasWarning = true; badgeClass = 'warn'; badgeText = 'warn'; }
7508
+ } else {
7509
+ icon = '&#x26A0;';
7510
+ hasWarning = true;
7511
+ badgeClass = 'optional';
7512
+ badgeText = 'optional';
7513
+ }
7514
+
7515
+ const el = document.createElement('div');
7516
+ el.className = 'doctor-check';
7517
+ el.innerHTML = `
7518
+ <span class="doctor-check-icon" style="color:var(--${badgeClass === 'pass' ? 'success' : badgeClass === 'fail' ? 'error' : 'warning'})">${icon}</span>
7519
+ <div class="doctor-check-body">
7520
+ <div class="doctor-check-name">${check.name}</div>
7521
+ <div class="doctor-check-msg">${check.message || ''}</div>
7522
+ ${check.hint ? `<div class="doctor-check-hint">${check.hint}</div>` : ''}
7523
+ </div>
7524
+ <span class="doctor-check-badge ${badgeClass}">${badgeText}</span>
7525
+ `;
7526
+ checkList.appendChild(el);
7527
+ }
7528
+
7529
+ if (hasError) {
7530
+ summary.style.background = 'rgba(255,105,96,0.08)';
7531
+ summary.style.color = 'var(--error)';
7532
+ summary.textContent = 'Some required checks failed. Fix the issues above to use vai.';
7533
+ } else if (hasWarning) {
7534
+ summary.style.background = 'rgba(255,192,16,0.08)';
7535
+ summary.style.color = 'var(--warning)';
7536
+ summary.textContent = 'All required checks passed. Some optional features are not configured.';
7537
+ } else {
7538
+ summary.style.background = 'rgba(0,212,170,0.08)';
7539
+ summary.style.color = 'var(--success)';
7540
+ summary.textContent = 'All checks passed. vai is ready to use!';
7541
+ }
7542
+
7543
+ loading.style.display = 'none';
7544
+ results.style.display = 'block';
7545
+ } catch (err) {
7546
+ loading.style.display = 'none';
7547
+ empty.style.display = 'block';
7548
+ empty.textContent = 'Health check failed: ' + err.message;
7549
+ }
7550
+
7551
+ btn.disabled = false;
7552
+ btn.textContent = 'Run Health Check';
6679
7553
  }
6680
7554
 
6681
7555
  // ── Update Checker ──
@@ -6867,6 +7741,13 @@ function initOnboarding() {
6867
7741
  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
7742
  arrow: 'left',
6869
7743
  },
7744
+ {
7745
+ target: '[data-tab="workflows"]',
7746
+ icon: '\u2699\uFE0F',
7747
+ title: 'Workflow Visualizer',
7748
+ body: '<strong>Visualize and execute workflows</strong> as interactive DAGs. Browse built-in templates, inspect step configuration, and watch execution animate in real time.',
7749
+ arrow: 'left',
7750
+ },
6870
7751
  {
6871
7752
  target: '[data-tab="benchmark"]',
6872
7753
  icon: '⏱️',
@@ -8341,6 +9222,145 @@ function renderMarkdown(md) {
8341
9222
  return result.join('\n');
8342
9223
  }
8343
9224
 
9225
+ /**
9226
+ * Tool metadata: icon, label, and a function to summarize the call for the thinking panel.
9227
+ */
9228
+ const TOOL_META = {
9229
+ vai_query: { icon: '\uD83D\uDD0D', label: 'RAG Query', verb: 'Searching', descFn: a => a.query ? `"${a.query}"` : '' },
9230
+ vai_search: { icon: '\uD83D\uDD0E', label: 'Vector Search', verb: 'Searching vectors', descFn: a => a.query ? `"${a.query}"` : '' },
9231
+ vai_rerank: { icon: '\u2195\uFE0F', label: 'Rerank', verb: 'Reranking', descFn: a => a.query ? `${a.documents?.length || '?'} docs for "${a.query}"` : '' },
9232
+ vai_embed: { icon: '\uD83E\uDDE0', label: 'Embed', verb: 'Embedding', descFn: a => a.text ? `"${a.text.slice(0, 60)}${a.text.length > 60 ? '...' : ''}"` : '' },
9233
+ vai_similarity: { icon: '\uD83C\uDFAF', label: 'Similarity', verb: 'Comparing', descFn: a => a.text1 ? `two texts` : '' },
9234
+ vai_collections: { icon: '\uD83D\uDDC4\uFE0F', label: 'Collections', verb: 'Discovering', descFn: a => a.db ? `in ${a.db}` : 'available databases' },
9235
+ vai_models: { icon: '\uD83E\uDD16', label: 'Models', verb: 'Listing', descFn: () => 'available models' },
9236
+ vai_topics: { icon: '\uD83D\uDCDA', label: 'Topics', verb: 'Browsing', descFn: () => 'educational topics' },
9237
+ vai_explain: { icon: '\uD83D\uDCA1', label: 'Explain', verb: 'Explaining', descFn: a => a.topic || '' },
9238
+ vai_estimate: { icon: '\uD83D\uDCB0', label: 'Cost Estimate', verb: 'Estimating', descFn: a => a.docs ? `${a.docs} docs` : '' },
9239
+ vai_ingest: { icon: '\uD83D\uDCE5', label: 'Ingest', verb: 'Ingesting', descFn: a => a.source || 'document' },
9240
+ };
9241
+ const DEFAULT_TOOL_META = { icon: '\u2699\uFE0F', label: '', verb: 'Running', descFn: () => '' };
9242
+
9243
+ /**
9244
+ * Create the thinking panel <details> element.
9245
+ * Returns { panel, timeline, addStep, finalize }.
9246
+ */
9247
+ function createThinkingPanel() {
9248
+ const panel = document.createElement('details');
9249
+ panel.className = 'chat-thinking';
9250
+ panel.open = true;
9251
+
9252
+ const summary = document.createElement('summary');
9253
+ summary.innerHTML =
9254
+ '<span class="thinking-icon">\uD83E\uDDE0</span>' +
9255
+ '<span class="thinking-label">Thinking</span>' +
9256
+ '<span class="thinking-count">0</span>' +
9257
+ '<span class="thinking-elapsed"></span>' +
9258
+ '<span class="thinking-chevron">\u25B6</span>';
9259
+ panel.appendChild(summary);
9260
+
9261
+ const timeline = document.createElement('div');
9262
+ timeline.className = 'thinking-timeline';
9263
+ panel.appendChild(timeline);
9264
+
9265
+ let stepCount = 0;
9266
+ let activeStep = null;
9267
+ const startTime = Date.now();
9268
+ let elapsedTimer = null;
9269
+
9270
+ // Update the elapsed time in the summary
9271
+ function tickElapsed() {
9272
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
9273
+ summary.querySelector('.thinking-elapsed').textContent = elapsed + 's';
9274
+ }
9275
+ elapsedTimer = setInterval(tickElapsed, 200);
9276
+ tickElapsed();
9277
+
9278
+ function addStep(data) {
9279
+ const meta = TOOL_META[data.name] || DEFAULT_TOOL_META;
9280
+ const desc = meta.descFn(data.args || {});
9281
+
9282
+ // Mark previous active step as done
9283
+ if (activeStep) {
9284
+ activeStep.classList.remove('active');
9285
+ activeStep.classList.add('done');
9286
+ }
9287
+
9288
+ stepCount++;
9289
+ summary.querySelector('.thinking-count').textContent = stepCount;
9290
+
9291
+ const step = document.createElement('div');
9292
+ step.className = 'thinking-step active';
9293
+ if (data.error) step.className = 'thinking-step error';
9294
+
9295
+ const iconDiv = document.createElement('div');
9296
+ iconDiv.className = 'thinking-step-icon';
9297
+ iconDiv.textContent = meta.icon;
9298
+ step.appendChild(iconDiv);
9299
+
9300
+ const body = document.createElement('div');
9301
+ body.className = 'thinking-step-body';
9302
+
9303
+ const header = document.createElement('div');
9304
+ header.className = 'thinking-step-header';
9305
+ const nameSpan = document.createElement('span');
9306
+ nameSpan.className = 'thinking-step-name';
9307
+ nameSpan.textContent = meta.verb + (desc ? ' ' : '') + (meta.label || data.name);
9308
+ header.appendChild(nameSpan);
9309
+
9310
+ if (data.timeMs !== undefined) {
9311
+ const timeSpan = document.createElement('span');
9312
+ timeSpan.className = 'thinking-step-time';
9313
+ timeSpan.textContent = data.timeMs + 'ms';
9314
+ header.appendChild(timeSpan);
9315
+ }
9316
+ body.appendChild(header);
9317
+
9318
+ if (desc) {
9319
+ const descDiv = document.createElement('div');
9320
+ descDiv.className = 'thinking-step-desc';
9321
+ descDiv.textContent = desc;
9322
+ body.appendChild(descDiv);
9323
+ }
9324
+
9325
+ if (data.error) {
9326
+ const errDiv = document.createElement('div');
9327
+ errDiv.className = 'thinking-step-detail';
9328
+ errDiv.style.color = '#e74c3c';
9329
+ errDiv.textContent = data.error;
9330
+ body.appendChild(errDiv);
9331
+ } else if (data.resultSummary) {
9332
+ const detailDiv = document.createElement('div');
9333
+ detailDiv.className = 'thinking-step-detail';
9334
+ detailDiv.innerHTML = data.resultSummary;
9335
+ body.appendChild(detailDiv);
9336
+ }
9337
+
9338
+ step.appendChild(body);
9339
+ timeline.appendChild(step);
9340
+
9341
+ if (!data.error) activeStep = step;
9342
+
9343
+ return step;
9344
+ }
9345
+
9346
+ function finalize() {
9347
+ clearInterval(elapsedTimer);
9348
+ // Final elapsed
9349
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
9350
+ summary.querySelector('.thinking-elapsed').textContent = elapsed + 's';
9351
+ summary.querySelector('.thinking-label').textContent = 'Thought for ' + elapsed + 's';
9352
+ summary.querySelector('.thinking-icon').textContent = '\u2728';
9353
+ // Mark last step done and collapse
9354
+ if (activeStep) {
9355
+ activeStep.classList.remove('active');
9356
+ activeStep.classList.add('done');
9357
+ }
9358
+ panel.open = false;
9359
+ }
9360
+
9361
+ return { panel, addStep, finalize };
9362
+ }
9363
+
8344
9364
  function addChatMessage(role, content, sources) {
8345
9365
  const container = document.getElementById('chatMessages');
8346
9366
  const div = document.createElement('div');
@@ -8350,6 +9370,26 @@ function addChatMessage(role, content, sources) {
8350
9370
  contentSpan.textContent = content;
8351
9371
  div.appendChild(contentSpan);
8352
9372
 
9373
+ // Add copy button for assistant messages
9374
+ if (role === 'assistant') {
9375
+ const copyBtn = document.createElement('button');
9376
+ copyBtn.className = 'chat-copy-btn';
9377
+ copyBtn.title = 'Copy to clipboard';
9378
+ 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>';
9379
+ copyBtn.addEventListener('click', () => {
9380
+ const text = contentSpan.textContent || contentSpan.innerText;
9381
+ navigator.clipboard.writeText(text).then(() => {
9382
+ copyBtn.classList.add('copied');
9383
+ 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>';
9384
+ setTimeout(() => {
9385
+ copyBtn.classList.remove('copied');
9386
+ 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>';
9387
+ }, 2000);
9388
+ });
9389
+ });
9390
+ div.appendChild(copyBtn);
9391
+ }
9392
+
8353
9393
  if (sources && sources.length > 0) {
8354
9394
  const details = document.createElement('details');
8355
9395
  details.className = 'chat-sources';
@@ -8433,7 +9473,7 @@ async function sendChatMessage() {
8433
9473
  let assistantDiv = null;
8434
9474
  let fullText = '';
8435
9475
  let sources = [];
8436
- let toolCallsDiv = null;
9476
+ let thinkingPanel = null;
8437
9477
 
8438
9478
  while (true) {
8439
9479
  const { done, value } = await reader.read();
@@ -8457,33 +9497,13 @@ async function sendChatMessage() {
8457
9497
  }
8458
9498
 
8459
9499
  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);
9500
+ // Create thinking panel on first tool call
9501
+ if (!thinkingPanel) {
9502
+ typing.remove();
9503
+ thinkingPanel = createThinkingPanel();
9504
+ document.getElementById('chatMessages').appendChild(thinkingPanel.panel);
8484
9505
  }
8485
- toolCallsDiv.appendChild(tc);
8486
- typing.textContent = 'Calling ' + data.name + '...';
9506
+ thinkingPanel.addStep(data);
8487
9507
  document.getElementById('chatMessages').scrollTop = document.getElementById('chatMessages').scrollHeight;
8488
9508
  }
8489
9509
 
@@ -8498,6 +9518,8 @@ async function sendChatMessage() {
8498
9518
  }
8499
9519
 
8500
9520
  if (currentEvent === 'done') {
9521
+ // Finalize the thinking panel (collapse, show elapsed)
9522
+ if (thinkingPanel) thinkingPanel.finalize();
8501
9523
  // Render accumulated text as markdown for assistant messages
8502
9524
  if (assistantDiv && fullText) {
8503
9525
  const contentEl = assistantDiv.querySelector('.chat-message-content');
@@ -8537,6 +9559,7 @@ async function sendChatMessage() {
8537
9559
 
8538
9560
  } catch (err) {
8539
9561
  if (typing.parentNode) typing.remove();
9562
+ if (thinkingPanel) thinkingPanel.finalize();
8540
9563
  addChatMessage('system-msg', `Error: ${err.message}`);
8541
9564
  } finally {
8542
9565
  sendBtn.disabled = false;
@@ -8751,6 +9774,1227 @@ init();
8751
9774
  </div>
8752
9775
  </div>
8753
9776
 
9777
+ <!-- ========== WORKFLOW VISUALIZER JS ========== -->
9778
+ <script>
9779
+ // ── Workflow Visualizer ──
9780
+
9781
+ // escapeHtml is defined in the main IIFE scope and not accessible here — redeclare it
9782
+ function escapeHtml(str) {
9783
+ if (str == null) return '';
9784
+ const s = typeof str === 'string' ? str : String(str);
9785
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
9786
+ }
9787
+
9788
+ const WF_NODE_META = {
9789
+ query: { icon: '\u{1F50D}', label: 'RAG Query', color: '#40E0FF', category: 'retrieval' },
9790
+ search: { icon: '\u{1F50E}', label: 'Vector Search', color: '#40E0FF', category: 'retrieval' },
9791
+ rerank: { icon: '\u{1F3C6}', label: 'Rerank', color: '#40E0FF', category: 'retrieval' },
9792
+ ingest: { icon: '\u{1F4E5}', label: 'Ingest', color: '#40E0FF', category: 'retrieval' },
9793
+ embed: { icon: '\u{1F4D0}', label: 'Embed', color: '#B388FF', category: 'embedding' },
9794
+ similarity: { icon: '\u{1F517}', label: 'Similarity', color: '#B388FF', category: 'embedding' },
9795
+ collections: { icon: '\u{1F5C4}', label: 'Collections', color: '#00D4AA', category: 'management' },
9796
+ models: { icon: '\u{1F4CB}', label: 'Models', color: '#00D4AA', category: 'management' },
9797
+ estimate: { icon: '\u{1F4B0}', label: 'Cost Estimate', color: '#FFB74D', category: 'utility' },
9798
+ explain: { icon: '\u{1F4D6}', label: 'Explain', color: '#FFB74D', category: 'utility' },
9799
+ topics: { icon: '\u{1F5C2}', label: 'Topics', color: '#FFB74D', category: 'utility' },
9800
+ merge: { icon: '\u{1F500}', label: 'Merge', color: '#90A4AE', category: 'control' },
9801
+ filter: { icon: '\u{1F9F9}', label: 'Filter', color: '#90A4AE', category: 'control' },
9802
+ transform: { icon: '\u{1F504}', label: 'Transform', color: '#90A4AE', category: 'control' },
9803
+ generate: { icon: '\u2728', label: 'Generate', color: '#69F0AE', category: 'generation' },
9804
+ };
9805
+
9806
+ const WF_NODE_W = 180;
9807
+ const WF_NODE_H = 64;
9808
+ const WF_LAYER_GAP = 260;
9809
+ const WF_NODE_GAP = 100;
9810
+ const WF_PAD = 80;
9811
+ const WF_PORT_R = 5; // Port circle radius
9812
+
9813
+ let wfState = {
9814
+ workflows: [],
9815
+ activeWorkflow: null,
9816
+ selectedNodeId: null,
9817
+ executionState: {},
9818
+ executionResults: {},
9819
+ nodePositions: {},
9820
+ layers: [],
9821
+ graph: {},
9822
+ zoom: 1,
9823
+ panX: 0,
9824
+ panY: 0,
9825
+ isPanning: false,
9826
+ panStart: { x: 0, y: 0 },
9827
+ executing: false,
9828
+ };
9829
+
9830
+ // ── Library ──
9831
+ async function wfLoadLibrary() {
9832
+ try {
9833
+ const res = await fetch('/api/workflows');
9834
+ const data = await res.json();
9835
+ wfState.workflows = data.workflows || [];
9836
+ wfRenderLibrary();
9837
+ } catch (err) {
9838
+ const list = document.getElementById('wfLibraryList');
9839
+ if (list) list.innerHTML = '<div style="padding:16px;color:var(--text-muted);font-size:12px;">Failed to load workflows</div>';
9840
+ }
9841
+ }
9842
+
9843
+ function wfRenderLibrary() {
9844
+ const list = document.getElementById('wfLibraryList');
9845
+ if (!list) return;
9846
+ if (wfState.workflows.length === 0) {
9847
+ list.innerHTML = '<div style="padding:16px;color:var(--text-muted);font-size:12px;">No workflows found</div>';
9848
+ return;
9849
+ }
9850
+ list.innerHTML = wfState.workflows.map(w => {
9851
+ // w.name is the file stem (e.g., "multi-collection-search")
9852
+ // w.description comes from the workflow JSON's description field
9853
+ const displayName = w.name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
9854
+ return `<div class="wf-library-item" data-wf-name="${w.name}" onclick="wfSelectWorkflow('${w.name}')">
9855
+ <div class="wf-library-item-name">${displayName}</div>
9856
+ <div class="wf-library-item-desc">${w.description || ''}</div>
9857
+ </div>`;
9858
+ }).join('');
9859
+ }
9860
+
9861
+ async function wfSelectWorkflow(name) {
9862
+ // Highlight in library
9863
+ document.querySelectorAll('.wf-library-item').forEach(el => {
9864
+ el.classList.toggle('active', el.dataset.wfName === name);
9865
+ });
9866
+
9867
+ try {
9868
+ const res = await fetch('/api/workflows/' + encodeURIComponent(name));
9869
+ const data = await res.json();
9870
+ wfState.activeWorkflow = data.definition;
9871
+ wfState.selectedNodeId = null;
9872
+ wfState.executionState = {};
9873
+ wfState.executionResults = {};
9874
+ wfSetToolbarEnabled(true);
9875
+ document.getElementById('wfCanvasEmpty').style.display = 'none';
9876
+ await wfRenderWorkflow(data.definition);
9877
+ wfOpenInspector();
9878
+ wfUpdateInspector();
9879
+ } catch (err) {
9880
+ console.error('Failed to load workflow:', err);
9881
+ }
9882
+ }
9883
+
9884
+ // ── Load workflow from file ──
9885
+ function wfLoadFromFile() {
9886
+ document.getElementById('wfFileInput').click();
9887
+ }
9888
+
9889
+ function wfHandleFileLoad(event) {
9890
+ const file = event.target.files[0];
9891
+ if (!file) return;
9892
+ const reader = new FileReader();
9893
+ reader.onload = async (e) => {
9894
+ try {
9895
+ const definition = JSON.parse(e.target.result);
9896
+ if (!definition.steps || !Array.isArray(definition.steps)) {
9897
+ alert('Invalid workflow: missing "steps" array');
9898
+ return;
9899
+ }
9900
+ // Validate via server
9901
+ const valRes = await fetch('/api/workflows/validate', {
9902
+ method: 'POST',
9903
+ headers: { 'Content-Type': 'application/json' },
9904
+ body: JSON.stringify({ definition }),
9905
+ });
9906
+ const valData = await valRes.json();
9907
+ if (!valData.valid) {
9908
+ alert('Workflow validation failed:\n' + (valData.errors || []).join('\n'));
9909
+ return;
9910
+ }
9911
+ // Deselect any library item
9912
+ document.querySelectorAll('.wf-library-item.active').forEach(el => el.classList.remove('active'));
9913
+ // Load the workflow
9914
+ wfState.activeWorkflow = definition;
9915
+ wfState.selectedNodeId = null;
9916
+ wfState.executionState = {};
9917
+ wfState.executionResults = {};
9918
+ wfSetToolbarEnabled(true);
9919
+ document.getElementById('wfCanvasEmpty').style.display = 'none';
9920
+ wfHideExecStatus();
9921
+ await wfRenderWorkflow(definition);
9922
+ wfOpenInspector();
9923
+ wfUpdateInspector();
9924
+ } catch (err) {
9925
+ alert('Failed to parse workflow file: ' + err.message);
9926
+ }
9927
+ };
9928
+ reader.readAsText(file);
9929
+ // Reset file input so the same file can be re-loaded
9930
+ event.target.value = '';
9931
+ }
9932
+
9933
+ // ── DAG Layout + SVG Rendering ──
9934
+ async function wfRenderWorkflow(definition) {
9935
+ const svg = document.getElementById('wf-canvas');
9936
+ // Clear previous nodes and edges (keep defs)
9937
+ svg.querySelectorAll('.wf-node, .wf-edge-group').forEach(el => el.remove());
9938
+
9939
+ // Get execution plan from server
9940
+ let layers, graph;
9941
+ try {
9942
+ const res = await fetch('/api/workflows/plan', {
9943
+ method: 'POST',
9944
+ headers: { 'Content-Type': 'application/json' },
9945
+ body: JSON.stringify({ definition }),
9946
+ });
9947
+ const data = await res.json();
9948
+ layers = data.layers;
9949
+ graph = data.graph;
9950
+ } catch (err) {
9951
+ console.error('Failed to get execution plan:', err);
9952
+ return;
9953
+ }
9954
+
9955
+ wfState.layers = layers;
9956
+ wfState.graph = graph;
9957
+
9958
+ // Build step lookup
9959
+ const stepMap = {};
9960
+ definition.steps.forEach(s => { stepMap[s.id] = s; });
9961
+
9962
+ // Calculate positions
9963
+ const positions = {};
9964
+ const maxLayerSize = Math.max(...layers.map(l => l.length));
9965
+ const totalW = layers.length * WF_LAYER_GAP;
9966
+ const totalH = maxLayerSize * (WF_NODE_H + WF_NODE_GAP);
9967
+
9968
+ layers.forEach((layer, li) => {
9969
+ const x = WF_PAD + li * WF_LAYER_GAP;
9970
+ const layerH = layer.length * WF_NODE_H + (layer.length - 1) * WF_NODE_GAP;
9971
+ const startY = WF_PAD + (totalH - layerH) / 2;
9972
+ layer.forEach((stepId, ni) => {
9973
+ positions[stepId] = {
9974
+ x,
9975
+ y: startY + ni * (WF_NODE_H + WF_NODE_GAP),
9976
+ };
9977
+ });
9978
+ });
9979
+ wfState.nodePositions = positions;
9980
+
9981
+ // Build port-visibility maps: which nodes have input deps, which have dependents
9982
+ const nodeHasDeps = {}; // node has incoming edges (show input port)
9983
+ const nodeHasDependents = {}; // node has outgoing edges (show output port)
9984
+ for (const [stepId, deps] of Object.entries(graph)) {
9985
+ if (!deps || !Array.isArray(deps)) continue;
9986
+ deps.forEach(rawDepId => {
9987
+ const depId = rawDepId.replace(/^!/, '');
9988
+ if (positions[depId] && positions[stepId]) {
9989
+ nodeHasDeps[stepId] = true;
9990
+ nodeHasDependents[depId] = true;
9991
+ }
9992
+ });
9993
+ }
9994
+
9995
+ // Draw edges first (behind nodes)
9996
+ const edgeGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
9997
+ edgeGroup.classList.add('wf-edge-group');
9998
+ for (const [stepId, deps] of Object.entries(graph)) {
9999
+ if (!deps || !Array.isArray(deps)) continue;
10000
+ deps.forEach(rawDepId => {
10001
+ // Strip negation prefix (e.g., "!similarity_check" -> "similarity_check")
10002
+ const depId = rawDepId.replace(/^!/, '');
10003
+ if (positions[depId] && positions[stepId]) {
10004
+ const edge = wfDrawEdge(depId, stepId, positions);
10005
+ edgeGroup.appendChild(edge);
10006
+ }
10007
+ });
10008
+ }
10009
+ svg.appendChild(edgeGroup);
10010
+
10011
+ // Draw nodes
10012
+ for (const step of definition.steps) {
10013
+ const pos = positions[step.id];
10014
+ if (!pos) continue;
10015
+ const state = wfState.executionState[step.id] || 'idle';
10016
+ const hasDeps = !!nodeHasDeps[step.id];
10017
+ const hasDependents = !!nodeHasDependents[step.id];
10018
+ const nodeGroup = wfDrawNode(step, pos.x, pos.y, state, hasDeps, hasDependents);
10019
+ svg.appendChild(nodeGroup);
10020
+ }
10021
+
10022
+ // Set viewBox to fit
10023
+ wfFitToView();
10024
+ }
10025
+
10026
+ function wfDrawNode(step, x, y, state, hasDeps, hasDependents) {
10027
+ const meta = WF_NODE_META[step.tool] || { icon: '\u2699', label: step.tool, color: '#666', category: 'unknown' };
10028
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
10029
+ g.classList.add('wf-node');
10030
+ if (state !== 'idle') g.classList.add('wf-node--' + state);
10031
+ if (wfState.selectedNodeId === step.id) g.classList.add('selected');
10032
+ g.dataset.stepId = step.id;
10033
+ g.setAttribute('transform', `translate(${x}, ${y})`);
10034
+ g.addEventListener('click', (e) => {
10035
+ e.stopPropagation();
10036
+ wfSelectNode(step.id);
10037
+ });
10038
+
10039
+ // Background rect
10040
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
10041
+ rect.setAttribute('width', WF_NODE_W);
10042
+ rect.setAttribute('height', WF_NODE_H);
10043
+ rect.setAttribute('fill', meta.color);
10044
+ rect.setAttribute('stroke', meta.color);
10045
+ rect.setAttribute('opacity', '0.85');
10046
+ g.appendChild(rect);
10047
+
10048
+ // Input port (left side): only if this node has dependencies
10049
+ if (hasDeps) {
10050
+ const inPort = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
10051
+ inPort.classList.add('wf-port', 'wf-port-in');
10052
+ inPort.setAttribute('cx', 0);
10053
+ inPort.setAttribute('cy', WF_NODE_H / 2);
10054
+ inPort.setAttribute('r', WF_PORT_R);
10055
+ inPort.setAttribute('stroke', meta.color);
10056
+ inPort.setAttribute('stroke-width', '2');
10057
+ g.appendChild(inPort);
10058
+ }
10059
+
10060
+ // Output port (right side): only if other nodes depend on this one
10061
+ if (hasDependents) {
10062
+ const outPort = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
10063
+ outPort.classList.add('wf-port', 'wf-port-out');
10064
+ outPort.setAttribute('cx', WF_NODE_W);
10065
+ outPort.setAttribute('cy', WF_NODE_H / 2);
10066
+ outPort.setAttribute('r', WF_PORT_R);
10067
+ outPort.setAttribute('fill', meta.color);
10068
+ outPort.setAttribute('stroke-width', '2');
10069
+ g.appendChild(outPort);
10070
+ }
10071
+
10072
+ // Icon
10073
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10074
+ icon.classList.add('wf-node-icon');
10075
+ icon.setAttribute('x', 22);
10076
+ icon.setAttribute('y', WF_NODE_H / 2);
10077
+ icon.textContent = meta.icon;
10078
+ g.appendChild(icon);
10079
+
10080
+ // Label (step name, truncated)
10081
+ const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10082
+ label.classList.add('wf-node-label');
10083
+ label.setAttribute('x', WF_NODE_W / 2 + 10);
10084
+ label.setAttribute('y', WF_NODE_H / 2 - 7);
10085
+ const maxChars = 20;
10086
+ const displayName = (step.name || step.id).length > maxChars
10087
+ ? (step.name || step.id).slice(0, maxChars - 2) + '..'
10088
+ : (step.name || step.id);
10089
+ label.textContent = displayName;
10090
+ g.appendChild(label);
10091
+
10092
+ // Tool badge
10093
+ const badge = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10094
+ badge.classList.add('wf-node-badge');
10095
+ badge.setAttribute('x', WF_NODE_W / 2 + 10);
10096
+ badge.setAttribute('y', WF_NODE_H / 2 + 10);
10097
+ badge.textContent = meta.label;
10098
+ g.appendChild(badge);
10099
+
10100
+ // Condition indicator
10101
+ if (step.condition) {
10102
+ const cond = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10103
+ cond.classList.add('wf-node-condition');
10104
+ cond.setAttribute('x', WF_NODE_W - 8);
10105
+ cond.setAttribute('y', 14);
10106
+ cond.textContent = '\u26A1';
10107
+ g.appendChild(cond);
10108
+ }
10109
+
10110
+ // Status overlay (for execution)
10111
+ if (state === 'completed') {
10112
+ const check = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10113
+ check.classList.add('wf-node-status');
10114
+ check.setAttribute('x', WF_NODE_W - 18);
10115
+ check.setAttribute('y', WF_NODE_H / 2);
10116
+ check.textContent = '\u2713';
10117
+ g.appendChild(check);
10118
+ } else if (state === 'error') {
10119
+ const errIcon = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10120
+ errIcon.classList.add('wf-node-status');
10121
+ errIcon.setAttribute('x', WF_NODE_W - 18);
10122
+ errIcon.setAttribute('y', WF_NODE_H / 2);
10123
+ errIcon.textContent = '\u2717';
10124
+ g.appendChild(errIcon);
10125
+ } else if (state === 'skipped') {
10126
+ const skip = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10127
+ skip.classList.add('wf-node-status');
10128
+ skip.setAttribute('x', WF_NODE_W - 18);
10129
+ skip.setAttribute('y', WF_NODE_H / 2);
10130
+ skip.textContent = '\u2500';
10131
+ g.appendChild(skip);
10132
+ }
10133
+
10134
+ // Time badge (if completed)
10135
+ const result = wfState.executionResults[step.id];
10136
+ if (result && result.timeMs !== undefined) {
10137
+ const time = document.createElementNS('http://www.w3.org/2000/svg', 'text');
10138
+ time.classList.add('wf-node-time');
10139
+ time.setAttribute('x', WF_NODE_W / 2);
10140
+ time.setAttribute('y', WF_NODE_H + 16);
10141
+ time.textContent = result.timeMs + 'ms';
10142
+ g.appendChild(time);
10143
+ }
10144
+
10145
+ return g;
10146
+ }
10147
+
10148
+ function wfDrawEdge(fromId, toId, positions) {
10149
+ const from = positions[fromId];
10150
+ const to = positions[toId];
10151
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
10152
+ g.classList.add('wf-edge-group-item');
10153
+ g.dataset.from = fromId;
10154
+ g.dataset.to = toId;
10155
+
10156
+ // Port positions: output port on right side of source, input port on left side of target
10157
+ const x1 = from.x + WF_NODE_W;
10158
+ const y1 = from.y + WF_NODE_H / 2;
10159
+ const x2 = to.x;
10160
+ const y2 = to.y + WF_NODE_H / 2;
10161
+
10162
+ // Detect backward edges (target is same column or to the left)
10163
+ const isBackward = x2 <= x1;
10164
+
10165
+ let d;
10166
+ if (isBackward) {
10167
+ // Route backward edges: go down from output, loop under/over, come in from left of target
10168
+ const midY = Math.max(from.y + WF_NODE_H, to.y + WF_NODE_H) + 40;
10169
+ d = `M ${x1} ${y1} C ${x1 + 50} ${y1}, ${x1 + 50} ${midY}, ${(x1 + x2) / 2} ${midY} C ${x2 - 50} ${midY}, ${x2 - 50} ${y2}, ${x2} ${y2}`;
10170
+ } else {
10171
+ // Normal left-to-right bezier
10172
+ const dx = Math.abs(x2 - x1);
10173
+ const tension = Math.max(dx * 0.4, 40);
10174
+ d = `M ${x1} ${y1} C ${x1 + tension} ${y1}, ${x2 - tension} ${y2}, ${x2} ${y2}`;
10175
+ }
10176
+
10177
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
10178
+ path.classList.add('wf-edge');
10179
+ if (isBackward) path.classList.add('wf-edge--backward');
10180
+ path.setAttribute('d', d);
10181
+
10182
+ // Set state-based classes
10183
+ const fromState = wfState.executionState[fromId];
10184
+ const toState = wfState.executionState[toId];
10185
+ if (toState === 'running') path.classList.add('wf-edge--active');
10186
+ else if (fromState === 'completed' && (toState === 'completed' || toState === 'skipped')) path.classList.add('wf-edge--complete');
10187
+
10188
+ g.appendChild(path);
10189
+ return g;
10190
+ }
10191
+
10192
+ // ── Inspector Toggle ──
10193
+ function wfToggleInspector() {
10194
+ const panel = document.getElementById('wfInspector');
10195
+ const btn = document.getElementById('wfInspectorToggle');
10196
+ if (!panel) return;
10197
+ panel.classList.toggle('collapsed');
10198
+ if (btn) btn.innerHTML = panel.classList.contains('collapsed') ? '&lsaquo;' : '&rsaquo;';
10199
+ }
10200
+
10201
+ function wfOpenInspector() {
10202
+ const panel = document.getElementById('wfInspector');
10203
+ const btn = document.getElementById('wfInspectorToggle');
10204
+ if (!panel || !panel.classList.contains('collapsed')) return;
10205
+ panel.classList.remove('collapsed');
10206
+ if (btn) btn.innerHTML = '&rsaquo;';
10207
+ }
10208
+
10209
+ // ── Node Selection ──
10210
+ function wfSelectNode(stepId) {
10211
+ wfState.selectedNodeId = stepId;
10212
+ // Update visual selection
10213
+ document.querySelectorAll('.wf-node').forEach(n => {
10214
+ n.classList.toggle('selected', n.dataset.stepId === stepId);
10215
+ });
10216
+ wfOpenInspector();
10217
+ wfUpdateInspector();
10218
+ }
10219
+
10220
+ function wfDeselectNode() {
10221
+ wfState.selectedNodeId = null;
10222
+ document.querySelectorAll('.wf-node.selected').forEach(n => n.classList.remove('selected'));
10223
+ wfUpdateInspector();
10224
+ }
10225
+
10226
+ // ── Inspector ──
10227
+ function wfUpdateInspector() {
10228
+ const body = document.getElementById('wfInspectorBody');
10229
+ const header = document.getElementById('wfInspectorHeader');
10230
+ if (!body || !header) return;
10231
+
10232
+ const def = wfState.activeWorkflow;
10233
+ if (!def) {
10234
+ header.textContent = 'Inspector';
10235
+ body.innerHTML = '<div class="wf-inspector-empty">Select a workflow to view details</div>';
10236
+ return;
10237
+ }
10238
+
10239
+ try {
10240
+ if (!wfState.selectedNodeId) {
10241
+ // Show workflow-level info
10242
+ header.textContent = def.name || 'Workflow';
10243
+ let html = '';
10244
+
10245
+ // Description
10246
+ if (def.description) {
10247
+ html += `<div class="wf-inspector-section">
10248
+ <div class="wf-inspector-section-title">Description</div>
10249
+ <div style="font-size:12px;color:var(--text);line-height:1.4;">${escapeHtml(def.description)}</div>
10250
+ </div>`;
10251
+ }
10252
+
10253
+ // Inputs
10254
+ if (def.inputs && Object.keys(def.inputs).length > 0) {
10255
+ html += '<div class="wf-inspector-section"><div class="wf-inspector-section-title">Inputs</div>';
10256
+ for (const [key, spec] of Object.entries(def.inputs)) {
10257
+ const req = spec.required ? ' <span style="color:#e74c3c">*</span>' : '';
10258
+ const defVal = spec.default !== undefined ? ` (default: ${spec.default})` : '';
10259
+ html += `<div style="margin-bottom:8px;">
10260
+ <div style="font-size:12px;font-weight:600;color:var(--text);">${escapeHtml(key)}${req}</div>
10261
+ <div style="font-size:11px;color:var(--text-muted);">${escapeHtml(spec.description || spec.type || '')}${defVal}</div>
10262
+ <input class="wf-inspector-input" id="wf-input-${key}" placeholder="${escapeHtml(key)}" value="${spec.default !== undefined ? spec.default : ''}">
10263
+ </div>`;
10264
+ }
10265
+ html += '</div>';
10266
+ }
10267
+
10268
+ // Steps summary
10269
+ html += `<div class="wf-inspector-section">
10270
+ <div class="wf-inspector-section-title">Steps</div>
10271
+ <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>
10272
+ </div>`;
10273
+
10274
+ // Output mapping
10275
+ if (def.output) {
10276
+ html += `<div class="wf-inspector-section">
10277
+ <div class="wf-inspector-section-title">Output</div>
10278
+ <div class="wf-inspector-code">${escapeHtml(JSON.stringify(def.output, null, 2))}</div>
10279
+ </div>`;
10280
+ }
10281
+
10282
+ // Execution result
10283
+ if (wfState.executionResults._done) {
10284
+ const r = wfState.executionResults._done;
10285
+ const doneJson = JSON.stringify(r.output, null, 2);
10286
+ html += `<div class="wf-inspector-section">
10287
+ <div class="wf-inspector-section-title">Result</div>
10288
+ <div class="wf-inspector-result success">
10289
+ <div style="font-weight:600;margin-bottom:4px;">Completed in ${r.totalTimeMs}ms</div>
10290
+ <div class="wf-inspector-code" style="max-height:150px;overflow:auto;">${escapeHtml(doneJson)}</div>
10291
+ </div>
10292
+ <button class="wf-output-expand-btn" data-expand-step="_done">&#x2922; Expand</button>
10293
+ </div>`;
10294
+ }
10295
+
10296
+ body.innerHTML = html;
10297
+ wfBindExpandButtons(body);
10298
+ return;
10299
+ }
10300
+
10301
+ // Show step details
10302
+ const step = def.steps.find(s => s.id === wfState.selectedNodeId);
10303
+ if (!step) {
10304
+ body.innerHTML = '<div class="wf-inspector-empty">Step not found: ' + escapeHtml(wfState.selectedNodeId) + '</div>';
10305
+ return;
10306
+ }
10307
+
10308
+ const meta = WF_NODE_META[step.tool] || { icon: '\u2699', label: step.tool, color: '#666' };
10309
+ header.textContent = step.name || step.id;
10310
+
10311
+ let html = '';
10312
+
10313
+ // Tool badge
10314
+ html += `<div class="wf-inspector-section">
10315
+ <div class="wf-inspector-section-title">Tool</div>
10316
+ <span class="wf-tool-badge" style="background:${meta.color}">${meta.icon} ${meta.label}</span>
10317
+ </div>`;
10318
+
10319
+ // Step ID
10320
+ html += `<div class="wf-inspector-section">
10321
+ <div class="wf-inspector-section-title">ID</div>
10322
+ <div style="font-size:12px;color:var(--text);font-family:monospace;">${escapeHtml(step.id)}</div>
10323
+ </div>`;
10324
+
10325
+ // Inputs
10326
+ if (step.inputs) {
10327
+ html += `<div class="wf-inspector-section">
10328
+ <div class="wf-inspector-section-title">Inputs</div>`;
10329
+ for (const [key, val] of Object.entries(step.inputs)) {
10330
+ const display = typeof val === 'string' ? val : JSON.stringify(val);
10331
+ html += `<div class="wf-inspector-field">
10332
+ <span class="wf-inspector-field-label">${escapeHtml(key)}</span>
10333
+ <span class="wf-inspector-field-value" style="font-family:monospace;font-size:11px;">${escapeHtml(display)}</span>
10334
+ </div>`;
10335
+ }
10336
+ html += '</div>';
10337
+ }
10338
+
10339
+ // Condition
10340
+ if (step.condition) {
10341
+ html += `<div class="wf-inspector-section">
10342
+ <div class="wf-inspector-section-title">Condition \u26A1</div>
10343
+ <div class="wf-inspector-code">${escapeHtml(step.condition)}</div>
10344
+ </div>`;
10345
+ }
10346
+
10347
+ // ForEach
10348
+ if (step.forEach) {
10349
+ html += `<div class="wf-inspector-section">
10350
+ <div class="wf-inspector-section-title">ForEach</div>
10351
+ <div class="wf-inspector-code">${escapeHtml(JSON.stringify(step.forEach, null, 2))}</div>
10352
+ </div>`;
10353
+ }
10354
+
10355
+ // Execution result for this step
10356
+ const result = wfState.executionResults[step.id];
10357
+ const state = wfState.executionState[step.id];
10358
+ if (state === 'completed' && result) {
10359
+ const outputJson = result.output ? JSON.stringify(result.output, null, 2) : '';
10360
+ const stepTitle = step.name || step.id;
10361
+ html += `<div class="wf-inspector-section">
10362
+ <div class="wf-inspector-section-title">Result</div>
10363
+ <div class="wf-inspector-result success">
10364
+ <div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;">${result.timeMs}ms${result.summary ? ', ' + result.summary : ''}</div>
10365
+ ${outputJson ? '<div class="wf-inspector-code" style="max-height:120px;overflow:auto;">' + escapeHtml(outputJson) + '</div>' : ''}
10366
+ </div>
10367
+ ${outputJson ? '<button class="wf-output-expand-btn" data-expand-step="' + escapeHtml(step.id) + '">&#x2922; Expand</button>' : ''}
10368
+ </div>`;
10369
+ } else if (state === 'error' && result) {
10370
+ html += `<div class="wf-inspector-section">
10371
+ <div class="wf-inspector-section-title">Error</div>
10372
+ <div class="wf-inspector-result error">
10373
+ <div style="font-size:12px;margin-bottom:4px;">${escapeHtml(result.error || 'Unknown error')}</div>
10374
+ </div>
10375
+ </div>`;
10376
+ } else if (state === 'skipped' && result) {
10377
+ html += `<div class="wf-inspector-section">
10378
+ <div class="wf-inspector-section-title">Skipped</div>
10379
+ <div style="font-size:12px;color:var(--text-muted);">${escapeHtml(result.reason || 'Condition not met')}</div>
10380
+ </div>`;
10381
+ }
10382
+
10383
+ body.innerHTML = html;
10384
+ wfBindExpandButtons(body);
10385
+ } catch (err) {
10386
+ console.error('Inspector render error:', err);
10387
+ body.innerHTML = '<div class="wf-inspector-empty" style="color:#e74c3c;">Error rendering inspector: ' + escapeHtml(err.message) + '</div>';
10388
+ }
10389
+ }
10390
+
10391
+ function wfBindExpandButtons(container) {
10392
+ container.querySelectorAll('.wf-output-expand-btn[data-expand-step]').forEach(btn => {
10393
+ btn.addEventListener('click', () => {
10394
+ const stepId = btn.dataset.expandStep;
10395
+ const result = wfState.executionResults[stepId];
10396
+ if (!result) return;
10397
+ let title, content;
10398
+ if (stepId === '_done') {
10399
+ title = 'Workflow Output';
10400
+ content = JSON.stringify(result.output, null, 2);
10401
+ } else {
10402
+ const def = wfState.activeWorkflow;
10403
+ const step = def ? def.steps.find(s => s.id === stepId) : null;
10404
+ title = (step ? step.name || step.id : stepId) + ' Output';
10405
+ content = JSON.stringify(result.output, null, 2);
10406
+ }
10407
+ wfOpenOutputModal(title, content);
10408
+ });
10409
+ });
10410
+ }
10411
+
10412
+ // escapeHtml is already defined globally — reuse it
10413
+
10414
+ // ── Toolbar helpers ──
10415
+ function wfSetToolbarEnabled(enabled) {
10416
+ document.getElementById('wfRunBtn').disabled = !enabled;
10417
+ document.getElementById('wfDryRunBtn').disabled = !enabled;
10418
+ document.getElementById('wfExportBtn').disabled = !enabled;
10419
+ }
10420
+
10421
+ // ── Export workflow JSON ──
10422
+ function wfExportJson() {
10423
+ const def = wfState.activeWorkflow;
10424
+ if (!def) return;
10425
+ const json = JSON.stringify(def, null, 2);
10426
+ const name = (def.name || 'workflow').replace(/\s+/g, '-').toLowerCase();
10427
+ const blob = new Blob([json], { type: 'application/json' });
10428
+ const url = URL.createObjectURL(blob);
10429
+ const a = document.createElement('a');
10430
+ a.href = url;
10431
+ a.download = name + '.vai-workflow.json';
10432
+ document.body.appendChild(a);
10433
+ a.click();
10434
+ document.body.removeChild(a);
10435
+ URL.revokeObjectURL(url);
10436
+ }
10437
+
10438
+ // ── Dry Run ──
10439
+ function wfDryRun() {
10440
+ const def = wfState.activeWorkflow;
10441
+ if (!def || !wfState.layers) return;
10442
+
10443
+ const layers = wfState.layers;
10444
+ const stepMap = {};
10445
+ def.steps.forEach(s => { stepMap[s.id] = s; });
10446
+
10447
+ // Build layer HTML
10448
+ let layersHtml = '';
10449
+ let totalSteps = 0;
10450
+ let conditionalSteps = 0;
10451
+
10452
+ layers.forEach((layer, i) => {
10453
+ const parallel = layer.length > 1 ? ' (parallel)' : '';
10454
+ layersHtml += '<div class="wf-dryrun-layer">';
10455
+ layersHtml += '<div class="wf-dryrun-layer-title">';
10456
+ layersHtml += '<span class="wf-dryrun-layer-badge">' + (i + 1) + '</span>';
10457
+ layersHtml += ' Layer ' + (i + 1) + parallel;
10458
+ layersHtml += '</div>';
10459
+
10460
+ layer.forEach(stepId => {
10461
+ const step = stepMap[stepId];
10462
+ if (!step) return;
10463
+ totalSteps++;
10464
+ const meta = WF_NODE_META[step.tool] || { icon: '\u2699', label: step.tool, color: '#666' };
10465
+ layersHtml += '<div class="wf-dryrun-step">';
10466
+ layersHtml += '<span class="wf-dryrun-step-icon">' + meta.icon + '</span>';
10467
+ layersHtml += '<div class="wf-dryrun-step-info">';
10468
+ layersHtml += '<div class="wf-dryrun-step-name">' + escapeHtml(step.name || step.id) + '</div>';
10469
+ layersHtml += '<div class="wf-dryrun-step-tool">' + escapeHtml(meta.label) + ' (' + escapeHtml(step.id) + ')</div>';
10470
+ if (step.condition) {
10471
+ conditionalSteps++;
10472
+ layersHtml += '<div class="wf-dryrun-step-cond">\u26A1 ' + escapeHtml(step.condition) + '</div>';
10473
+ }
10474
+ layersHtml += '</div></div>';
10475
+ });
10476
+
10477
+ layersHtml += '</div>';
10478
+ });
10479
+
10480
+ // Summary stats
10481
+ const parallelLayers = layers.filter(l => l.length > 1).length;
10482
+ let summaryHtml = '<div class="wf-dryrun-summary">';
10483
+ summaryHtml += '<div class="wf-dryrun-stat"><div class="wf-dryrun-stat-value">' + totalSteps + '</div><div class="wf-dryrun-stat-label">Steps</div></div>';
10484
+ summaryHtml += '<div class="wf-dryrun-stat"><div class="wf-dryrun-stat-value">' + layers.length + '</div><div class="wf-dryrun-stat-label">Layers</div></div>';
10485
+ summaryHtml += '<div class="wf-dryrun-stat"><div class="wf-dryrun-stat-value">' + parallelLayers + '</div><div class="wf-dryrun-stat-label">Parallel</div></div>';
10486
+ if (conditionalSteps > 0) {
10487
+ summaryHtml += '<div class="wf-dryrun-stat"><div class="wf-dryrun-stat-value">' + conditionalSteps + '</div><div class="wf-dryrun-stat-label">Conditional</div></div>';
10488
+ }
10489
+ summaryHtml += '</div>';
10490
+
10491
+ // Inputs summary
10492
+ let inputsHtml = '';
10493
+ if (def.inputs && Object.keys(def.inputs).length > 0) {
10494
+ inputsHtml += '<div style="margin-bottom:12px;font-size:12px;">';
10495
+ 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>';
10496
+ for (const [key, spec] of Object.entries(def.inputs)) {
10497
+ const req = spec.required ? ' *' : '';
10498
+ inputsHtml += '<div style="margin-bottom:2px;"><span style="font-weight:600;color:var(--text);">' + escapeHtml(key) + req + '</span>';
10499
+ inputsHtml += ' <span style="color:var(--text-muted);">(' + escapeHtml(spec.type || 'string') + ')</span></div>';
10500
+ }
10501
+ inputsHtml += '</div>';
10502
+ }
10503
+
10504
+ // Create overlay
10505
+ const canvasArea = document.querySelector('.wf-canvas-area');
10506
+ // Remove any existing overlay
10507
+ canvasArea.querySelectorAll('.wf-dryrun-overlay').forEach(el => el.remove());
10508
+
10509
+ const overlay = document.createElement('div');
10510
+ overlay.className = 'wf-dryrun-overlay';
10511
+ overlay.innerHTML = '<div class="wf-dryrun-panel">' +
10512
+ '<div class="wf-dryrun-header">' +
10513
+ '<span class="wf-dryrun-title">Execution Plan: ' + escapeHtml(def.name || 'Workflow') + '</span>' +
10514
+ '<button class="wf-dryrun-close" onclick="wfCloseDryRun()" title="Close">&times;</button>' +
10515
+ '</div>' +
10516
+ '<div class="wf-dryrun-body">' +
10517
+ inputsHtml +
10518
+ summaryHtml +
10519
+ '<div style="margin-top:16px;">' + layersHtml + '</div>' +
10520
+ '</div>' +
10521
+ '</div>';
10522
+
10523
+ // Click backdrop to close
10524
+ overlay.addEventListener('click', (e) => {
10525
+ if (e.target === overlay) wfCloseDryRun();
10526
+ });
10527
+
10528
+ canvasArea.appendChild(overlay);
10529
+ }
10530
+
10531
+ function wfCloseDryRun() {
10532
+ document.querySelectorAll('.wf-dryrun-overlay').forEach(el => el.remove());
10533
+ }
10534
+
10535
+ // ── Execution ──
10536
+ let wfAbortController = null;
10537
+ let wfExecTimer = null;
10538
+ let wfExecStartTime = 0;
10539
+ const WF_EXEC_TIMEOUT_MS = 120000; // 2 minute total timeout
10540
+
10541
+ function wfShowExecStatus(text, state) {
10542
+ const bar = document.getElementById('wfExecStatus');
10543
+ const textEl = document.getElementById('wfExecStatusText');
10544
+ if (!bar || !textEl) return;
10545
+ bar.style.display = 'flex';
10546
+ bar.className = 'wf-exec-status' + (state ? ' ' + state : '');
10547
+ textEl.textContent = text;
10548
+ }
10549
+
10550
+ function wfHideExecStatus() {
10551
+ const bar = document.getElementById('wfExecStatus');
10552
+ if (bar) bar.style.display = 'none';
10553
+ }
10554
+
10555
+ function wfUpdateExecTimer() {
10556
+ const timeEl = document.getElementById('wfExecStatusTime');
10557
+ if (!timeEl || !wfState.executing) return;
10558
+ const elapsed = Date.now() - wfExecStartTime;
10559
+ const secs = (elapsed / 1000).toFixed(1);
10560
+ timeEl.textContent = secs + 's';
10561
+
10562
+ // Check timeout
10563
+ if (elapsed > WF_EXEC_TIMEOUT_MS) {
10564
+ wfStopExecution('Execution timed out after ' + (WF_EXEC_TIMEOUT_MS / 1000) + 's');
10565
+ return;
10566
+ }
10567
+ wfExecTimer = requestAnimationFrame(wfUpdateExecTimer);
10568
+ }
10569
+
10570
+ function wfStopExecution(reason) {
10571
+ if (!wfState.executing) return;
10572
+ if (wfAbortController) {
10573
+ wfAbortController.abort();
10574
+ wfAbortController = null;
10575
+ }
10576
+ if (wfExecTimer) {
10577
+ cancelAnimationFrame(wfExecTimer);
10578
+ wfExecTimer = null;
10579
+ }
10580
+
10581
+ // Mark any still-running nodes as error
10582
+ const def = wfState.activeWorkflow;
10583
+ if (def) {
10584
+ def.steps.forEach(s => {
10585
+ if (wfState.executionState[s.id] === 'running') {
10586
+ wfState.executionState[s.id] = 'error';
10587
+ wfState.executionResults[s.id] = { error: reason || 'Stopped by user' };
10588
+ } else if (wfState.executionState[s.id] === 'pending') {
10589
+ wfState.executionState[s.id] = 'skipped';
10590
+ wfState.executionResults[s.id] = { reason: reason || 'Stopped by user' };
10591
+ }
10592
+ });
10593
+ wfRefreshNodes();
10594
+ }
10595
+
10596
+ const stopReason = reason || 'Stopped by user';
10597
+ wfShowExecStatus(stopReason, reason && reason.includes('timed out') ? 'error' : 'stopped');
10598
+ wfState.executing = false;
10599
+ wfSetToolbarEnabled(true);
10600
+ document.getElementById('wfStopBtn').style.display = 'none';
10601
+ wfUpdateInspector();
10602
+ }
10603
+
10604
+ async function wfExecute() {
10605
+ const def = wfState.activeWorkflow;
10606
+ if (!def || wfState.executing) return;
10607
+
10608
+ // Collect inputs
10609
+ const inputs = {};
10610
+ if (def.inputs) {
10611
+ for (const [key, spec] of Object.entries(def.inputs)) {
10612
+ const el = document.getElementById('wf-input-' + key);
10613
+ let val = el ? el.value : (spec.default !== undefined ? spec.default : undefined);
10614
+ if (val === '' && spec.default !== undefined) val = spec.default;
10615
+ if (val === '' && spec.required) {
10616
+ alert('Input "' + key + '" is required');
10617
+ return;
10618
+ }
10619
+ // Type coerce
10620
+ if (spec.type === 'number' && val !== undefined && val !== '') val = Number(val);
10621
+ if (val !== undefined && val !== '') inputs[key] = val;
10622
+ }
10623
+ }
10624
+
10625
+ wfState.executing = true;
10626
+ wfState.executionResults = {};
10627
+ wfAbortController = new AbortController();
10628
+ wfExecStartTime = Date.now();
10629
+ wfSetToolbarEnabled(false);
10630
+ document.getElementById('wfStopBtn').style.display = '';
10631
+ wfShowExecStatus('Running...', '');
10632
+ wfExecTimer = requestAnimationFrame(wfUpdateExecTimer);
10633
+
10634
+ // Reset all nodes to pending
10635
+ def.steps.forEach(s => { wfState.executionState[s.id] = 'pending'; });
10636
+ wfRefreshNodes();
10637
+
10638
+ let hasError = false;
10639
+ let errorMessage = '';
10640
+
10641
+ try {
10642
+ const res = await fetch('/api/workflows/execute', {
10643
+ method: 'POST',
10644
+ headers: { 'Content-Type': 'application/json' },
10645
+ body: JSON.stringify({ definition: def, inputs }),
10646
+ signal: wfAbortController.signal,
10647
+ });
10648
+
10649
+ if (!res.ok) {
10650
+ throw new Error('Server returned ' + res.status + ': ' + (await res.text()));
10651
+ }
10652
+
10653
+ const reader = res.body.getReader();
10654
+ const decoder = new TextDecoder();
10655
+ let buffer = '';
10656
+ let currentEvent = '';
10657
+
10658
+ while (true) {
10659
+ const { done, value } = await reader.read();
10660
+ if (done) break;
10661
+ buffer += decoder.decode(value, { stream: true });
10662
+
10663
+ const lines = buffer.split('\n');
10664
+ buffer = lines.pop() || '';
10665
+
10666
+ for (const line of lines) {
10667
+ if (line.startsWith('event: ')) {
10668
+ currentEvent = line.slice(7).trim();
10669
+ } else if (line.startsWith('data: ') && currentEvent) {
10670
+ let data;
10671
+ try { data = JSON.parse(line.slice(6)); } catch { continue; }
10672
+
10673
+ if (currentEvent === 'step_start') {
10674
+ wfState.executionState[data.stepId] = 'running';
10675
+ const stepName = (def.steps.find(s => s.id === data.stepId) || {}).name || data.stepId;
10676
+ wfShowExecStatus('Running: ' + stepName, '');
10677
+ wfRefreshNodes();
10678
+ } else if (currentEvent === 'step_complete') {
10679
+ wfState.executionState[data.stepId] = 'completed';
10680
+ wfState.executionResults[data.stepId] = {
10681
+ output: data.output,
10682
+ timeMs: data.timeMs,
10683
+ summary: data.summary || '',
10684
+ };
10685
+ wfRefreshNodes();
10686
+ if (wfState.selectedNodeId === data.stepId) wfUpdateInspector();
10687
+ } else if (currentEvent === 'step_skip') {
10688
+ wfState.executionState[data.stepId] = 'skipped';
10689
+ wfState.executionResults[data.stepId] = { reason: data.reason };
10690
+ wfRefreshNodes();
10691
+ if (wfState.selectedNodeId === data.stepId) wfUpdateInspector();
10692
+ } else if (currentEvent === 'step_error') {
10693
+ wfState.executionState[data.stepId] = 'error';
10694
+ wfState.executionResults[data.stepId] = { error: data.error };
10695
+ hasError = true;
10696
+ errorMessage = data.error || 'Step failed';
10697
+ wfRefreshNodes();
10698
+ if (wfState.selectedNodeId === data.stepId) wfUpdateInspector();
10699
+ } else if (currentEvent === 'done') {
10700
+ wfState.executionResults._done = data;
10701
+ if (!wfState.selectedNodeId) wfUpdateInspector();
10702
+ } else if (currentEvent === 'error') {
10703
+ hasError = true;
10704
+ errorMessage = data.error || 'Workflow error';
10705
+ console.error('Workflow execution error:', data.error);
10706
+ }
10707
+ currentEvent = '';
10708
+ }
10709
+ }
10710
+ }
10711
+ } catch (err) {
10712
+ if (err.name === 'AbortError') {
10713
+ // Handled by wfStopExecution
10714
+ return;
10715
+ }
10716
+ hasError = true;
10717
+ errorMessage = err.message || 'Execution failed';
10718
+ console.error('Workflow execution failed:', err);
10719
+ // Mark running nodes as error
10720
+ def.steps.forEach(s => {
10721
+ if (wfState.executionState[s.id] === 'running') {
10722
+ wfState.executionState[s.id] = 'error';
10723
+ wfState.executionResults[s.id] = { error: errorMessage };
10724
+ }
10725
+ });
10726
+ wfRefreshNodes();
10727
+ }
10728
+
10729
+ if (wfExecTimer) { cancelAnimationFrame(wfExecTimer); wfExecTimer = null; }
10730
+ wfAbortController = null;
10731
+ wfState.executing = false;
10732
+ wfSetToolbarEnabled(true);
10733
+ document.getElementById('wfStopBtn').style.display = 'none';
10734
+
10735
+ const elapsed = ((Date.now() - wfExecStartTime) / 1000).toFixed(1);
10736
+ if (hasError) {
10737
+ wfShowExecStatus('Failed: ' + errorMessage, 'error');
10738
+ } else {
10739
+ wfShowExecStatus('Completed in ' + elapsed + 's', 'done');
10740
+ }
10741
+ wfUpdateInspector();
10742
+ }
10743
+
10744
+ function wfRefreshNodes() {
10745
+ // Re-render the full SVG (simple approach: redraw)
10746
+ if (wfState.activeWorkflow) {
10747
+ const svg = document.getElementById('wf-canvas');
10748
+ const def = wfState.activeWorkflow;
10749
+ const positions = wfState.nodePositions;
10750
+
10751
+ // Remove existing nodes and edges
10752
+ svg.querySelectorAll('.wf-node, .wf-edge-group').forEach(el => el.remove());
10753
+
10754
+ // Redraw edges
10755
+ const edgeGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
10756
+ edgeGroup.classList.add('wf-edge-group');
10757
+ for (const [stepId, deps] of Object.entries(wfState.graph)) {
10758
+ if (!deps || !Array.isArray(deps)) continue;
10759
+ deps.forEach(rawDepId => {
10760
+ const depId = rawDepId.replace(/^!/, '');
10761
+ if (positions[depId] && positions[stepId]) {
10762
+ edgeGroup.appendChild(wfDrawEdge(depId, stepId, positions));
10763
+ }
10764
+ });
10765
+ }
10766
+ svg.appendChild(edgeGroup);
10767
+
10768
+ // Redraw nodes
10769
+ for (const step of def.steps) {
10770
+ const pos = positions[step.id];
10771
+ if (!pos) continue;
10772
+ const state = wfState.executionState[step.id] || 'idle';
10773
+ svg.appendChild(wfDrawNode(step, pos.x, pos.y, state));
10774
+ }
10775
+ }
10776
+ }
10777
+
10778
+ function wfResetExecution() {
10779
+ if (wfState.executing) return;
10780
+ wfState.executionState = {};
10781
+ wfState.executionResults = {};
10782
+ wfHideExecStatus();
10783
+ wfRefreshNodes();
10784
+ wfUpdateInspector();
10785
+ }
10786
+
10787
+ // ── Output Modal ──
10788
+ let wfOutputModalData = '';
10789
+
10790
+ function wfOpenOutputModal(title, content) {
10791
+ wfOutputModalData = content;
10792
+ const backdrop = document.getElementById('wfOutputModalBackdrop');
10793
+ const titleEl = document.getElementById('wfOutputModalTitle');
10794
+ const bodyEl = document.getElementById('wfOutputModalBody');
10795
+ const copyLabel = document.getElementById('wfOutputCopyLabel');
10796
+ if (!backdrop || !bodyEl) return;
10797
+ titleEl.textContent = title || 'Output';
10798
+ bodyEl.textContent = content;
10799
+ copyLabel.textContent = 'Copy';
10800
+ backdrop.style.display = 'flex';
10801
+ }
10802
+
10803
+ function wfCloseOutputModal() {
10804
+ const backdrop = document.getElementById('wfOutputModalBackdrop');
10805
+ if (backdrop) backdrop.style.display = 'none';
10806
+ }
10807
+
10808
+ function wfCopyOutput() {
10809
+ const label = document.getElementById('wfOutputCopyLabel');
10810
+ navigator.clipboard.writeText(wfOutputModalData).then(() => {
10811
+ if (label) { label.textContent = 'Copied!'; setTimeout(() => { label.textContent = 'Copy'; }, 2000); }
10812
+ }).catch(() => {
10813
+ if (label) label.textContent = 'Failed';
10814
+ });
10815
+ }
10816
+
10817
+ // Close modal on Escape
10818
+ document.addEventListener('keydown', (e) => {
10819
+ if (e.key === 'Escape') {
10820
+ const backdrop = document.getElementById('wfOutputModalBackdrop');
10821
+ if (backdrop && backdrop.style.display !== 'none') {
10822
+ wfCloseOutputModal();
10823
+ e.preventDefault();
10824
+ }
10825
+ }
10826
+ });
10827
+
10828
+ // ── Zoom & Pan ──
10829
+ function wfZoom(direction) {
10830
+ // Multiplicative zoom: feels consistent at any zoom level
10831
+ const factor = direction > 0 ? 1.1 : 1 / 1.1;
10832
+ wfState.zoom = Math.max(0.2, Math.min(5, wfState.zoom * factor));
10833
+ wfApplyViewBox();
10834
+ }
10835
+
10836
+ function wfFitToView() {
10837
+ const svg = document.getElementById('wf-canvas');
10838
+ if (!svg) return;
10839
+ const positions = wfState.nodePositions;
10840
+ const ids = Object.keys(positions);
10841
+ if (ids.length === 0) return;
10842
+
10843
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
10844
+ ids.forEach(id => {
10845
+ const p = positions[id];
10846
+ minX = Math.min(minX, p.x);
10847
+ minY = Math.min(minY, p.y);
10848
+ maxX = Math.max(maxX, p.x + WF_NODE_W);
10849
+ maxY = Math.max(maxY, p.y + WF_NODE_H + 50); // extra for time label + backward edge loops
10850
+ });
10851
+
10852
+ const pad = 40;
10853
+ const vbW = maxX - minX + pad * 2;
10854
+ const vbH = maxY - minY + pad * 2;
10855
+ wfState.panX = minX - pad;
10856
+ wfState.panY = minY - pad;
10857
+ // Calculate zoom to fit
10858
+ const rect = svg.parentElement.getBoundingClientRect();
10859
+ const scaleX = rect.width / vbW;
10860
+ const scaleY = rect.height / vbH;
10861
+ wfState.zoom = Math.min(scaleX, scaleY, 1.5);
10862
+ wfApplyViewBox();
10863
+ }
10864
+
10865
+ function wfApplyViewBox() {
10866
+ const svg = document.getElementById('wf-canvas');
10867
+ if (!svg) return;
10868
+ const rect = svg.parentElement.getBoundingClientRect();
10869
+ const w = rect.width / wfState.zoom;
10870
+ const h = rect.height / wfState.zoom;
10871
+ svg.setAttribute('viewBox', `${wfState.panX} ${wfState.panY} ${w} ${h}`);
10872
+ }
10873
+
10874
+ // Pan via mouse drag
10875
+ function wfInitPan() {
10876
+ const svg = document.getElementById('wf-canvas');
10877
+ if (!svg) return;
10878
+
10879
+ svg.addEventListener('mousedown', (e) => {
10880
+ if (e.target === svg || e.target.tagName === 'svg') {
10881
+ wfState.isPanning = true;
10882
+ wfState.panStart = { x: e.clientX, y: e.clientY };
10883
+ svg.style.cursor = 'grabbing';
10884
+ }
10885
+ });
10886
+
10887
+ svg.addEventListener('mousemove', (e) => {
10888
+ if (!wfState.isPanning) return;
10889
+ const dx = (e.clientX - wfState.panStart.x) / wfState.zoom;
10890
+ const dy = (e.clientY - wfState.panStart.y) / wfState.zoom;
10891
+ wfState.panX -= dx;
10892
+ wfState.panY -= dy;
10893
+ wfState.panStart = { x: e.clientX, y: e.clientY };
10894
+ wfApplyViewBox();
10895
+ });
10896
+
10897
+ svg.addEventListener('mouseup', () => {
10898
+ wfState.isPanning = false;
10899
+ svg.style.cursor = '';
10900
+ });
10901
+
10902
+ svg.addEventListener('mouseleave', () => {
10903
+ wfState.isPanning = false;
10904
+ svg.style.cursor = '';
10905
+ });
10906
+
10907
+ // Scroll wheel zoom, centered on cursor position
10908
+ svg.addEventListener('wheel', (e) => {
10909
+ e.preventDefault();
10910
+ const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
10911
+ const oldZoom = wfState.zoom;
10912
+ const newZoom = Math.max(0.2, Math.min(5, oldZoom * factor));
10913
+ // Zoom toward cursor: keep the SVG point under the mouse fixed
10914
+ const rect = svg.getBoundingClientRect();
10915
+ const mx = (e.clientX - rect.left) / oldZoom + wfState.panX;
10916
+ const my = (e.clientY - rect.top) / oldZoom + wfState.panY;
10917
+ wfState.zoom = newZoom;
10918
+ wfState.panX = mx - (e.clientX - rect.left) / newZoom;
10919
+ wfState.panY = my - (e.clientY - rect.top) / newZoom;
10920
+ wfApplyViewBox();
10921
+ }, { passive: false });
10922
+
10923
+ // Click on canvas background to deselect
10924
+ svg.addEventListener('click', (e) => {
10925
+ if (e.target === svg || e.target.tagName === 'svg') {
10926
+ wfDeselectNode();
10927
+ }
10928
+ });
10929
+ }
10930
+
10931
+ // ── Docs shortcut (F1) ──
10932
+ const DOCS_URLS = {
10933
+ embed: 'https://docs.vaicli.com/docs/commands/embeddings/embed',
10934
+ compare: 'https://docs.vaicli.com/docs/commands/embeddings/similarity',
10935
+ search: 'https://docs.vaicli.com/docs/commands/embeddings/rerank',
10936
+ multimodal: 'https://docs.vaicli.com/docs/commands/embeddings/embed',
10937
+ generate: 'https://docs.vaicli.com/docs/commands/project-setup/generate',
10938
+ chat: 'https://docs.vaicli.com/docs/commands/advanced/chat',
10939
+ workflows: 'https://docs.vaicli.com/docs/commands/advanced/workflow-run',
10940
+ benchmark: 'https://docs.vaicli.com/docs/commands/evaluation/benchmark',
10941
+ explore: 'https://docs.vaicli.com/docs/commands/tools-and-learning/explain',
10942
+ about: 'https://docs.vaicli.com/docs/',
10943
+ settings: 'https://docs.vaicli.com/docs/commands/tools-and-learning/config',
10944
+ };
10945
+ document.addEventListener('keydown', (e) => {
10946
+ if (e.key === 'F1') {
10947
+ e.preventDefault();
10948
+ const activeTab = document.querySelector('.tab-btn.active');
10949
+ const tabName = activeTab ? activeTab.dataset.tab : '';
10950
+ const url = DOCS_URLS[tabName] || 'https://docs.vaicli.com';
10951
+ window.open(url, '_blank', 'noopener');
10952
+ }
10953
+ });
10954
+
10955
+ // Keyboard shortcuts (when workflows tab is active)
10956
+ document.addEventListener('keydown', (e) => {
10957
+ const activeTab = document.querySelector('.tab-btn.active');
10958
+ if (!activeTab || activeTab.dataset.tab !== 'workflows') return;
10959
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
10960
+
10961
+ const PAN_STEP = 40;
10962
+ if (e.key === '+' || e.key === '=') { wfZoom(1); e.preventDefault(); }
10963
+ else if (e.key === '-') { wfZoom(-1); e.preventDefault(); }
10964
+ else if (e.key === '0') { wfFitToView(); e.preventDefault(); }
10965
+ else if (e.key === 'Escape') { wfDeselectNode(); e.preventDefault(); }
10966
+ else if (e.key === 'ArrowLeft') { wfState.panX -= PAN_STEP / wfState.zoom; wfApplyViewBox(); e.preventDefault(); }
10967
+ else if (e.key === 'ArrowRight') { wfState.panX += PAN_STEP / wfState.zoom; wfApplyViewBox(); e.preventDefault(); }
10968
+ else if (e.key === 'ArrowUp') { wfState.panY -= PAN_STEP / wfState.zoom; wfApplyViewBox(); e.preventDefault(); }
10969
+ else if (e.key === 'ArrowDown') { wfState.panY += PAN_STEP / wfState.zoom; wfApplyViewBox(); e.preventDefault(); }
10970
+ });
10971
+
10972
+ // ── Init ──
10973
+ function wfInit() {
10974
+ wfLoadLibrary();
10975
+ wfInitPan();
10976
+ }
10977
+
10978
+ // Auto-init: use MutationObserver on the panel to detect when it becomes visible
10979
+ let wfInitialized = false;
10980
+ (function() {
10981
+ const panel = document.getElementById('tab-workflows');
10982
+ if (!panel) return;
10983
+ const observer = new MutationObserver(() => {
10984
+ if (panel.classList.contains('active') && !wfInitialized) {
10985
+ wfInitialized = true;
10986
+ wfInit();
10987
+ }
10988
+ });
10989
+ observer.observe(panel, { attributes: true, attributeFilter: ['class'] });
10990
+ // Also init immediately if the tab is already active (shouldn't be, but safety)
10991
+ if (panel.classList.contains('active')) {
10992
+ wfInitialized = true;
10993
+ wfInit();
10994
+ }
10995
+ })();
10996
+ </script>
10997
+
8754
10998
  <script>
8755
10999
  (function() {
8756
11000
  const BUG_API = 'https://vaicli.com/api/bugs';