voyageai-cli 1.26.0 → 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;
@@ -2334,6 +2409,247 @@ select:focus { outline: none; border-color: var(--accent); }
2334
2409
  .chat-sources summary { cursor: pointer; }
2335
2410
  .chat-sources ul { margin: 4px 0 0 16px; padding: 0; }
2336
2411
  .chat-sources li { margin: 2px 0; }
2412
+ /* Markdown rendering in assistant messages */
2413
+ .chat-message.assistant .chat-message-content.rendered {
2414
+ white-space: normal;
2415
+ }
2416
+ .chat-message.assistant .chat-message-content.rendered p {
2417
+ margin: 0 0 8px 0;
2418
+ }
2419
+ .chat-message.assistant .chat-message-content.rendered p:last-child {
2420
+ margin-bottom: 0;
2421
+ }
2422
+ .chat-message.assistant .chat-message-content.rendered h1,
2423
+ .chat-message.assistant .chat-message-content.rendered h2,
2424
+ .chat-message.assistant .chat-message-content.rendered h3,
2425
+ .chat-message.assistant .chat-message-content.rendered h4 {
2426
+ margin: 12px 0 6px 0;
2427
+ font-weight: 600;
2428
+ color: var(--accent-text, #fff);
2429
+ }
2430
+ .chat-message.assistant .chat-message-content.rendered h1 { font-size: 1.3em; }
2431
+ .chat-message.assistant .chat-message-content.rendered h2 { font-size: 1.15em; }
2432
+ .chat-message.assistant .chat-message-content.rendered h3 { font-size: 1.05em; }
2433
+ .chat-message.assistant .chat-message-content.rendered code {
2434
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
2435
+ font-size: 0.9em;
2436
+ background: var(--bg-input, #112733);
2437
+ padding: 2px 5px;
2438
+ border-radius: 4px;
2439
+ }
2440
+ .chat-message.assistant .chat-message-content.rendered pre {
2441
+ background: var(--bg-input, #112733);
2442
+ border: 1px solid var(--border);
2443
+ border-radius: 8px;
2444
+ padding: 12px;
2445
+ margin: 8px 0;
2446
+ overflow-x: auto;
2447
+ white-space: pre;
2448
+ }
2449
+ .chat-message.assistant .chat-message-content.rendered pre code {
2450
+ background: none;
2451
+ padding: 0;
2452
+ border-radius: 0;
2453
+ font-size: 13px;
2454
+ line-height: 1.5;
2455
+ }
2456
+ .chat-message.assistant .chat-message-content.rendered ul,
2457
+ .chat-message.assistant .chat-message-content.rendered ol {
2458
+ margin: 6px 0;
2459
+ padding-left: 20px;
2460
+ }
2461
+ .chat-message.assistant .chat-message-content.rendered li {
2462
+ margin: 3px 0;
2463
+ }
2464
+ .chat-message.assistant .chat-message-content.rendered blockquote {
2465
+ border-left: 3px solid var(--accent, #00D4AA);
2466
+ margin: 8px 0;
2467
+ padding: 4px 12px;
2468
+ color: var(--text-muted);
2469
+ }
2470
+ .chat-message.assistant .chat-message-content.rendered a {
2471
+ color: var(--accent, #00D4AA);
2472
+ text-decoration: none;
2473
+ }
2474
+ .chat-message.assistant .chat-message-content.rendered a:hover {
2475
+ text-decoration: underline;
2476
+ }
2477
+ .chat-message.assistant .chat-message-content.rendered table {
2478
+ border-collapse: collapse;
2479
+ margin: 8px 0;
2480
+ font-size: 13px;
2481
+ width: 100%;
2482
+ }
2483
+ .chat-message.assistant .chat-message-content.rendered th,
2484
+ .chat-message.assistant .chat-message-content.rendered td {
2485
+ border: 1px solid var(--border);
2486
+ padding: 6px 10px;
2487
+ text-align: left;
2488
+ }
2489
+ .chat-message.assistant .chat-message-content.rendered th {
2490
+ background: var(--bg-input, #112733);
2491
+ font-weight: 600;
2492
+ }
2493
+ .chat-message.assistant .chat-message-content.rendered hr {
2494
+ border: none;
2495
+ border-top: 1px solid var(--border);
2496
+ margin: 12px 0;
2497
+ }
2498
+
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;
2652
+ }
2337
2653
  .chat-input-area {
2338
2654
  padding: 12px 16px;
2339
2655
  border-top: 1px solid var(--border);
@@ -2648,6 +2964,450 @@ select:focus { outline: none; border-color: var(--accent); }
2648
2964
  .settings-row { flex-direction: column; align-items: flex-start; gap: 8px; }
2649
2965
  .settings-select, .settings-input { min-width: 100%; }
2650
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; }
2651
3411
  </style>
2652
3412
  </head>
2653
3413
  <body>
@@ -2699,6 +3459,9 @@ select:focus { outline: none; border-color: var(--accent); }
2699
3459
  <symbol id="lg-shield" viewBox="0 0 16 16">
2700
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"/>
2701
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>
2702
3465
  </svg>
2703
3466
 
2704
3467
  <div class="app-shell">
@@ -2719,6 +3482,7 @@ select:focus { outline: none; border-color: var(--accent); }
2719
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>
2720
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>
2721
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>
2722
3486
  </div>
2723
3487
  <div class="sidebar-nav-divider"></div>
2724
3488
  <div class="sidebar-nav-group" role="tablist" aria-label="Learn">
@@ -2738,10 +3502,16 @@ select:focus { outline: none; border-color: var(--accent); }
2738
3502
  </div>
2739
3503
  <div style="display:flex;align-items:center;justify-content:space-between;">
2740
3504
  <div id="appVersionLabel" style="font-size:10px;color:var(--text-muted);"></div>
2741
- <button class="sidebar-bug-link" id="bugButton" title="Report a Bug">
2742
- <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>
2743
- <span class="sidebar-bug-label">Bug</span>
2744
- </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>
2745
3515
  </div>
2746
3516
  </div>
2747
3517
  </aside>
@@ -2771,6 +3541,7 @@ select:focus { outline: none; border-color: var(--accent); }
2771
3541
  <h2 class="page-header-title">Embed</h2>
2772
3542
  <p class="page-header-subtitle">Generate vector embeddings for text</p>
2773
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>
2774
3545
  </div>
2775
3546
  <div class="card">
2776
3547
  <div class="card-title">Input Text</div>
@@ -2834,7 +3605,8 @@ select:focus { outline: none; border-color: var(--accent); }
2834
3605
  <div class="page-header">
2835
3606
  <h2 class="page-header-title">Compare</h2>
2836
3607
  <p class="page-header-subtitle">Visualize similarity between text pairs</p>
2837
- <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>
2838
3610
  </div>
2839
3611
  <div class="compare-grid">
2840
3612
  <div class="card">
@@ -2888,7 +3660,8 @@ select:focus { outline: none; border-color: var(--accent); }
2888
3660
  <div class="page-header">
2889
3661
  <h2 class="page-header-title">Rerank</h2>
2890
3662
  <p class="page-header-subtitle">Re-order documents by relevance to a query</p>
2891
- <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>
2892
3665
  </div>
2893
3666
  <div class="card">
2894
3667
  <div class="card-title">Query</div>
@@ -2939,7 +3712,8 @@ Semantic search understands meaning beyond keyword matching</textarea>
2939
3712
  <div class="page-header">
2940
3713
  <h2 class="page-header-title">Multimodal</h2>
2941
3714
  <p class="page-header-subtitle">Compare images and text in the same vector space</p>
2942
- <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>
2943
3717
  </div>
2944
3718
 
2945
3719
  <!-- Section A: Image ↔ Text Similarity -->
@@ -2949,7 +3723,7 @@ Semantic search understands meaning beyond keyword matching</textarea>
2949
3723
  <div class="mm-drop-zone" id="mmDropZone">
2950
3724
  <div class="mm-drop-icon">🖼️</div>
2951
3725
  <div class="mm-drop-text">Drop an image here or click to browse</div>
2952
- <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>
2953
3727
  </div>
2954
3728
  <input type="file" id="mmFileInput" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
2955
3729
  <div class="mm-preview" id="mmPreview">
@@ -3055,6 +3829,7 @@ Semantic search understands meaning beyond keyword matching</textarea>
3055
3829
  <h2 class="page-header-title">Benchmark</h2>
3056
3830
  <p class="page-header-subtitle">Compare model speed, cost, and quality</p>
3057
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>
3058
3833
  </div>
3059
3834
 
3060
3835
  <!-- Sub-panel switcher -->
@@ -3562,6 +4337,80 @@ Reranking models rescore initial search results to improve relevance ordering.</
3562
4337
  </div>
3563
4338
  </div>
3564
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
+
3565
4414
  <!-- ========== ABOUT TAB ========== -->
3566
4415
  <div class="tab-panel" id="tab-about" role="tabpanel" aria-labelledby="tab-btn-about" tabindex="0">
3567
4416
  <div class="about-container">
@@ -3607,8 +4456,11 @@ Reranking models rescore initial search results to improve relevance ordering.</
3607
4456
  <strong>⚖️ Compare</strong> — Measure similarity with cosine, dot product &amp; euclidean distance<br>
3608
4457
  <strong>🔍 Search</strong> — Semantic search with optional reranking<br>
3609
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>
3610
4462
  <strong>⏱ Benchmark</strong> — Compare model latency, ranking quality, and costs<br>
3611
- <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
3612
4464
  </div>
3613
4465
  </div>
3614
4466
 
@@ -3639,9 +4491,12 @@ Reranking models rescore initial search results to improve relevance ordering.</
3639
4491
  <div class="about-section" style="padding-bottom:0;">
3640
4492
  <div class="about-section-title">What's New</div>
3641
4493
  <div class="about-text" style="font-size:13px;">
3642
- <strong>v1.2</strong> — Multimodal tab (image text similarity, cross-modal gallery search),
3643
- 4 new Explore concepts (multimodal embeddings, cross-modal search, modality gap, multimodal RAG),
3644
- 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 🕹️
3645
4500
  </div>
3646
4501
  </div>
3647
4502
  </div>
@@ -3658,6 +4513,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
3658
4513
  <div class="page-header">
3659
4514
  <h2 class="page-header-title">Generate</h2>
3660
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>
3661
4517
  </div>
3662
4518
 
3663
4519
  <!-- Mode Toggle -->
@@ -3831,7 +4687,8 @@ Reranking models rescore initial search results to improve relevance ordering.</
3831
4687
  <div class="page-header">
3832
4688
  <h2 class="page-header-title">Explore</h2>
3833
4689
  <p class="page-header-subtitle">Learn embedding and vector search concepts</p>
3834
- <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>
3835
4692
  </div>
3836
4693
  <div style="margin-bottom:16px;">
3837
4694
  <input type="text" id="exploreSearch" placeholder="🔍 Search concepts..." oninput="filterExplore()" style="max-width:400px;" aria-label="Search concepts">
@@ -3888,6 +4745,10 @@ Reranking models rescore initial search results to improve relevance ordering.</
3888
4745
  <svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-shield"/></svg>
3889
4746
  <span>Data &amp; Privacy</span>
3890
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>
3891
4752
  </nav>
3892
4753
 
3893
4754
  <!-- Settings content panels -->
@@ -4046,6 +4907,39 @@ Reranking models rescore initial search results to improve relevance ordering.</
4046
4907
  </select>
4047
4908
  </div>
4048
4909
  </div>
4910
+ <div class="settings-row" id="chatApiKeyRow" style="display:none">
4911
+ <div class="settings-label">
4912
+ <span class="settings-label-text">API Key</span>
4913
+ <span class="settings-label-hint">Provider API key (stored securely in OS keychain)</span>
4914
+ </div>
4915
+ <div class="settings-control" style="display:flex;gap:8px;align-items:center">
4916
+ <input
4917
+ type="password"
4918
+ class="settings-input"
4919
+ id="chatApiKey"
4920
+ placeholder="Enter your API key"
4921
+ style="flex:1;font-family:monospace;font-size:13px"
4922
+ >
4923
+ <button
4924
+ class="btn btn-secondary"
4925
+ id="chatApiKeyToggle"
4926
+ onclick="toggleChatApiKeyVisibility()"
4927
+ title="Show/hide API key"
4928
+ style="padding:8px 12px;min-width:auto"
4929
+ >
4930
+ 👁️
4931
+ </button>
4932
+ <button
4933
+ class="btn"
4934
+ id="chatApiKeySave"
4935
+ onclick="saveChatApiKey()"
4936
+ title="Save API key"
4937
+ style="padding:8px 12px;min-width:auto"
4938
+ >
4939
+ 💾
4940
+ </button>
4941
+ </div>
4942
+ </div>
4049
4943
  </div>
4050
4944
  <div class="settings-section">
4051
4945
  <div class="settings-section-title">Knowledge Base</div>
@@ -4085,6 +4979,18 @@ Reranking models rescore initial search results to improve relevance ordering.</
4085
4979
  <button class="settings-toggle active" id="chatRerank" type="button"></button>
4086
4980
  </div>
4087
4981
  </div>
4982
+ <div class="settings-row">
4983
+ <div class="settings-label">
4984
+ <span class="settings-label-text">Chat Mode</span>
4985
+ <span class="settings-label-hint">Pipeline: fixed RAG retrieval. Agent: LLM chooses which tools to call.</span>
4986
+ </div>
4987
+ <div class="settings-control">
4988
+ <select class="settings-select" id="chatMode">
4989
+ <option value="pipeline">Pipeline (fixed RAG)</option>
4990
+ <option value="agent">Agent (tool-calling)</option>
4991
+ </select>
4992
+ </div>
4993
+ </div>
4088
4994
  </div>
4089
4995
  <div class="settings-section">
4090
4996
  <div class="settings-section-title">Custom Instructions</div>
@@ -4202,6 +5108,30 @@ Reranking models rescore initial search results to improve relevance ordering.</
4202
5108
  </div>
4203
5109
  </div>
4204
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
+
4205
5135
  <div style="text-align:center;padding:8px 0;">
4206
5136
  <span class="settings-saved" id="settingsSavedMsg">✓ Saved</span>
4207
5137
  </div>
@@ -5169,7 +6099,9 @@ async function loadConcepts() {
5169
6099
  }
5170
6100
 
5171
6101
  function escapeHtml(str) {
5172
- 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;');
5173
6105
  }
5174
6106
 
5175
6107
  function buildExploreCards() {
@@ -6497,6 +7429,8 @@ function initSettings() {
6497
7429
  }
6498
7430
  const chatModel = document.getElementById('chatModel');
6499
7431
  if (chatModel) chatModel.addEventListener('change', saveChatSettings);
7432
+ const chatMode = document.getElementById('chatMode');
7433
+ if (chatMode) chatMode.addEventListener('change', saveChatSettings);
6500
7434
  const chatMaxDocs = document.getElementById('chatMaxDocs');
6501
7435
  if (chatMaxDocs) chatMaxDocs.addEventListener('change', saveChatSettings);
6502
7436
 
@@ -6508,11 +7442,117 @@ function initSettings() {
6508
7442
  const chatSystemPrompt = document.getElementById('chatSystemPrompt');
6509
7443
  if (chatSystemPrompt) chatSystemPrompt.addEventListener('input', saveChatSettingsDebounced);
6510
7444
 
7445
+ // API key input listeners
7446
+ const chatApiKey = document.getElementById('chatApiKey');
7447
+ if (chatApiKey) {
7448
+ // Mark as modified when user types
7449
+ chatApiKey.addEventListener('input', () => {
7450
+ delete chatApiKey.dataset.isMasked;
7451
+ delete chatApiKey.dataset.actualKey;
7452
+ });
7453
+ // Save on Enter key
7454
+ chatApiKey.addEventListener('keydown', (e) => {
7455
+ if (e.key === 'Enter') {
7456
+ e.preventDefault();
7457
+ saveChatApiKey();
7458
+ }
7459
+ });
7460
+ }
7461
+
6511
7462
  // Set up settings sub-navigation
6512
7463
  setupSettingsNav();
7464
+
7465
+ // Set up doctor health check button
7466
+ setupDoctorPanel();
6513
7467
  }
6514
7468
 
6515
- // ── Update Checker ──
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';
7553
+ }
7554
+
7555
+ // ── Update Checker ──
6516
7556
  function checkForAppUpdate() {
6517
7557
  if (!window.vai || !window.vai.isElectron) return;
6518
7558
 
@@ -6701,6 +7741,13 @@ function initOnboarding() {
6701
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.',
6702
7742
  arrow: 'left',
6703
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
+ },
6704
7751
  {
6705
7752
  target: '[data-tab="benchmark"]',
6706
7753
  icon: '⏱️',
@@ -6881,10 +7928,29 @@ function initMultimodal() {
6881
7928
  const dropZone = document.getElementById('mmDropZone');
6882
7929
  const fileInput = document.getElementById('mmFileInput');
6883
7930
 
6884
- // Click to browse
6885
- dropZone.addEventListener('click', () => fileInput.click());
7931
+ // Click to browse — in Electron, use native dialog (more reliable);
7932
+ // in browser, use <input type="file"> with a re-trigger guard.
7933
+ let fileDialogOpen = false;
7934
+ dropZone.addEventListener('click', async () => {
7935
+ if (fileDialogOpen) return;
7936
+ fileDialogOpen = true;
7937
+ try {
7938
+ if (window.vai && window.vai.isElectron && window.vai.openImageDialog) {
7939
+ const result = await window.vai.openImageDialog();
7940
+ if (!result.canceled && result.dataUrl) {
7941
+ handleMultimodalImageFromData(result.dataUrl, result.name, result.size);
7942
+ }
7943
+ } else {
7944
+ fileInput.click();
7945
+ }
7946
+ } finally {
7947
+ setTimeout(() => { fileDialogOpen = false; }, 300);
7948
+ }
7949
+ });
6886
7950
  fileInput.addEventListener('change', (e) => {
7951
+ fileDialogOpen = false;
6887
7952
  if (e.target.files && e.target.files[0]) handleMultimodalImage(e.target.files[0]);
7953
+ fileInput.value = '';
6888
7954
  });
6889
7955
 
6890
7956
  // Drag and drop
@@ -6936,6 +8002,23 @@ function initMultimodal() {
6936
8002
  });
6937
8003
  }
6938
8004
 
8005
+ // Handle image from Electron native dialog (already has dataUrl)
8006
+ function handleMultimodalImageFromData(dataUrl, name, size) {
8007
+ mmImageData = dataUrl;
8008
+ const img = document.getElementById('mmPreviewImg');
8009
+ img.src = mmImageData;
8010
+ img.onload = () => {
8011
+ const info = document.getElementById('mmFileInfo');
8012
+ const sizeStr = size > 1024 * 1024
8013
+ ? (size / (1024 * 1024)).toFixed(1) + ' MB'
8014
+ : (size / 1024).toFixed(0) + ' KB';
8015
+ info.textContent = `${name} · ${img.naturalWidth}×${img.naturalHeight} · ${sizeStr}`;
8016
+ };
8017
+ document.getElementById('mmDropZone').style.display = 'none';
8018
+ document.getElementById('mmPreview').classList.add('visible');
8019
+ hideError('mmError');
8020
+ }
8021
+
6939
8022
  function handleMultimodalImage(file) {
6940
8023
  const VALID_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
6941
8024
  if (!VALID_TYPES.includes(file.type)) {
@@ -7073,13 +8156,33 @@ function renderGalleryGrid() {
7073
8156
  const addSlot = document.createElement('div');
7074
8157
  addSlot.className = 'mm-gallery-slot';
7075
8158
  addSlot.innerHTML = '<span class="mm-slot-add">+</span>';
7076
- addSlot.addEventListener('click', () => {
7077
- document.getElementById('mmGalleryFileInput').click();
8159
+ addSlot.addEventListener('click', async () => {
8160
+ if (window._galleryDialogOpen) return;
8161
+ window._galleryDialogOpen = true;
8162
+ try {
8163
+ if (window.vai && window.vai.isElectron && window.vai.openImageDialog) {
8164
+ const result = await window.vai.openImageDialog();
8165
+ if (!result.canceled && result.dataUrl) {
8166
+ addGalleryImageFromData(result.dataUrl, result.name, result.size);
8167
+ }
8168
+ } else {
8169
+ document.getElementById('mmGalleryFileInput').click();
8170
+ }
8171
+ } finally {
8172
+ setTimeout(() => { window._galleryDialogOpen = false; }, 300);
8173
+ }
7078
8174
  });
7079
8175
  grid.appendChild(addSlot);
7080
8176
  }
7081
8177
  }
7082
8178
 
8179
+ // Add gallery image from Electron native dialog (already has dataUrl)
8180
+ function addGalleryImageFromData(dataUrl, name, size) {
8181
+ if (mmGalleryImages.length >= 6) return;
8182
+ mmGalleryImages.push({ dataUrl, name, size });
8183
+ renderGalleryGrid();
8184
+ }
8185
+
7083
8186
  function addGalleryImage(file) {
7084
8187
  const VALID_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
7085
8188
  if (!VALID_TYPES.includes(file.type)) return;
@@ -7731,6 +8834,7 @@ function saveChatSettings() {
7731
8834
  maxDocs: parseInt(document.getElementById('chatMaxDocs').value) || 5,
7732
8835
  rerank: document.getElementById('chatRerank').classList.contains('active'),
7733
8836
  systemPrompt: document.getElementById('chatSystemPrompt').value.trim(),
8837
+ mode: document.getElementById('chatMode').value,
7734
8838
  };
7735
8839
  fetch('/api/chat/config', {
7736
8840
  method: 'POST',
@@ -7779,6 +8883,7 @@ async function loadChatConfig() {
7779
8883
  if (data.chat?.maxContextDocs) document.getElementById('chatMaxDocs').value = data.chat.maxContextDocs;
7780
8884
  if (data.chat?.rerank === false) document.getElementById('chatRerank').classList.remove('active');
7781
8885
  if (data.chat?.systemPrompt) document.getElementById('chatSystemPrompt').value = data.chat.systemPrompt;
8886
+ if (data.mode || data.chat?.mode) document.getElementById('chatMode').value = data.mode || data.chat?.mode || 'pipeline';
7782
8887
  updateChatStatus();
7783
8888
  // Show not-configured banner if incomplete
7784
8889
  const notConfigured = document.getElementById('chatNotConfigured');
@@ -7792,23 +8897,137 @@ async function loadChatConfig() {
7792
8897
  }
7793
8898
  }
7794
8899
 
8900
+ // ── Chat API Key Management ──
8901
+
8902
+ async function loadChatApiKey() {
8903
+ if (!window.vai?.llmKey) return;
8904
+ const apiKeyInput = document.getElementById('chatApiKey');
8905
+ if (!apiKeyInput) return;
8906
+
8907
+ try {
8908
+ const key = await window.vai.llmKey.get();
8909
+ if (key) {
8910
+ // Mask the key by default (show first 4 + last 4)
8911
+ const masked = key.slice(0, 4) + '•'.repeat(Math.max(0, key.length - 8)) + key.slice(-4);
8912
+ apiKeyInput.value = masked;
8913
+ apiKeyInput.dataset.actualKey = key;
8914
+ apiKeyInput.dataset.isMasked = 'true';
8915
+ apiKeyInput.type = 'password';
8916
+ } else {
8917
+ apiKeyInput.value = '';
8918
+ delete apiKeyInput.dataset.actualKey;
8919
+ delete apiKeyInput.dataset.isMasked;
8920
+ }
8921
+ } catch (err) {
8922
+ console.error('Failed to load LLM API key:', err);
8923
+ }
8924
+ }
8925
+
8926
+ function toggleChatApiKeyVisibility() {
8927
+ const apiKeyInput = document.getElementById('chatApiKey');
8928
+ const toggleBtn = document.getElementById('chatApiKeyToggle');
8929
+ if (!apiKeyInput || !toggleBtn) return;
8930
+
8931
+ if (apiKeyInput.dataset.isMasked === 'true' && apiKeyInput.dataset.actualKey) {
8932
+ // Show actual key
8933
+ apiKeyInput.value = apiKeyInput.dataset.actualKey;
8934
+ apiKeyInput.type = 'text';
8935
+ apiKeyInput.dataset.isMasked = 'false';
8936
+ toggleBtn.textContent = '🙈';
8937
+ toggleBtn.title = 'Hide API key';
8938
+ } else if (apiKeyInput.dataset.actualKey) {
8939
+ // Re-mask the key
8940
+ const key = apiKeyInput.dataset.actualKey;
8941
+ const masked = key.slice(0, 4) + '•'.repeat(Math.max(0, key.length - 8)) + key.slice(-4);
8942
+ apiKeyInput.value = masked;
8943
+ apiKeyInput.type = 'password';
8944
+ apiKeyInput.dataset.isMasked = 'true';
8945
+ toggleBtn.textContent = '👁️';
8946
+ toggleBtn.title = 'Show API key';
8947
+ } else {
8948
+ // No stored key, just toggle between text/password for new input
8949
+ apiKeyInput.type = apiKeyInput.type === 'password' ? 'text' : 'password';
8950
+ toggleBtn.textContent = apiKeyInput.type === 'password' ? '👁️' : '🙈';
8951
+ toggleBtn.title = apiKeyInput.type === 'password' ? 'Show API key' : 'Hide API key';
8952
+ }
8953
+ }
8954
+
8955
+ async function saveChatApiKey() {
8956
+ if (!window.vai?.llmKey) return;
8957
+ const apiKeyInput = document.getElementById('chatApiKey');
8958
+ const toggleBtn = document.getElementById('chatApiKeyToggle');
8959
+ if (!apiKeyInput) return;
8960
+
8961
+ let key = apiKeyInput.value.trim();
8962
+
8963
+ // If showing masked value and it hasn't been modified, no need to save
8964
+ if (apiKeyInput.dataset.isMasked === 'true' && apiKeyInput.dataset.actualKey) {
8965
+ const currentMasked = apiKeyInput.dataset.actualKey.slice(0, 4) + '•'.repeat(Math.max(0, apiKeyInput.dataset.actualKey.length - 8)) + apiKeyInput.dataset.actualKey.slice(-4);
8966
+ if (key === currentMasked) {
8967
+ flashSaved();
8968
+ return;
8969
+ }
8970
+ }
8971
+
8972
+ if (!key) {
8973
+ // Delete the key if empty
8974
+ try {
8975
+ await window.vai.llmKey.delete();
8976
+ delete apiKeyInput.dataset.actualKey;
8977
+ delete apiKeyInput.dataset.isMasked;
8978
+ flashSaved();
8979
+ } catch (err) {
8980
+ console.error('Failed to delete LLM API key:', err);
8981
+ alert('Failed to delete API key: ' + err.message);
8982
+ }
8983
+ return;
8984
+ }
8985
+
8986
+ try {
8987
+ await window.vai.llmKey.set(key);
8988
+ // Mask the newly saved key
8989
+ const masked = key.slice(0, 4) + '•'.repeat(Math.max(0, key.length - 8)) + key.slice(-4);
8990
+ apiKeyInput.value = masked;
8991
+ apiKeyInput.dataset.actualKey = key;
8992
+ apiKeyInput.dataset.isMasked = 'true';
8993
+ apiKeyInput.type = 'password';
8994
+ toggleBtn.textContent = '👁️';
8995
+ toggleBtn.title = 'Show API key';
8996
+ flashSaved();
8997
+ } catch (err) {
8998
+ console.error('Failed to save LLM API key:', err);
8999
+ alert('Failed to save API key: ' + err.message);
9000
+ }
9001
+ }
9002
+
7795
9003
  function updateChatStatus() {
7796
9004
  const provider = document.getElementById('chatProvider');
7797
9005
  const db = document.getElementById('chatDb').value;
7798
9006
  const collection = document.getElementById('chatCollection').value;
9007
+ const mode = document.getElementById('chatMode')?.value || 'pipeline';
7799
9008
  const providerLabel = provider.value
7800
9009
  ? provider.options[provider.selectedIndex].text
7801
9010
  : 'No provider';
7802
- document.getElementById('chatStatusProvider').textContent = providerLabel;
9011
+ const modeLabel = mode === 'agent' ? 'Agent' : 'Pipeline';
9012
+ document.getElementById('chatStatusProvider').textContent = `${providerLabel} (${modeLabel})`;
7803
9013
  document.getElementById('chatStatusDb').textContent =
7804
- (db && collection) ? `${db}.${collection}` : 'No database';
9014
+ (db && collection) ? `${db}.${collection}` : (mode === 'agent' ? 'Agent discovers' : 'No database');
7805
9015
  }
7806
9016
 
7807
9017
  async function chatProviderChanged() {
7808
9018
  updateChatStatus();
7809
9019
  const provider = document.getElementById('chatProvider').value;
7810
9020
  const modelSelect = document.getElementById('chatModel');
7811
-
9021
+ const apiKeyRow = document.getElementById('chatApiKeyRow');
9022
+
9023
+ // Show/hide API key row based on provider (not needed for Ollama)
9024
+ if (apiKeyRow) {
9025
+ apiKeyRow.style.display = (provider === 'anthropic' || provider === 'openai') ? 'flex' : 'none';
9026
+ if (provider === 'anthropic' || provider === 'openai') {
9027
+ await loadChatApiKey();
9028
+ }
9029
+ }
9030
+
7812
9031
  if (!provider) {
7813
9032
  modelSelect.innerHTML = '<option value="">Select provider first</option>';
7814
9033
  return;
@@ -7886,6 +9105,262 @@ function chatInputKeydown(e) {
7886
9105
  }
7887
9106
  }
7888
9107
 
9108
+ /**
9109
+ * Lightweight markdown-to-HTML renderer for assistant chat messages.
9110
+ * Handles: fenced code blocks, inline code, headings, bold, italic,
9111
+ * links, images, unordered/ordered lists, blockquotes, tables, and <hr>.
9112
+ * No external dependencies.
9113
+ */
9114
+ function renderMarkdown(md) {
9115
+ // Escape HTML to prevent XSS, then selectively render markdown
9116
+ function esc(s) {
9117
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
9118
+ }
9119
+
9120
+ // Extract fenced code blocks first (preserve content as-is)
9121
+ const codeBlocks = [];
9122
+ let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
9123
+ const idx = codeBlocks.length;
9124
+ codeBlocks.push(`<pre><code${lang ? ' data-lang="' + esc(lang) + '"' : ''}>${esc(code.replace(/\n$/, ''))}</code></pre>`);
9125
+ return '\x00CB' + idx + '\x00';
9126
+ });
9127
+
9128
+ // Escape remaining HTML
9129
+ text = esc(text);
9130
+
9131
+ // Restore code block placeholders
9132
+ text = text.replace(/\x00CB(\d+)\x00/g, (_, i) => codeBlocks[i]);
9133
+
9134
+ // Inline code (must come after escape but before other inline formatting)
9135
+ text = text.replace(/`([^`\n]+)`/g, '<code>$1</code>');
9136
+
9137
+ // Headings (must be at line start)
9138
+ text = text.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
9139
+ text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
9140
+ text = text.replace(/^## (.+)$/gm, '<h2>$1</h2>');
9141
+ text = text.replace(/^# (.+)$/gm, '<h1>$1</h1>');
9142
+
9143
+ // Horizontal rule
9144
+ text = text.replace(/^---+$/gm, '<hr>');
9145
+
9146
+ // Bold and italic
9147
+ text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
9148
+ text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
9149
+ text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
9150
+
9151
+ // Links and images
9152
+ text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" style="max-width:100%;border-radius:4px;">');
9153
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
9154
+
9155
+ // Tables (pipe-delimited)
9156
+ text = text.replace(/((?:^\|.+\|$\n?)+)/gm, (block) => {
9157
+ const rows = block.trim().split('\n').filter(r => r.trim());
9158
+ if (rows.length < 2) return block;
9159
+ // Check if second row is separator
9160
+ const sep = rows[1];
9161
+ if (!/^\|[\s:-]+\|$/.test(sep.trim().replace(/\|/g, '|'))) return block;
9162
+ const headerCells = rows[0].split('|').filter(c => c.trim() !== '').map(c => c.trim());
9163
+ let html = '<table><thead><tr>' + headerCells.map(c => '<th>' + c + '</th>').join('') + '</tr></thead><tbody>';
9164
+ for (let i = 2; i < rows.length; i++) {
9165
+ const cells = rows[i].split('|').filter(c => c.trim() !== '').map(c => c.trim());
9166
+ html += '<tr>' + cells.map(c => '<td>' + c + '</td>').join('') + '</tr>';
9167
+ }
9168
+ html += '</tbody></table>';
9169
+ return html;
9170
+ });
9171
+
9172
+ // Blockquotes
9173
+ text = text.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
9174
+ // Merge adjacent blockquotes
9175
+ text = text.replace(/<\/blockquote>\n<blockquote>/g, '\n');
9176
+
9177
+ // Unordered lists (lines starting with - or *)
9178
+ text = text.replace(/((?:^[\s]*[-*] .+$\n?)+)/gm, (block) => {
9179
+ const items = block.trim().split('\n').map(line => {
9180
+ const content = line.replace(/^[\s]*[-*] /, '');
9181
+ return '<li>' + content + '</li>';
9182
+ });
9183
+ return '<ul>' + items.join('') + '</ul>';
9184
+ });
9185
+
9186
+ // Ordered lists (lines starting with number.)
9187
+ text = text.replace(/((?:^[\s]*\d+\. .+$\n?)+)/gm, (block) => {
9188
+ const items = block.trim().split('\n').map(line => {
9189
+ const content = line.replace(/^[\s]*\d+\. /, '');
9190
+ return '<li>' + content + '</li>';
9191
+ });
9192
+ return '<ol>' + items.join('') + '</ol>';
9193
+ });
9194
+
9195
+ // Paragraphs: wrap remaining text lines that aren't already wrapped in block elements
9196
+ const blockTags = ['<h1', '<h2', '<h3', '<h4', '<hr', '<pre', '<ul', '<ol', '<table', '<blockquote'];
9197
+ const lines = text.split('\n');
9198
+ const result = [];
9199
+ let paraLines = [];
9200
+
9201
+ function flushPara() {
9202
+ if (paraLines.length > 0) {
9203
+ const content = paraLines.join('\n').trim();
9204
+ if (content) result.push('<p>' + content + '</p>');
9205
+ paraLines = [];
9206
+ }
9207
+ }
9208
+
9209
+ for (const line of lines) {
9210
+ const trimmed = line.trim();
9211
+ if (trimmed === '') {
9212
+ flushPara();
9213
+ } else if (blockTags.some(tag => trimmed.startsWith(tag))) {
9214
+ flushPara();
9215
+ result.push(line);
9216
+ } else {
9217
+ paraLines.push(line);
9218
+ }
9219
+ }
9220
+ flushPara();
9221
+
9222
+ return result.join('\n');
9223
+ }
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
+
7889
9364
  function addChatMessage(role, content, sources) {
7890
9365
  const container = document.getElementById('chatMessages');
7891
9366
  const div = document.createElement('div');
@@ -7895,6 +9370,26 @@ function addChatMessage(role, content, sources) {
7895
9370
  contentSpan.textContent = content;
7896
9371
  div.appendChild(contentSpan);
7897
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
+
7898
9393
  if (sources && sources.length > 0) {
7899
9394
  const details = document.createElement('details');
7900
9395
  details.className = 'chat-sources';
@@ -7929,12 +9424,14 @@ async function sendChatMessage() {
7929
9424
  const maxDocs = parseInt(document.getElementById('chatMaxDocs').value) || 5;
7930
9425
  const rerank = document.getElementById('chatRerank').classList.contains('active');
7931
9426
  const systemPrompt = document.getElementById('chatSystemPrompt').value.trim() || undefined;
9427
+ const mode = document.getElementById('chatMode')?.value || 'pipeline';
9428
+ const isAgent = mode === 'agent';
7932
9429
 
7933
9430
  if (!provider) {
7934
9431
  addChatMessage('system-msg', 'Please select an LLM provider in <a href="#" onclick="openChatSettings();return false;">Chat Settings</a>.');
7935
9432
  return;
7936
9433
  }
7937
- if (!db || !collection) {
9434
+ if (!isAgent && (!db || !collection)) {
7938
9435
  addChatMessage('system-msg', 'Please configure a database and collection in <a href="#" onclick="openChatSettings();return false;">Chat Settings</a>.');
7939
9436
  return;
7940
9437
  }
@@ -7947,7 +9444,7 @@ async function sendChatMessage() {
7947
9444
  // Show typing indicator
7948
9445
  const typing = document.createElement('div');
7949
9446
  typing.className = 'chat-typing';
7950
- typing.textContent = 'Thinking';
9447
+ typing.textContent = isAgent ? 'Agent working' : 'Thinking';
7951
9448
  document.getElementById('chatMessages').appendChild(typing);
7952
9449
 
7953
9450
  // Disable input
@@ -7959,7 +9456,7 @@ async function sendChatMessage() {
7959
9456
  const res = await fetch('/api/chat/message', {
7960
9457
  method: 'POST',
7961
9458
  headers: { 'Content-Type': 'application/json' },
7962
- body: JSON.stringify({ query, db, collection, provider, model, maxDocs, rerank, systemPrompt }),
9459
+ body: JSON.stringify({ query, db, collection, provider, model, maxDocs, rerank, systemPrompt, mode }),
7963
9460
  });
7964
9461
 
7965
9462
  if (!res.ok) {
@@ -7976,6 +9473,7 @@ async function sendChatMessage() {
7976
9473
  let assistantDiv = null;
7977
9474
  let fullText = '';
7978
9475
  let sources = [];
9476
+ let thinkingPanel = null;
7979
9477
 
7980
9478
  while (true) {
7981
9479
  const { done, value } = await reader.read();
@@ -7998,6 +9496,17 @@ async function sendChatMessage() {
7998
9496
  typing.textContent = `Retrieved ${data.docs?.length || 0} docs (${data.timeMs}ms)`;
7999
9497
  }
8000
9498
 
9499
+ if (currentEvent === 'tool_call') {
9500
+ // Create thinking panel on first tool call
9501
+ if (!thinkingPanel) {
9502
+ typing.remove();
9503
+ thinkingPanel = createThinkingPanel();
9504
+ document.getElementById('chatMessages').appendChild(thinkingPanel.panel);
9505
+ }
9506
+ thinkingPanel.addStep(data);
9507
+ document.getElementById('chatMessages').scrollTop = document.getElementById('chatMessages').scrollHeight;
9508
+ }
9509
+
8001
9510
  if (currentEvent === 'chunk') {
8002
9511
  if (!assistantDiv) {
8003
9512
  typing.remove();
@@ -8009,6 +9518,14 @@ async function sendChatMessage() {
8009
9518
  }
8010
9519
 
8011
9520
  if (currentEvent === 'done') {
9521
+ // Finalize the thinking panel (collapse, show elapsed)
9522
+ if (thinkingPanel) thinkingPanel.finalize();
9523
+ // Render accumulated text as markdown for assistant messages
9524
+ if (assistantDiv && fullText) {
9525
+ const contentEl = assistantDiv.querySelector('.chat-message-content');
9526
+ contentEl.innerHTML = renderMarkdown(fullText);
9527
+ contentEl.classList.add('rendered');
9528
+ }
8012
9529
  sources = data.sources || [];
8013
9530
  if (sources.length > 0 && assistantDiv) {
8014
9531
  const details = document.createElement('details');
@@ -8042,6 +9559,7 @@ async function sendChatMessage() {
8042
9559
 
8043
9560
  } catch (err) {
8044
9561
  if (typing.parentNode) typing.remove();
9562
+ if (thinkingPanel) thinkingPanel.finalize();
8045
9563
  addChatMessage('system-msg', `Error: ${err.message}`);
8046
9564
  } finally {
8047
9565
  sendBtn.disabled = false;
@@ -8074,6 +9592,9 @@ window.switchSettingsSection = switchSettingsSection;
8074
9592
  window.updateChatStatus = updateChatStatus;
8075
9593
  window.chatInputKeydown = chatInputKeydown;
8076
9594
  window.chatProviderChanged = chatProviderChanged;
9595
+ window.loadChatApiKey = loadChatApiKey;
9596
+ window.toggleChatApiKeyVisibility = toggleChatApiKeyVisibility;
9597
+ window.saveChatApiKey = saveChatApiKey;
8077
9598
 
8078
9599
  // ── Start ──
8079
9600
  init();
@@ -8253,6 +9774,1227 @@ init();
8253
9774
  </div>
8254
9775
  </div>
8255
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
+
8256
10998
  <script>
8257
10999
  (function() {
8258
11000
  const BUG_API = 'https://vaicli.com/api/bugs';