voyageai-cli 1.30.1 → 1.30.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/src/cli.js +2 -0
  4. package/src/commands/about.js +3 -3
  5. package/src/commands/code-search.js +751 -0
  6. package/src/commands/doctor.js +1 -1
  7. package/src/commands/embed.js +121 -2
  8. package/src/commands/index-workspace.js +9 -5
  9. package/src/commands/playground.js +65 -4
  10. package/src/commands/quickstart.js +4 -4
  11. package/src/commands/workflow.js +132 -65
  12. package/src/lib/api.js +31 -0
  13. package/src/lib/catalog.js +4 -2
  14. package/src/lib/code-search.js +315 -0
  15. package/src/lib/codegen.js +1 -1
  16. package/src/lib/explanations.js +3 -3
  17. package/src/lib/github.js +226 -0
  18. package/src/lib/input.js +92 -1
  19. package/src/lib/template-engine.js +154 -20
  20. package/src/lib/workflow-builder.js +753 -0
  21. package/src/lib/workflow-formatters.js +454 -0
  22. package/src/lib/workflow-input-cache.js +111 -0
  23. package/src/lib/workflow-scaffold.js +1 -1
  24. package/src/lib/workflow.js +124 -8
  25. package/src/mcp/schemas/index.js +142 -0
  26. package/src/mcp/server.js +17 -4
  27. package/src/mcp/tools/authoring.js +662 -0
  28. package/src/mcp/tools/code-search.js +620 -0
  29. package/src/mcp/tools/embedding.js +72 -3
  30. package/src/mcp/tools/ingest.js +2 -5
  31. package/src/mcp/tools/retrieval.js +2 -15
  32. package/src/mcp/tools/workspace.js +1 -12
  33. package/src/mcp/utils.js +20 -0
  34. package/src/playground/help/workflow-nodes.js +127 -2
  35. package/src/playground/index.html +2013 -139
  36. package/src/workflows/code-review.json +110 -0
  37. package/src/workflows/cost-analysis.json +5 -0
  38. package/src/workflows/tests/code-review.fresh-index.test.json +83 -0
  39. package/src/workflows/tests/code-review.happy-path.test.json +121 -0
  40. package/src/workflows/tests/code-review.no-question.test.json +70 -0
  41. package/src/workflows/tests/smart-ingest.duplicate-detected.test.json +2 -2
@@ -1089,6 +1089,494 @@ select:focus { outline: none; border-color: var(--accent); }
1089
1089
  display: none;
1090
1090
  }
1091
1091
 
1092
+ /* ── Models Tab ── */
1093
+ .models-toolbar {
1094
+ display: flex;
1095
+ align-items: center;
1096
+ gap: 12px;
1097
+ margin-bottom: 20px;
1098
+ flex-wrap: wrap;
1099
+ }
1100
+ .models-filter-pills {
1101
+ display: flex;
1102
+ gap: 6px;
1103
+ }
1104
+ .models-filter-pill {
1105
+ background: var(--bg-card);
1106
+ border: 1px solid var(--border);
1107
+ color: var(--text-dim);
1108
+ padding: 4px 12px;
1109
+ border-radius: 20px;
1110
+ font-size: 12px;
1111
+ font-family: var(--font);
1112
+ cursor: pointer;
1113
+ transition: all 0.15s;
1114
+ }
1115
+ .models-filter-pill:hover {
1116
+ border-color: var(--accent);
1117
+ color: var(--text);
1118
+ }
1119
+ .models-filter-pill.active {
1120
+ background: rgba(0, 212, 170, 0.12);
1121
+ border-color: var(--accent);
1122
+ color: var(--accent);
1123
+ }
1124
+ .models-hero {
1125
+ position: relative;
1126
+ overflow: hidden;
1127
+ background: linear-gradient(135deg, rgba(0, 212, 170, 0.06), rgba(64, 224, 255, 0.06));
1128
+ border: 1px solid rgba(0, 212, 170, 0.2);
1129
+ margin-bottom: 24px;
1130
+ }
1131
+ .models-hero-shape {
1132
+ position: absolute;
1133
+ top: -30px;
1134
+ right: -30px;
1135
+ width: 200px;
1136
+ height: 200px;
1137
+ pointer-events: none;
1138
+ z-index: 0;
1139
+ }
1140
+ .models-hero-content {
1141
+ position: relative;
1142
+ z-index: 1;
1143
+ }
1144
+ .models-hero-badge {
1145
+ display: inline-block;
1146
+ background: linear-gradient(135deg, #00D4AA, #40E0FF);
1147
+ color: #001E2B;
1148
+ font-size: 11px;
1149
+ font-weight: 700;
1150
+ padding: 3px 10px;
1151
+ border-radius: 12px;
1152
+ text-transform: uppercase;
1153
+ letter-spacing: 0.5px;
1154
+ margin-bottom: 10px;
1155
+ }
1156
+ .models-hero-title {
1157
+ font-size: 18px;
1158
+ font-weight: 700;
1159
+ color: var(--text);
1160
+ margin-bottom: 8px;
1161
+ }
1162
+ .models-hero-desc {
1163
+ font-size: 13px;
1164
+ color: var(--text-dim);
1165
+ line-height: 1.6;
1166
+ max-width: 700px;
1167
+ margin-bottom: 14px;
1168
+ }
1169
+ .models-hero-models {
1170
+ display: flex;
1171
+ gap: 8px;
1172
+ flex-wrap: wrap;
1173
+ }
1174
+ .model-chip {
1175
+ display: inline-flex;
1176
+ align-items: center;
1177
+ gap: 6px;
1178
+ padding: 6px 12px;
1179
+ background: var(--bg-card);
1180
+ border: 1px solid var(--border);
1181
+ border-radius: 8px;
1182
+ font-size: 12px;
1183
+ font-family: var(--mono);
1184
+ color: var(--text);
1185
+ cursor: pointer;
1186
+ transition: all 0.15s;
1187
+ }
1188
+ .model-chip:hover {
1189
+ border-color: var(--accent);
1190
+ background: rgba(0, 212, 170, 0.06);
1191
+ }
1192
+ .model-chip-price {
1193
+ color: var(--text-muted);
1194
+ font-size: 11px;
1195
+ }
1196
+ .models-group {
1197
+ margin-bottom: 24px;
1198
+ }
1199
+ .models-group-header {
1200
+ display: flex;
1201
+ align-items: center;
1202
+ gap: 8px;
1203
+ margin-bottom: 12px;
1204
+ }
1205
+ .models-group-icon {
1206
+ color: var(--accent);
1207
+ opacity: 0.8;
1208
+ }
1209
+ .models-group-title {
1210
+ font-size: 15px;
1211
+ font-weight: 600;
1212
+ color: var(--text);
1213
+ }
1214
+ .models-group-count {
1215
+ font-size: 11px;
1216
+ color: var(--text-muted);
1217
+ background: var(--bg-card);
1218
+ border: 1px solid var(--border);
1219
+ border-radius: 10px;
1220
+ padding: 1px 8px;
1221
+ }
1222
+ .models-group-grid {
1223
+ display: grid;
1224
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
1225
+ gap: 14px;
1226
+ }
1227
+ .model-card {
1228
+ background: var(--bg-card);
1229
+ border: 1px solid var(--border);
1230
+ border-radius: var(--radius);
1231
+ padding: 18px;
1232
+ cursor: default;
1233
+ transition: all 0.2s;
1234
+ position: relative;
1235
+ overflow: hidden;
1236
+ }
1237
+ .model-card:hover {
1238
+ border-color: var(--accent);
1239
+ transform: translateY(-2px);
1240
+ box-shadow: 0 4px 20px rgba(0, 212, 170, 0.1);
1241
+ }
1242
+ .model-card.legacy {
1243
+ opacity: 0.5;
1244
+ }
1245
+ .model-card.legacy:hover {
1246
+ opacity: 0.7;
1247
+ }
1248
+ .model-card-nebula {
1249
+ position: absolute;
1250
+ top: -40px;
1251
+ right: -40px;
1252
+ width: 140px;
1253
+ height: 140px;
1254
+ pointer-events: none;
1255
+ z-index: 0;
1256
+ }
1257
+ .model-card-header {
1258
+ display: flex;
1259
+ align-items: center;
1260
+ justify-content: space-between;
1261
+ margin-bottom: 6px;
1262
+ position: relative;
1263
+ z-index: 1;
1264
+ }
1265
+ .model-card-name {
1266
+ font-size: 15px;
1267
+ font-weight: 600;
1268
+ color: var(--text);
1269
+ font-family: var(--mono);
1270
+ }
1271
+ .model-card-type-badge {
1272
+ font-size: 10px;
1273
+ padding: 2px 8px;
1274
+ border-radius: 10px;
1275
+ text-transform: uppercase;
1276
+ letter-spacing: 0.3px;
1277
+ font-weight: 600;
1278
+ }
1279
+ .model-card-type-badge.embedding {
1280
+ background: rgba(0, 212, 170, 0.15);
1281
+ color: #00D4AA;
1282
+ }
1283
+ .model-card-type-badge.reranking {
1284
+ background: rgba(64, 224, 255, 0.15);
1285
+ color: #40E0FF;
1286
+ }
1287
+ .model-card-type-badge.multimodal {
1288
+ background: rgba(255, 170, 64, 0.15);
1289
+ color: #FFAA40;
1290
+ }
1291
+ .model-card-shared-badge {
1292
+ display: inline-flex;
1293
+ align-items: center;
1294
+ gap: 4px;
1295
+ font-size: 10px;
1296
+ color: var(--blue, #40E0FF);
1297
+ margin-bottom: 6px;
1298
+ position: relative;
1299
+ z-index: 1;
1300
+ }
1301
+ .model-card-rteb {
1302
+ position: absolute;
1303
+ top: 14px;
1304
+ right: 14px;
1305
+ font-size: 11px;
1306
+ font-weight: 700;
1307
+ color: var(--accent);
1308
+ font-family: var(--mono);
1309
+ z-index: 1;
1310
+ }
1311
+ .model-card-desc {
1312
+ font-size: 13px;
1313
+ color: var(--text-dim);
1314
+ margin-bottom: 12px;
1315
+ position: relative;
1316
+ z-index: 1;
1317
+ }
1318
+ .model-card-specs {
1319
+ display: grid;
1320
+ grid-template-columns: 1fr 1fr;
1321
+ gap: 6px 16px;
1322
+ font-size: 12px;
1323
+ position: relative;
1324
+ z-index: 1;
1325
+ }
1326
+ .model-card-spec {
1327
+ display: flex;
1328
+ justify-content: space-between;
1329
+ }
1330
+ .model-card-spec-label {
1331
+ color: var(--text-muted);
1332
+ }
1333
+ .model-card-spec-value {
1334
+ color: var(--text);
1335
+ font-family: var(--mono);
1336
+ font-weight: 500;
1337
+ }
1338
+ .model-card-actions {
1339
+ display: flex;
1340
+ gap: 6px;
1341
+ margin-top: 12px;
1342
+ position: relative;
1343
+ z-index: 1;
1344
+ }
1345
+ .model-card-action {
1346
+ font-size: 11px;
1347
+ padding: 4px 10px;
1348
+ border-radius: 6px;
1349
+ border: 1px solid var(--border);
1350
+ background: transparent;
1351
+ color: var(--accent);
1352
+ cursor: pointer;
1353
+ font-family: var(--font);
1354
+ transition: all 0.15s;
1355
+ }
1356
+ .model-card-action:hover {
1357
+ background: rgba(0, 212, 170, 0.1);
1358
+ border-color: var(--accent);
1359
+ }
1360
+ .models-rteb-bar {
1361
+ display: flex;
1362
+ align-items: center;
1363
+ gap: 12px;
1364
+ margin-bottom: 8px;
1365
+ }
1366
+ .models-rteb-label {
1367
+ min-width: 160px;
1368
+ font-size: 13px;
1369
+ white-space: nowrap;
1370
+ }
1371
+ .models-rteb-track {
1372
+ flex: 1;
1373
+ height: 24px;
1374
+ background: var(--bg-surface);
1375
+ border-radius: 4px;
1376
+ overflow: hidden;
1377
+ }
1378
+ .models-rteb-fill {
1379
+ height: 100%;
1380
+ border-radius: 4px;
1381
+ transition: width 0.6s ease-out;
1382
+ }
1383
+ .models-rteb-score {
1384
+ min-width: 50px;
1385
+ text-align: right;
1386
+ font-family: var(--mono);
1387
+ font-weight: 600;
1388
+ font-size: 13px;
1389
+ }
1390
+
1391
+ /* ── Model Picker Overlay ── */
1392
+ .model-picker-overlay {
1393
+ position: fixed;
1394
+ inset: 0;
1395
+ background: rgba(0, 0, 0, 0.6);
1396
+ backdrop-filter: blur(4px);
1397
+ z-index: 1001;
1398
+ display: flex;
1399
+ align-items: center;
1400
+ justify-content: center;
1401
+ opacity: 0;
1402
+ pointer-events: none;
1403
+ transition: opacity 0.2s ease;
1404
+ }
1405
+ .model-picker-overlay.open {
1406
+ opacity: 1;
1407
+ pointer-events: auto;
1408
+ }
1409
+ .model-picker {
1410
+ background: var(--bg-card);
1411
+ border: 1px solid var(--border);
1412
+ border-radius: 12px;
1413
+ width: 480px;
1414
+ max-width: 90vw;
1415
+ max-height: 70vh;
1416
+ display: flex;
1417
+ flex-direction: column;
1418
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
1419
+ }
1420
+ .model-picker-header {
1421
+ display: flex;
1422
+ align-items: center;
1423
+ justify-content: space-between;
1424
+ padding: 16px 20px 12px;
1425
+ border-bottom: 1px solid var(--border);
1426
+ }
1427
+ .model-picker-title {
1428
+ font-size: 16px;
1429
+ font-weight: 600;
1430
+ color: var(--text);
1431
+ }
1432
+ .model-picker-context {
1433
+ font-size: 12px;
1434
+ color: var(--text-muted);
1435
+ margin-top: 2px;
1436
+ }
1437
+ .model-picker-close {
1438
+ background: none;
1439
+ border: none;
1440
+ color: var(--text-dim);
1441
+ font-size: 22px;
1442
+ cursor: pointer;
1443
+ padding: 0 4px;
1444
+ line-height: 1;
1445
+ }
1446
+ .model-picker-close:hover {
1447
+ color: var(--text);
1448
+ }
1449
+ .model-picker-search {
1450
+ padding: 12px 20px;
1451
+ }
1452
+ .model-picker-search input {
1453
+ width: 100%;
1454
+ background: var(--bg-input, var(--bg-surface));
1455
+ border: 1px solid var(--border);
1456
+ color: var(--text);
1457
+ padding: 8px 12px;
1458
+ border-radius: var(--radius);
1459
+ font-size: 13px;
1460
+ font-family: var(--font);
1461
+ }
1462
+ .model-picker-recommendation {
1463
+ padding: 0 20px 8px;
1464
+ font-size: 12px;
1465
+ color: var(--blue, #40E0FF);
1466
+ display: none;
1467
+ }
1468
+ .model-picker-recommendation.visible {
1469
+ display: block;
1470
+ }
1471
+ .model-picker-list {
1472
+ flex: 1;
1473
+ overflow-y: auto;
1474
+ padding: 0 12px 12px;
1475
+ }
1476
+ .model-picker-item {
1477
+ display: flex;
1478
+ align-items: flex-start;
1479
+ gap: 12px;
1480
+ padding: 10px 12px;
1481
+ border-radius: 8px;
1482
+ cursor: pointer;
1483
+ transition: background 0.1s;
1484
+ border: 1px solid transparent;
1485
+ }
1486
+ .model-picker-item:hover,
1487
+ .model-picker-item:focus {
1488
+ background: rgba(0, 212, 170, 0.06);
1489
+ border-color: var(--border);
1490
+ outline: none;
1491
+ }
1492
+ .model-picker-item.selected {
1493
+ background: rgba(0, 212, 170, 0.1);
1494
+ border-color: var(--accent);
1495
+ }
1496
+ .model-picker-item-radio {
1497
+ width: 16px;
1498
+ height: 16px;
1499
+ border: 2px solid var(--border);
1500
+ border-radius: 50%;
1501
+ flex-shrink: 0;
1502
+ margin-top: 2px;
1503
+ transition: all 0.15s;
1504
+ }
1505
+ .model-picker-item.selected .model-picker-item-radio {
1506
+ border-color: var(--accent);
1507
+ background: var(--accent);
1508
+ box-shadow: inset 0 0 0 3px var(--bg-card);
1509
+ }
1510
+ .model-picker-item-info {
1511
+ flex: 1;
1512
+ min-width: 0;
1513
+ }
1514
+ .model-picker-item-name {
1515
+ font-size: 13px;
1516
+ font-weight: 600;
1517
+ color: var(--text);
1518
+ font-family: var(--mono);
1519
+ }
1520
+ .model-picker-item-meta {
1521
+ font-size: 11px;
1522
+ color: var(--text-dim);
1523
+ margin-top: 2px;
1524
+ }
1525
+ .model-picker-item-tags {
1526
+ display: flex;
1527
+ gap: 4px;
1528
+ margin-top: 4px;
1529
+ flex-wrap: wrap;
1530
+ }
1531
+ .model-picker-item-tag {
1532
+ font-size: 10px;
1533
+ padding: 1px 6px;
1534
+ border-radius: 8px;
1535
+ background: rgba(0, 212, 170, 0.08);
1536
+ color: var(--accent);
1537
+ }
1538
+ .model-picker-item-shared {
1539
+ font-size: 10px;
1540
+ padding: 1px 6px;
1541
+ border-radius: 8px;
1542
+ background: rgba(64, 224, 255, 0.1);
1543
+ color: var(--blue, #40E0FF);
1544
+ }
1545
+ .model-picker-footer {
1546
+ padding: 10px 20px;
1547
+ border-top: 1px solid var(--border);
1548
+ text-align: center;
1549
+ font-size: 12px;
1550
+ }
1551
+ .model-picker-footer a {
1552
+ color: var(--accent);
1553
+ text-decoration: none;
1554
+ }
1555
+ .model-picker-footer a:hover {
1556
+ text-decoration: underline;
1557
+ }
1558
+ .model-picker-trigger {
1559
+ background: transparent;
1560
+ border: 1px solid var(--border);
1561
+ color: var(--text-muted);
1562
+ cursor: pointer;
1563
+ padding: 4px 6px;
1564
+ border-radius: 4px;
1565
+ transition: color 0.15s, background 0.15s, border-color 0.15s;
1566
+ display: inline-flex;
1567
+ align-items: center;
1568
+ vertical-align: middle;
1569
+ margin-left: 4px;
1570
+ }
1571
+ .model-picker-trigger:hover {
1572
+ color: var(--accent);
1573
+ background: rgba(0, 212, 170, 0.08);
1574
+ border-color: var(--accent);
1575
+ }
1576
+ [data-theme="light"] .model-card:hover {
1577
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
1578
+ }
1579
+
1092
1580
  /* Explore modal */
1093
1581
  .explore-modal-overlay {
1094
1582
  position: fixed;
@@ -4029,24 +4517,39 @@ select:focus { outline: none; border-color: var(--accent); }
4029
4517
  cursor: help;
4030
4518
  }
4031
4519
  .wf-validation-bar {
4032
- position: fixed;
4033
- top: 44px;
4034
- left: 260px;
4035
- right: 0;
4520
+ position: absolute;
4521
+ top: 52px;
4522
+ left: 12px;
4523
+ right: 12px;
4036
4524
  height: 28px;
4037
4525
  background: var(--bg-surface);
4038
- border-bottom: 1px solid var(--border);
4526
+ border: 1px solid var(--border);
4527
+ border-radius: 6px;
4039
4528
  display: none;
4040
4529
  align-items: center;
4041
- padding: 0 16px;
4530
+ padding: 0 12px;
4042
4531
  font-size: 12px;
4043
- z-index: 50;
4532
+ z-index: 9;
4044
4533
  cursor: pointer;
4045
4534
  transition: background-color 0.15s;
4046
4535
  }
4047
4536
  .wf-validation-bar:hover {
4048
4537
  background: var(--bg-card);
4049
4538
  }
4539
+ .wf-validation-bar .wf-validation-close {
4540
+ margin-left: auto;
4541
+ background: none;
4542
+ border: none;
4543
+ color: inherit;
4544
+ cursor: pointer;
4545
+ font-size: 14px;
4546
+ padding: 0 4px;
4547
+ opacity: 0.6;
4548
+ line-height: 1;
4549
+ }
4550
+ .wf-validation-bar .wf-validation-close:hover {
4551
+ opacity: 1;
4552
+ }
4050
4553
  .wf-validation-bar.warning {
4051
4554
  color: #FFB74D;
4052
4555
  border-bottom-color: #FFB74D;
@@ -4200,6 +4703,54 @@ select:focus { outline: none; border-color: var(--accent); }
4200
4703
  border-radius: 6px; padding: 8px; overflow-x: auto;
4201
4704
  white-space: pre-wrap; color: var(--text); margin-top: 4px;
4202
4705
  }
4706
+ /* Output format selector and rich rendering */
4707
+ .wf-output-format-select {
4708
+ font-size: 11px; padding: 2px 6px; border-radius: 4px;
4709
+ background: var(--bg-card); border: 1px solid var(--border);
4710
+ color: var(--text); cursor: pointer; margin-left: 8px;
4711
+ }
4712
+ .wf-output-format-select:hover { border-color: var(--accent); }
4713
+ .wf-output-table {
4714
+ width: 100%; border-collapse: collapse; font-size: 11px;
4715
+ color: var(--text); margin-top: 8px;
4716
+ }
4717
+ .wf-output-table th {
4718
+ text-align: left; padding: 6px 8px; font-weight: 600;
4719
+ border-bottom: 1px solid var(--accent); color: var(--accent);
4720
+ font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px;
4721
+ }
4722
+ .wf-output-table td {
4723
+ padding: 5px 8px; border-bottom: 1px solid var(--border);
4724
+ font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px;
4725
+ }
4726
+ .wf-output-table tr:hover td { background: rgba(0,212,170,0.05); }
4727
+ .wf-output-markdown {
4728
+ font-size: 12px; line-height: 1.5; color: var(--text); padding: 8px;
4729
+ }
4730
+ .wf-output-markdown h2, .wf-output-markdown h3 {
4731
+ color: var(--accent); margin-top: 12px; margin-bottom: 4px;
4732
+ }
4733
+ .wf-output-markdown table {
4734
+ border-collapse: collapse; width: 100%; margin: 8px 0;
4735
+ }
4736
+ .wf-output-markdown th, .wf-output-markdown td {
4737
+ border: 1px solid var(--border); padding: 4px 8px; font-size: 11px;
4738
+ }
4739
+ .wf-output-text-field { margin-bottom: 12px; }
4740
+ .wf-output-text-label {
4741
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
4742
+ color: var(--accent); letter-spacing: 0.5px; margin-bottom: 2px;
4743
+ }
4744
+ .wf-output-text-value {
4745
+ font-size: 12px; color: var(--text); line-height: 1.5; white-space: pre-wrap;
4746
+ }
4747
+ .wf-output-metric {
4748
+ display: inline-block; margin-right: 16px; margin-bottom: 6px;
4749
+ }
4750
+ .wf-output-metric-val {
4751
+ font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px;
4752
+ color: #40E0FF; font-weight: 600;
4753
+ }
4203
4754
  .wf-tool-badge {
4204
4755
  display: inline-flex; align-items: center; gap: 4px;
4205
4756
  padding: 2px 8px; border-radius: 10px; font-size: 11px;
@@ -5754,6 +6305,7 @@ select:focus { outline: none; border-color: var(--accent); }
5754
6305
  <div class="sidebar-nav-divider"></div>
5755
6306
  <div class="sidebar-nav-group" role="tablist" aria-label="Learn">
5756
6307
  <div class="sidebar-nav-label" id="nav-learn-label">Learn</div>
6308
+ <button class="tab-btn" data-tab="models" data-tooltip="Models" role="tab" aria-selected="false" aria-controls="tab-models" id="tab-btn-models"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-bot"/></svg></span><span>Models</span></button>
5757
6309
  <button class="tab-btn" data-tab="benchmark" data-tooltip="Benchmark" role="tab" aria-selected="false" aria-controls="tab-benchmark" id="tab-btn-benchmark"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-gauge"/></svg></span><span>Benchmark</span></button>
5758
6310
  <button class="tab-btn" data-tab="explore" data-tooltip="Explore" role="tab" aria-selected="false" aria-controls="tab-explore" id="tab-btn-explore"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-bulb"/></svg></span><span>Explore</span></button>
5759
6311
  <button class="tab-btn" data-tab="about" data-tooltip="About" role="tab" aria-selected="false" aria-controls="tab-about" id="tab-btn-about"><span class="tab-btn-icon" aria-hidden="true"><svg><use href="#lg-info"/></svg></span><span>About</span></button>
@@ -6150,12 +6702,12 @@ Semantic search understands meaning beyond keyword matching</textarea>
6150
6702
  <div class="tab-panel" id="tab-multimodal" role="tabpanel" aria-labelledby="tab-btn-multimodal" tabindex="0">
6151
6703
  <div class="page-header">
6152
6704
  <h2 class="page-header-title">Multimodal</h2>
6153
- <p class="page-header-subtitle">Compare images and text in the same vector space</p>
6154
- <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>
6705
+ <p class="page-header-subtitle">Compare images, video, and text in the same vector space</p>
6706
+ <p class="page-header-hint">Voyage AI's multimodal models embed images, video, and text into a unified vector space, so you can compare them directly with cosine similarity.</p>
6155
6707
  <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>
6156
6708
  </div>
6157
6709
 
6158
- <!-- Section A: Image ↔ Text Similarity -->
6710
+ <!-- Section A: Image / Video ↔ Text Similarity -->
6159
6711
  <div class="mm-grid">
6160
6712
  <div class="card">
6161
6713
  <div class="card-title">Image</div>
@@ -6171,9 +6723,23 @@ Semantic search understands meaning beyond keyword matching</textarea>
6171
6723
  <button class="mm-clear-btn" onclick="clearMultimodalImage()">✕ Clear</button>
6172
6724
  </div>
6173
6725
  </div>
6726
+ <div class="card">
6727
+ <div class="card-title">Video</div>
6728
+ <div class="mm-drop-zone" id="mmVideoDropZone">
6729
+ <div class="mm-drop-icon">🎬</div>
6730
+ <div class="mm-drop-text">Drop a video here or click to browse</div>
6731
+ <div class="mm-drop-hint">MP4, WebM, MOV, max 20 MB</div>
6732
+ </div>
6733
+ <input type="file" id="mmVideoFileInput" accept="video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-matroska" style="display:none">
6734
+ <div class="mm-preview" id="mmVideoPreview">
6735
+ <video id="mmPreviewVideo" controls style="max-width:100%;max-height:240px;border-radius:8px;background:#000;"></video>
6736
+ <div class="mm-file-info" id="mmVideoFileInfo"></div>
6737
+ <button class="mm-clear-btn" onclick="clearMultimodalVideo()">✕ Clear</button>
6738
+ </div>
6739
+ </div>
6174
6740
  <div class="card">
6175
6741
  <div class="card-title">Text</div>
6176
- <textarea id="mmText" rows="8" placeholder="Describe what you see, or enter any text to compare against the image..."></textarea>
6742
+ <textarea id="mmText" rows="8" placeholder="Describe what you see, or enter any text to compare against the media..."></textarea>
6177
6743
  </div>
6178
6744
  </div>
6179
6745
 
@@ -6474,9 +7040,9 @@ Reranking models rescore initial search results to improve relevance ordering.</
6474
7040
  <div style="margin-top:16px;padding:12px;background:var(--accent-glow);border-radius:8px;border-left:3px solid var(--accent);">
6475
7041
  <strong style="color:var(--accent);">💡 Pro Tip: Asymmetric Retrieval</strong>
6476
7042
  <p style="margin:8px 0 0 0;font-size:13px;color:var(--text);">
6477
- Embed your document corpus with <code style="background:var(--surface);padding:2px 6px;border-radius:4px;">voyage-3-lite</code> ($0.02/M) and
6478
- query with <code style="background:var(--surface);padding:2px 6px;border-radius:4px;">voyage-3</code> ($0.06/M).
6479
- Because all Voyage-3 models share the same embedding space, you get top-tier retrieval quality at a fraction of the cost.
7043
+ Embed your document corpus with <code style="background:var(--surface);padding:2px 6px;border-radius:4px;">voyage-4-lite</code> ($0.02/M) and
7044
+ query with <code style="background:var(--surface);padding:2px 6px;border-radius:4px;">voyage-4</code> ($0.06/M).
7045
+ Because all Voyage 4 models share the same embedding space, you get top-tier retrieval quality at a fraction of the cost.
6480
7046
  </p>
6481
7047
  </div>
6482
7048
  </div>
@@ -6489,8 +7055,8 @@ Reranking models rescore initial search results to improve relevance ordering.</
6489
7055
  <div style="font-size:20px;margin-bottom:8px;">🔗</div>
6490
7056
  <div style="font-weight:600;margin-bottom:4px;">Shared Embedding Space</div>
6491
7057
  <div style="font-size:13px;color:var(--text-dim);">
6492
- All Voyage-3 models produce compatible embeddings. Mix <code>voyage-3-large</code> for queries
6493
- with <code>voyage-3-lite</code> for documents they work together seamlessly.
7058
+ All Voyage 4 models produce compatible embeddings. Mix <code>voyage-4-large</code> for queries
7059
+ with <code>voyage-4-lite</code> for documents, and they work together seamlessly.
6494
7060
  </div>
6495
7061
  </div>
6496
7062
  <div style="padding:16px;background:var(--surface);border-radius:8px;border:1px solid var(--border);">
@@ -6818,6 +7384,9 @@ Reranking models rescore initial search results to improve relevance ordering.</
6818
7384
  <button onclick="wfZoom(1)" title="Zoom in">+</button>
6819
7385
  <button onclick="wfZoom(-1)" title="Zoom out">&minus;</button>
6820
7386
  <button onclick="wfFitToView()" title="Fit to view">&#8862;</button>
7387
+ <button onclick="wfRelayout()" title="Auto-layout (reorder nodes to minimize crossings)">
7388
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><line x1="10" y1="6.5" x2="14" y2="6.5"/><line x1="10" y1="17.5" x2="14" y2="17.5"/></svg>
7389
+ </button>
6821
7390
  <button onclick="wfResetExecution()" title="Reset">&#8635;</button>
6822
7391
  <span class="wf-toolbar-sep"></span>
6823
7392
  <button class="wf-plan-btn" onclick="wfDryRun()" id="wfDryRunBtn" disabled title="Dry run: show execution plan">&#9881; Plan</button>
@@ -6837,6 +7406,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
6837
7406
  <!-- Draft validation status bar -->
6838
7407
  <div class="wf-validation-bar" id="wfValidationBar">
6839
7408
  <span id="wfValidationBarText"></span>
7409
+ <button class="wf-validation-close" onclick="event.stopPropagation(); document.getElementById('wfValidationBar').style.display='none';" title="Dismiss">&times;</button>
6840
7410
  </div>
6841
7411
  <!-- Edge handles for collapsed panels -->
6842
7412
  <div class="wf-edge-handle wf-edge-handle--left" id="wfEdgeHandleLeft" onclick="wfToggleLibrary()" title="Expand library">&#x203A;</div>
@@ -7112,7 +7682,7 @@ Reranking models rescore initial search results to improve relevance ordering.</
7112
7682
  <option value="voyage-4-large">voyage-4-large</option>
7113
7683
  <option value="voyage-4">voyage-4</option>
7114
7684
  <option value="voyage-4-lite">voyage-4-lite</option>
7115
- <option value="voyage-3-large">voyage-3-large</option>
7685
+ <option value="voyage-code-3">voyage-code-3</option>
7116
7686
  </select>
7117
7687
  </label>
7118
7688
  <label>
@@ -7233,6 +7803,54 @@ Reranking models rescore initial search results to improve relevance ordering.</
7233
7803
  </div>
7234
7804
  </div>
7235
7805
 
7806
+ <!-- ========== MODELS TAB ========== -->
7807
+ <div class="tab-panel" id="tab-models" role="tabpanel" aria-labelledby="tab-btn-models" tabindex="0">
7808
+ <div class="page-header">
7809
+ <h2 class="page-header-title">Models</h2>
7810
+ <p class="page-header-subtitle">Voyage AI model showcase</p>
7811
+ <p class="page-header-hint">Explore all Voyage AI models, compare specs, and find the right model for your use case.</p>
7812
+ <a class="page-header-docs" href="https://docs.voyageai.com/docs/embeddings" target="_blank" rel="noopener" title="Voyage AI model docs"><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>
7813
+ </div>
7814
+
7815
+ <!-- Search + Filter -->
7816
+ <div class="models-toolbar">
7817
+ <input type="text" id="modelsSearch" placeholder="Search models..." oninput="filterModels()" style="max-width:280px;" aria-label="Search models">
7818
+ <div class="models-filter-pills" id="modelsFilterPills">
7819
+ <button class="models-filter-pill active" data-filter="all" onclick="setModelFilter('all')">All</button>
7820
+ <button class="models-filter-pill" data-filter="embedding" onclick="setModelFilter('embedding')">Embedding</button>
7821
+ <button class="models-filter-pill" data-filter="reranking" onclick="setModelFilter('reranking')">Reranking</button>
7822
+ <button class="models-filter-pill" data-filter="multimodal" onclick="setModelFilter('multimodal')">Multimodal</button>
7823
+ </div>
7824
+ </div>
7825
+
7826
+ <!-- Shared Embedding Space Hero -->
7827
+ <div class="models-hero card" id="modelsHero">
7828
+ <div class="models-hero-shape" id="modelsHeroShape"></div>
7829
+ <div class="models-hero-content">
7830
+ <div class="models-hero-badge">Voyage 4 Family</div>
7831
+ <div class="models-hero-title">Shared Embedding Space</div>
7832
+ <div class="models-hero-desc">
7833
+ voyage-4-large, voyage-4, and voyage-4-lite produce compatible embeddings in the same vector space.
7834
+ Index documents with the large model for best quality, then query with the lite model at a fraction of the cost.
7835
+ No re-indexing required.
7836
+ </div>
7837
+ <div class="models-hero-models" id="modelsHeroModels"></div>
7838
+ </div>
7839
+ </div>
7840
+
7841
+ <!-- Model Groups (dynamically rendered) -->
7842
+ <div id="modelsGroups"></div>
7843
+
7844
+ <!-- RTEB Benchmark Chart -->
7845
+ <div class="card" id="modelsRtebCard" style="margin-top:24px;">
7846
+ <div class="card-title">RTEB Retrieval Benchmark (NDCG@10)</div>
7847
+ <p style="color:var(--text-dim);font-size:13px;margin-bottom:16px;">
7848
+ Industry-standard benchmark for retrieval quality across 29 datasets. Higher is better.
7849
+ </p>
7850
+ <div id="modelsRtebChart"></div>
7851
+ </div>
7852
+ </div>
7853
+
7236
7854
  <!-- ========== EXPLORE TAB ========== -->
7237
7855
  <div class="tab-panel" id="tab-explore" role="tabpanel" aria-labelledby="tab-btn-explore" tabindex="0">
7238
7856
  <div class="page-header">
@@ -7263,9 +7881,30 @@ Reranking models rescore initial search results to improve relevance ordering.</
7263
7881
  </div>
7264
7882
  </div>
7265
7883
 
7266
- <!-- Hidden global model select (used by JS for model sync) -->
7267
- <select id="globalModel" style="display:none;"></select>
7268
-
7884
+ <!-- Hidden global model select (used by JS for model sync) -->
7885
+ <select id="globalModel" style="display:none;"></select>
7886
+
7887
+ <!-- ========== MODEL PICKER OVERLAY ========== -->
7888
+ <div class="model-picker-overlay" id="modelPickerOverlay" role="dialog" aria-modal="true" aria-labelledby="modelPickerTitle">
7889
+ <div class="model-picker">
7890
+ <div class="model-picker-header">
7891
+ <div>
7892
+ <div class="model-picker-title" id="modelPickerTitle">Choose a Model</div>
7893
+ <div class="model-picker-context" id="modelPickerContext"></div>
7894
+ </div>
7895
+ <button class="model-picker-close" id="modelPickerClose" aria-label="Close">&times;</button>
7896
+ </div>
7897
+ <div class="model-picker-search">
7898
+ <input type="text" id="modelPickerSearch" placeholder="Search models..." oninput="filterModelPicker()" aria-label="Search models">
7899
+ </div>
7900
+ <div class="model-picker-recommendation" id="modelPickerRecommendation"></div>
7901
+ <div class="model-picker-list" id="modelPickerList" role="listbox"></div>
7902
+ <div class="model-picker-footer">
7903
+ <a href="#" onclick="event.preventDefault(); closeModelPicker(); switchTab('models');">View all models in catalog</a>
7904
+ </div>
7905
+ </div>
7906
+ </div>
7907
+
7269
7908
  <!-- ========== SETTINGS TAB ========== -->
7270
7909
  <div class="tab-panel" id="tab-settings">
7271
7910
  <div class="settings-layout">
@@ -8063,6 +8702,7 @@ async function init() {
8063
8702
  await loadConfig();
8064
8703
  await Promise.all([loadModels(), loadConcepts()]);
8065
8704
  populateModelSelects();
8705
+ enhanceModelSelects();
8066
8706
  buildExploreCards();
8067
8707
 
8068
8708
  // Apply default tab setting
@@ -8146,6 +8786,9 @@ function switchTab(tab) {
8146
8786
  if (tab === 'home') {
8147
8787
  homeInit();
8148
8788
  }
8789
+ if (tab === 'models') {
8790
+ modelsInit();
8791
+ }
8149
8792
  }
8150
8793
  // Expose globally so Electron main process can call it
8151
8794
  window.switchTab = switchTab;
@@ -9541,6 +10184,393 @@ window.filterExplore = function() {
9541
10184
  });
9542
10185
  };
9543
10186
 
10187
+ // ── Models Tab ──
10188
+ let modelsTabData = {
10189
+ catalog: null,
10190
+ benchmarks: null,
10191
+ currentFilter: 'all',
10192
+ initialized: false
10193
+ };
10194
+
10195
+ async function loadModelsCatalog() {
10196
+ if (modelsTabData.catalog) return;
10197
+ try {
10198
+ const res = await fetch('/api/models/catalog');
10199
+ const data = await res.json();
10200
+ modelsTabData.catalog = data.models || [];
10201
+ modelsTabData.benchmarks = data.benchmarks || [];
10202
+ } catch {
10203
+ modelsTabData.catalog = allModels;
10204
+ modelsTabData.benchmarks = [];
10205
+ }
10206
+ }
10207
+
10208
+ function modelsInit() {
10209
+ if (modelsTabData.initialized) return;
10210
+ modelsTabData.initialized = true;
10211
+ loadModelsCatalog().then(() => {
10212
+ renderModelsHero();
10213
+ renderModelsGroups();
10214
+ renderModelsRtebChart();
10215
+ });
10216
+ }
10217
+
10218
+ function renderModelsHero() {
10219
+ const heroModels = document.getElementById('modelsHeroModels');
10220
+ const sharedModels = (modelsTabData.catalog || []).filter(
10221
+ m => m.sharedSpace === 'voyage-4' && !m.legacy && !m.unreleased
10222
+ );
10223
+ heroModels.innerHTML = sharedModels.map(m =>
10224
+ '<div class="model-chip" onclick="scrollToModelCard(\'' + m.name + '\')">' +
10225
+ '<span>' + escapeHtml(m.name) + '</span>' +
10226
+ '<span class="model-chip-price">' + escapeHtml(m.price) + '</span>' +
10227
+ '</div>'
10228
+ ).join('');
10229
+
10230
+ const shapeEl = document.getElementById('modelsHeroShape');
10231
+ if (shapeEl && !shapeEl.innerHTML) {
10232
+ shapeEl.innerHTML = generateNebulaSVG(200, 200, 900, {variant: 'rich', opacity: 0.08});
10233
+ }
10234
+ }
10235
+
10236
+ function getModelGroups(models) {
10237
+ const groups = [
10238
+ {
10239
+ id: 'voyage-4',
10240
+ title: 'Voyage 4 Flagship',
10241
+ icon: LI.zap,
10242
+ models: models.filter(m => m.family === 'voyage-4' && !m.legacy)
10243
+ },
10244
+ {
10245
+ id: 'domain',
10246
+ title: 'Domain-Specific',
10247
+ icon: LI.target,
10248
+ models: models.filter(m => !m.family && m.type === 'embedding' && !m.legacy && !m.multimodal)
10249
+ },
10250
+ {
10251
+ id: 'multimodal',
10252
+ title: 'Multimodal',
10253
+ icon: LI.image,
10254
+ models: models.filter(m => m.multimodal && !m.legacy)
10255
+ },
10256
+ {
10257
+ id: 'reranking',
10258
+ title: 'Reranking',
10259
+ icon: LI.shuffle,
10260
+ models: models.filter(m => m.type === 'reranking' && !m.legacy)
10261
+ },
10262
+ {
10263
+ id: 'legacy',
10264
+ title: 'Previous Generation',
10265
+ icon: LI.timer,
10266
+ models: models.filter(m => m.legacy === true)
10267
+ }
10268
+ ];
10269
+ return groups.filter(g => g.models.length > 0);
10270
+ }
10271
+
10272
+ function renderModelsGroups() {
10273
+ const container = document.getElementById('modelsGroups');
10274
+ const catalog = modelsTabData.catalog || [];
10275
+ const groups = getModelGroups(catalog);
10276
+
10277
+ container.innerHTML = groups.map(function(group, gi) {
10278
+ return '<div class="models-group" id="models-group-' + group.id + '">' +
10279
+ '<div class="models-group-header">' +
10280
+ '<span class="models-group-icon">' + lucideIcon(group.icon, 18) + '</span>' +
10281
+ '<span class="models-group-title">' + escapeHtml(group.title) + '</span>' +
10282
+ '<span class="models-group-count">' + group.models.length + '</span>' +
10283
+ '</div>' +
10284
+ '<div class="models-group-grid">' +
10285
+ group.models.map(function(m, mi) { return renderModelCard(m, gi * 100 + mi); }).join('') +
10286
+ '</div>' +
10287
+ '</div>';
10288
+ }).join('');
10289
+ }
10290
+
10291
+ function renderModelCard(model, seedOffset) {
10292
+ var typeClass = model.multimodal ? 'multimodal' :
10293
+ model.type === 'reranking' ? 'reranking' : 'embedding';
10294
+ var typeLabel = model.multimodal ? 'Multimodal' :
10295
+ model.type === 'reranking' ? 'Reranker' : 'Embedding';
10296
+
10297
+ var specs = [];
10298
+ if (model.context) specs.push({label: 'Context', value: model.context});
10299
+ if (model.dimensions && model.dimensions !== '\u2014') {
10300
+ var dimDefault = model.dimensions.split('(')[0].trim().split(',')[0];
10301
+ specs.push({label: 'Dims', value: dimDefault});
10302
+ }
10303
+ specs.push({label: 'Price', value: model.price});
10304
+ if (model.architecture) specs.push({label: 'Arch', value: model.architecture === 'moe' ? 'MoE' : 'Dense'});
10305
+
10306
+ var sharedBadge = model.sharedSpace ?
10307
+ '<div class="model-card-shared-badge">' + lucideIcon(LI.link, 12) + ' Shared: ' + model.sharedSpace + '</div>' : '';
10308
+
10309
+ var rtebBadge = model.rtebScore ?
10310
+ '<div class="model-card-rteb">' + model.rtebScore.toFixed(1) + '</div>' : '';
10311
+
10312
+ var tryActions = '';
10313
+ if (model.multimodal) {
10314
+ tryActions = '<button class="model-card-action" onclick="event.stopPropagation(); switchTab(\'multimodal\')">Try in Multimodal</button>';
10315
+ } else if (model.type === 'reranking') {
10316
+ tryActions = '<button class="model-card-action" onclick="event.stopPropagation(); switchTab(\'search\')">Try in Search</button>';
10317
+ } else if (!model.legacy) {
10318
+ tryActions = '<button class="model-card-action" onclick="event.stopPropagation(); switchTab(\'embed\')">Try in Embed</button>' +
10319
+ '<button class="model-card-action" onclick="event.stopPropagation(); switchTab(\'search\')">Try in Search</button>';
10320
+ }
10321
+
10322
+ return '<div class="model-card ' + (model.legacy ? 'legacy' : '') + '" data-model="' + model.name + '" data-type="' + typeClass + '" id="model-card-' + model.name + '">' +
10323
+ '<div class="model-card-nebula">' + generateNebulaSVG(140, 140, 500 + seedOffset, {variant: 'subtle', opacity: 0.05, sparkles: false}) + '</div>' +
10324
+ rtebBadge +
10325
+ '<div class="model-card-header">' +
10326
+ '<span class="model-card-name">' + escapeHtml(model.name) + '</span>' +
10327
+ '<span class="model-card-type-badge ' + typeClass + '">' + typeLabel + '</span>' +
10328
+ '</div>' +
10329
+ sharedBadge +
10330
+ '<div class="model-card-desc">' + escapeHtml(model.bestFor) + '</div>' +
10331
+ '<div class="model-card-specs">' +
10332
+ specs.map(function(s) {
10333
+ return '<div class="model-card-spec">' +
10334
+ '<span class="model-card-spec-label">' + s.label + '</span>' +
10335
+ '<span class="model-card-spec-value">' + escapeHtml(s.value) + '</span>' +
10336
+ '</div>';
10337
+ }).join('') +
10338
+ '</div>' +
10339
+ (tryActions ? '<div class="model-card-actions">' + tryActions + '</div>' : '') +
10340
+ '</div>';
10341
+ }
10342
+
10343
+ function renderModelsRtebChart() {
10344
+ var chart = document.getElementById('modelsRtebChart');
10345
+ var benchmarks = modelsTabData.benchmarks || [];
10346
+ if (!benchmarks.length) {
10347
+ chart.innerHTML = '<p style="color:var(--text-muted);font-size:13px;">Benchmark data unavailable.</p>';
10348
+ return;
10349
+ }
10350
+ var maxScore = Math.max.apply(null, benchmarks.map(function(b) { return b.score; }));
10351
+ var minDisplay = 55;
10352
+
10353
+ chart.innerHTML = benchmarks.map(function(b) {
10354
+ var isVoyage = b.provider === 'Voyage AI';
10355
+ var barPct = ((b.score - minDisplay) / (maxScore - minDisplay)) * 100;
10356
+ var color = isVoyage ? 'var(--accent)' : '#555';
10357
+ var labelColor = isVoyage ? 'var(--accent)' : 'var(--text-dim)';
10358
+ var fontWeight = isVoyage ? '600' : '400';
10359
+
10360
+ return '<div class="models-rteb-bar">' +
10361
+ '<span class="models-rteb-label" style="color:' + labelColor + ';font-weight:' + fontWeight + ';">' + escapeHtml(b.model) + '</span>' +
10362
+ '<div class="models-rteb-track">' +
10363
+ '<div class="models-rteb-fill" style="width:' + barPct.toFixed(1) + '%;background:' + color + ';' + (isVoyage ? '' : 'opacity:0.6;') + '"></div>' +
10364
+ '</div>' +
10365
+ '<span class="models-rteb-score" style="color:' + labelColor + ';">' + b.score.toFixed(2) + '</span>' +
10366
+ '</div>';
10367
+ }).join('');
10368
+ }
10369
+
10370
+ function scrollToModelCard(name) {
10371
+ var card = document.getElementById('model-card-' + name);
10372
+ if (card) {
10373
+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });
10374
+ card.style.outline = '2px solid var(--accent)';
10375
+ setTimeout(function() { card.style.outline = ''; }, 2000);
10376
+ }
10377
+ }
10378
+
10379
+ window.filterModels = function() {
10380
+ var q = document.getElementById('modelsSearch').value.toLowerCase().trim();
10381
+ document.querySelectorAll('.model-card').forEach(function(card) {
10382
+ if (!q) { card.style.display = ''; return; }
10383
+ var text = card.textContent.toLowerCase();
10384
+ card.style.display = text.includes(q) ? '' : 'none';
10385
+ });
10386
+ document.querySelectorAll('.models-group').forEach(function(group) {
10387
+ var visible = group.querySelectorAll('.model-card:not([style*="display: none"])');
10388
+ group.style.display = visible.length ? '' : 'none';
10389
+ });
10390
+ };
10391
+
10392
+ window.setModelFilter = function(filter) {
10393
+ modelsTabData.currentFilter = filter;
10394
+ document.querySelectorAll('.models-filter-pill').forEach(function(p) {
10395
+ p.classList.toggle('active', p.dataset.filter === filter);
10396
+ });
10397
+ document.querySelectorAll('.model-card').forEach(function(card) {
10398
+ if (filter === 'all') { card.style.display = ''; return; }
10399
+ var cardType = card.dataset.type;
10400
+ card.style.display = cardType === filter ? '' : 'none';
10401
+ });
10402
+ document.querySelectorAll('.models-group').forEach(function(group) {
10403
+ var visible = group.querySelectorAll('.model-card:not([style*="display: none"])');
10404
+ group.style.display = visible.length ? '' : 'none';
10405
+ });
10406
+ };
10407
+
10408
+ // ── Model Picker ──
10409
+ var modelPickerState = {
10410
+ isOpen: false,
10411
+ targetSelectId: null,
10412
+ context: null,
10413
+ selectedModel: null,
10414
+ previousFocus: null,
10415
+ modelType: 'embedding'
10416
+ };
10417
+
10418
+ function openModelPicker(selectId, context) {
10419
+ modelPickerState.isOpen = true;
10420
+ modelPickerState.targetSelectId = selectId;
10421
+ modelPickerState.context = context;
10422
+ modelPickerState.previousFocus = document.activeElement;
10423
+
10424
+ var sel = document.getElementById(selectId);
10425
+ modelPickerState.selectedModel = sel ? sel.value : null;
10426
+ modelPickerState.modelType = selectId === 'searchRerankModel' ? 'reranking' : 'embedding';
10427
+
10428
+ var contextHints = {
10429
+ embed: 'Choose an embedding model for vectorizing text.',
10430
+ compare: 'Choose a model for similarity scoring.',
10431
+ search: 'Choose an embedding model for document search.',
10432
+ multimodal: 'Only multimodal models are available here.'
10433
+ };
10434
+ document.getElementById('modelPickerContext').textContent = contextHints[context] || '';
10435
+
10436
+ var rec = document.getElementById('modelPickerRecommendation');
10437
+ var recommendations = {
10438
+ embed: 'Tip: voyage-4 offers balanced quality and cost.',
10439
+ compare: 'Tip: voyage-4-large gives highest accuracy comparisons.',
10440
+ search: 'Tip: Use voyage-4-large for docs, voyage-4-lite for queries (shared space saves cost).',
10441
+ multimodal: 'Tip: voyage-multimodal-3.5 supports text, images, and video.'
10442
+ };
10443
+ if (recommendations[context]) {
10444
+ rec.textContent = recommendations[context];
10445
+ rec.classList.add('visible');
10446
+ } else {
10447
+ rec.classList.remove('visible');
10448
+ }
10449
+
10450
+ renderModelPickerList();
10451
+ document.getElementById('modelPickerOverlay').classList.add('open');
10452
+ setTimeout(function() {
10453
+ document.getElementById('modelPickerSearch').focus();
10454
+ }, 100);
10455
+ }
10456
+
10457
+ function closeModelPicker() {
10458
+ modelPickerState.isOpen = false;
10459
+ document.getElementById('modelPickerOverlay').classList.remove('open');
10460
+ document.getElementById('modelPickerSearch').value = '';
10461
+ if (modelPickerState.previousFocus) {
10462
+ modelPickerState.previousFocus.focus();
10463
+ modelPickerState.previousFocus = null;
10464
+ }
10465
+ }
10466
+
10467
+ function selectModelFromPicker(name) {
10468
+ var sel = document.getElementById(modelPickerState.targetSelectId);
10469
+ if (sel) {
10470
+ sel.value = name;
10471
+ sel.dispatchEvent(new Event('change'));
10472
+ localStorage.setItem('vai-playground-model', name);
10473
+ if (modelPickerState.modelType === 'embedding') {
10474
+ ['embedModel', 'compareModel', 'searchEmbedModel'].forEach(function(id) {
10475
+ var otherSel = document.getElementById(id);
10476
+ if (otherSel && otherSel !== sel) otherSel.value = name;
10477
+ });
10478
+ }
10479
+ }
10480
+ closeModelPicker();
10481
+ sendTelemetry('model_picker_select', { model: name, context: modelPickerState.context });
10482
+ }
10483
+
10484
+ function renderModelPickerList() {
10485
+ var list = document.getElementById('modelPickerList');
10486
+ var type = modelPickerState.modelType;
10487
+ var context = modelPickerState.context;
10488
+ var selected = modelPickerState.selectedModel;
10489
+
10490
+ var models;
10491
+ if (context === 'multimodal') {
10492
+ models = allModels.filter(function(m) { return m.multimodal; });
10493
+ } else if (type === 'reranking') {
10494
+ models = rerankModels;
10495
+ } else {
10496
+ models = embedModels;
10497
+ }
10498
+
10499
+ list.innerHTML = models.map(function(m) {
10500
+ var isSelected = m.name === selected;
10501
+ var tags = [];
10502
+ if (m.architecture === 'moe') tags.push({text: 'MoE', cls: 'model-picker-item-tag'});
10503
+ if (m.sharedSpace) tags.push({text: 'Shared Space', cls: 'model-picker-item-shared'});
10504
+ if (m.rtebScore) tags.push({text: 'RTEB ' + m.rtebScore, cls: 'model-picker-item-tag'});
10505
+
10506
+ var meta = [m.bestFor, m.context, m.price].filter(Boolean).join(' \u00b7 ');
10507
+
10508
+ return '<div class="model-picker-item ' + (isSelected ? 'selected' : '') + '" ' +
10509
+ 'role="option" aria-selected="' + isSelected + '" tabindex="0" ' +
10510
+ 'data-model="' + m.name + '" ' +
10511
+ 'onclick="selectModelFromPicker(\'' + m.name + '\')" ' +
10512
+ 'onkeydown="if(event.key===\'Enter\') selectModelFromPicker(\'' + m.name + '\')">' +
10513
+ '<div class="model-picker-item-radio"></div>' +
10514
+ '<div class="model-picker-item-info">' +
10515
+ '<div class="model-picker-item-name">' + escapeHtml(m.name) + '</div>' +
10516
+ '<div class="model-picker-item-meta">' + escapeHtml(meta) + '</div>' +
10517
+ (tags.length ? '<div class="model-picker-item-tags">' +
10518
+ tags.map(function(t) { return '<span class="' + t.cls + '">' + t.text + '</span>'; }).join('') +
10519
+ '</div>' : '') +
10520
+ '</div>' +
10521
+ '</div>';
10522
+ }).join('');
10523
+ }
10524
+
10525
+ window.filterModelPicker = function() {
10526
+ var q = document.getElementById('modelPickerSearch').value.toLowerCase().trim();
10527
+ document.querySelectorAll('#modelPickerList .model-picker-item').forEach(function(item) {
10528
+ if (!q) { item.style.display = ''; return; }
10529
+ var text = item.textContent.toLowerCase();
10530
+ item.style.display = text.includes(q) ? '' : 'none';
10531
+ });
10532
+ };
10533
+
10534
+ function enhanceModelSelects() {
10535
+ var selectConfigs = [
10536
+ { id: 'embedModel', context: 'embed' },
10537
+ { id: 'compareModel', context: 'compare' },
10538
+ { id: 'searchEmbedModel', context: 'search' },
10539
+ { id: 'searchRerankModel', context: 'search' }
10540
+ ];
10541
+
10542
+ selectConfigs.forEach(function(cfg) {
10543
+ var sel = document.getElementById(cfg.id);
10544
+ if (!sel || sel.dataset.pickerEnhanced) return;
10545
+ sel.dataset.pickerEnhanced = 'true';
10546
+
10547
+ var pickerBtn = document.createElement('button');
10548
+ pickerBtn.className = 'model-picker-trigger';
10549
+ pickerBtn.type = 'button';
10550
+ pickerBtn.title = 'Browse models';
10551
+ pickerBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>';
10552
+ pickerBtn.addEventListener('click', function(e) {
10553
+ e.preventDefault();
10554
+ e.stopPropagation();
10555
+ openModelPicker(cfg.id, cfg.context);
10556
+ });
10557
+
10558
+ sel.parentNode.insertBefore(pickerBtn, sel.nextSibling);
10559
+ });
10560
+ }
10561
+
10562
+ // Model picker close handlers
10563
+ document.getElementById('modelPickerClose').addEventListener('click', closeModelPicker);
10564
+ document.getElementById('modelPickerOverlay').addEventListener('click', function(e) {
10565
+ if (e.target === document.getElementById('modelPickerOverlay')) closeModelPicker();
10566
+ });
10567
+ document.addEventListener('keydown', function(e) {
10568
+ if (e.key === 'Escape' && modelPickerState.isOpen) {
10569
+ e.preventDefault();
10570
+ closeModelPicker();
10571
+ }
10572
+ });
10573
+
9544
10574
  // ── Benchmark: Sub-panel switching ──
9545
10575
  document.querySelectorAll('.bench-panel-btn').forEach(btn => {
9546
10576
  btn.addEventListener('click', () => {
@@ -11065,7 +12095,23 @@ function checkForAppUpdate() {
11065
12095
  downloadBtn.style.display = 'none';
11066
12096
  bannerText.style.display = 'none';
11067
12097
  progressWrap.style.display = 'flex';
12098
+ // Timeout: if no progress after 30s, offer manual download
12099
+ let gotProgress = false;
12100
+ const dlTimeout = setTimeout(() => {
12101
+ if (!gotProgress) {
12102
+ progressWrap.style.display = 'none';
12103
+ bannerText.innerHTML = 'Download stalled. <a href="#" onclick="window.vai.updates.openRelease(\'' + result.releaseUrl + '\');return false;" style="color:var(--accent);">Download manually</a>';
12104
+ bannerText.style.display = '';
12105
+ }
12106
+ }, 30000);
12107
+ // Listen for first progress event to cancel timeout
12108
+ const unsub = window.vai.updates.onEvent((d) => {
12109
+ if (d.event === 'download-progress') { gotProgress = true; clearTimeout(dlTimeout); unsub(); }
12110
+ if (d.event === 'update-error') { clearTimeout(dlTimeout); unsub(); }
12111
+ if (d.event === 'update-downloaded') { clearTimeout(dlTimeout); unsub(); }
12112
+ });
11068
12113
  window.vai.updates.download().catch(() => {
12114
+ clearTimeout(dlTimeout);
11069
12115
  // Fallback to manual download
11070
12116
  window.vai.updates.openRelease(result.releaseUrl);
11071
12117
  });
@@ -11344,6 +12390,7 @@ function initOnboarding() {
11344
12390
 
11345
12391
  // ── Multimodal Tab ──
11346
12392
  let mmImageData = null; // base64 data URL of the uploaded image
12393
+ let mmVideoData = null; // base64 data URL of the uploaded video
11347
12394
  let mmGalleryImages = []; // array of { dataUrl, name, size }
11348
12395
  let mmSearchMode = 'text';
11349
12396
  let mmSearchImageIndex = -1;
@@ -11414,6 +12461,59 @@ function initMultimodal() {
11414
12461
  }
11415
12462
  });
11416
12463
 
12464
+ // Video drop zone — the hidden <input type="file"> lives inside the same
12465
+ // card, so its programmatic .click() bubbles back up through the DOM and
12466
+ // re-triggers the dropzone click handler, creating an infinite dialog loop.
12467
+ // Fix: stopPropagation on the input so its click never reaches the dropzone,
12468
+ // plus a re-trigger guard identical to the image dropzone.
12469
+ const videoDropZone = document.getElementById('mmVideoDropZone');
12470
+ const videoFileInput = document.getElementById('mmVideoFileInput');
12471
+
12472
+ videoFileInput.addEventListener('click', (e) => e.stopPropagation());
12473
+
12474
+ let videoDialogOpen = false;
12475
+ videoDropZone.addEventListener('click', async () => {
12476
+ if (videoDialogOpen) return;
12477
+ videoDialogOpen = true;
12478
+ try {
12479
+ if (window.vai && window.vai.isElectron && window.vai.openVideoDialog) {
12480
+ const result = await window.vai.openVideoDialog();
12481
+ if (!result.canceled && result.dataUrl) {
12482
+ handleMultimodalVideoFromData(result.dataUrl, result.name, result.size);
12483
+ }
12484
+ } else {
12485
+ videoFileInput.click();
12486
+ }
12487
+ } finally {
12488
+ setTimeout(() => { videoDialogOpen = false; }, 300);
12489
+ }
12490
+ });
12491
+ videoFileInput.addEventListener('change', (e) => {
12492
+ videoDialogOpen = false;
12493
+ if (e.target.files && e.target.files[0]) handleMultimodalVideo(e.target.files[0]);
12494
+ videoFileInput.value = '';
12495
+ });
12496
+
12497
+ ['dragenter', 'dragover'].forEach(evt => {
12498
+ videoDropZone.addEventListener(evt, (e) => {
12499
+ e.preventDefault();
12500
+ e.stopPropagation();
12501
+ videoDropZone.classList.add('drag-active');
12502
+ });
12503
+ });
12504
+ ['dragleave', 'drop'].forEach(evt => {
12505
+ videoDropZone.addEventListener(evt, (e) => {
12506
+ e.preventDefault();
12507
+ e.stopPropagation();
12508
+ videoDropZone.classList.remove('drag-active');
12509
+ });
12510
+ });
12511
+ videoDropZone.addEventListener('drop', (e) => {
12512
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
12513
+ handleMultimodalVideo(e.dataTransfer.files[0]);
12514
+ }
12515
+ });
12516
+
11417
12517
  // Gallery
11418
12518
  renderGalleryGrid();
11419
12519
 
@@ -11484,22 +12584,155 @@ window.clearMultimodalImage = function() {
11484
12584
  document.getElementById('mmFileInput').value = '';
11485
12585
  };
11486
12586
 
12587
+ async function handleMultimodalVideoFromData(dataUrl, name, size) {
12588
+ mmVideoData = dataUrl;
12589
+ const video = document.getElementById('mmPreviewVideo');
12590
+ video.src = mmVideoData;
12591
+
12592
+ const info = document.getElementById('mmVideoFileInfo');
12593
+ const sizeStr = size > 1024 * 1024
12594
+ ? (size / (1024 * 1024)).toFixed(1) + ' MB'
12595
+ : (size / 1024).toFixed(0) + ' KB';
12596
+
12597
+ document.getElementById('mmVideoDropZone').style.display = 'none';
12598
+ document.getElementById('mmVideoPreview').classList.add('visible');
12599
+ hideError('mmError');
12600
+
12601
+ // Estimate tokens from video metadata
12602
+ const meta = await estimateVideoTokens(video);
12603
+ if (meta) {
12604
+ const durStr = meta.duration.toFixed(1) + 's';
12605
+ const resStr = meta.width + '×' + meta.height;
12606
+ info.textContent = `${name} · ${sizeStr} · ${resStr} · ${durStr} · ~${meta.tokens.toLocaleString()} tokens`;
12607
+ if (meta.tokens > 32000) {
12608
+ showError('mmError',
12609
+ 'This video is estimated at ~' + meta.tokens.toLocaleString() + ' tokens, which exceeds the 32,000 token context window. ' +
12610
+ 'Try a shorter clip, lower resolution, or smaller dimensions. ' +
12611
+ '(' + resStr + ', ' + durStr + ')'
12612
+ );
12613
+ }
12614
+ } else {
12615
+ info.textContent = `${name} · ${sizeStr}`;
12616
+ }
12617
+ }
12618
+
12619
+ // Estimate video tokens: total pixels across all frames / 1120 pixels per token
12620
+ // The playground server downsamples video to 1fps before sending to Voyage AI,
12621
+ // so we estimate based on 1fps which matches what will actually be sent.
12622
+ function estimateVideoTokens(videoEl) {
12623
+ return new Promise((resolve) => {
12624
+ const checkMeta = () => {
12625
+ const w = videoEl.videoWidth;
12626
+ const h = videoEl.videoHeight;
12627
+ const dur = videoEl.duration;
12628
+ if (w && h && dur && isFinite(dur)) {
12629
+ // Server downsamples to 1fps before API call
12630
+ const assumedFps = 1;
12631
+ const frames = Math.ceil(dur * assumedFps);
12632
+ const totalPixels = w * h * frames;
12633
+ const tokens = Math.ceil(totalPixels / 1120);
12634
+ resolve({ width: w, height: h, duration: dur, frames, totalPixels, tokens });
12635
+ } else {
12636
+ resolve(null);
12637
+ }
12638
+ };
12639
+ if (videoEl.readyState >= 1) {
12640
+ checkMeta();
12641
+ } else {
12642
+ videoEl.addEventListener('loadedmetadata', checkMeta, { once: true });
12643
+ // Timeout fallback
12644
+ setTimeout(() => resolve(null), 3000);
12645
+ }
12646
+ });
12647
+ }
12648
+
12649
+ function handleMultimodalVideo(file) {
12650
+ const VALID_TYPES = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska'];
12651
+ if (!VALID_TYPES.includes(file.type) && !file.name.match(/\.(mp4|webm|mov|avi|mkv)$/i)) {
12652
+ showError('mmError', 'Unsupported video type. Use MP4, WebM, MOV, AVI, or MKV.');
12653
+ return;
12654
+ }
12655
+ if (file.size > 20 * 1024 * 1024) {
12656
+ showError('mmError', 'Video too large. Maximum size is 20 MB.');
12657
+ return;
12658
+ }
12659
+ hideError('mmError');
12660
+
12661
+ const reader = new FileReader();
12662
+ reader.onload = async (e) => {
12663
+ mmVideoData = e.target.result;
12664
+ const video = document.getElementById('mmPreviewVideo');
12665
+ video.src = mmVideoData;
12666
+
12667
+ const info = document.getElementById('mmVideoFileInfo');
12668
+ const sizeStr = file.size > 1024 * 1024
12669
+ ? (file.size / (1024 * 1024)).toFixed(1) + ' MB'
12670
+ : (file.size / 1024).toFixed(0) + ' KB';
12671
+
12672
+ document.getElementById('mmVideoDropZone').style.display = 'none';
12673
+ document.getElementById('mmVideoPreview').classList.add('visible');
12674
+
12675
+ // Estimate tokens from video metadata and warn if likely to exceed limit
12676
+ const meta = await estimateVideoTokens(video);
12677
+ if (meta) {
12678
+ const durStr = meta.duration.toFixed(1) + 's';
12679
+ const resStr = meta.width + '×' + meta.height;
12680
+ info.textContent = `${file.name} · ${sizeStr} · ${resStr} · ${durStr} · ~${meta.tokens.toLocaleString()} tokens`;
12681
+ if (meta.tokens > 32000) {
12682
+ showError('mmError',
12683
+ 'This video is estimated at ~' + meta.tokens.toLocaleString() + ' tokens, which exceeds the 32,000 token context window. ' +
12684
+ 'Try a shorter clip, lower resolution, or smaller dimensions. ' +
12685
+ '(' + resStr + ', ' + durStr + ')'
12686
+ );
12687
+ }
12688
+ } else {
12689
+ info.textContent = `${file.name} · ${sizeStr}`;
12690
+ }
12691
+ };
12692
+ reader.readAsDataURL(file);
12693
+ }
12694
+
12695
+ window.clearMultimodalVideo = function() {
12696
+ mmVideoData = null;
12697
+ const video = document.getElementById('mmPreviewVideo');
12698
+ video.pause();
12699
+ video.src = '';
12700
+ document.getElementById('mmVideoFileInfo').textContent = '';
12701
+ document.getElementById('mmVideoPreview').classList.remove('visible');
12702
+ document.getElementById('mmVideoDropZone').style.display = '';
12703
+ document.getElementById('mmVideoFileInput').value = '';
12704
+ };
12705
+
11487
12706
  window.doMultimodalCompare = async function() {
11488
12707
  hideError('mmError');
11489
12708
  sendTelemetry('api_call', { endpoint: 'multimodal-compare', model: document.getElementById('mmModel').value });
11490
12709
  const text = document.getElementById('mmText').value.trim();
11491
- if (!mmImageData) { showError('mmError', 'Upload an image first'); return; }
11492
- if (!text) { showError('mmError', 'Enter text to compare against the image'); return; }
12710
+ const hasMedia = mmImageData || mmVideoData;
12711
+ if (!hasMedia && !text) { showError('mmError', 'Upload an image or video, and enter text to compare'); return; }
12712
+
12713
+ // Need at least 2 inputs to compare
12714
+ const mediaInputs = [];
12715
+ if (mmImageData) {
12716
+ mediaInputs.push({ content: [{ type: 'image_base64', image_base64: mmImageData }], label: 'Image' });
12717
+ }
12718
+ if (mmVideoData) {
12719
+ mediaInputs.push({ content: [{ type: 'video_base64', video_base64: mmVideoData }], label: 'Video' });
12720
+ }
12721
+ if (text) {
12722
+ mediaInputs.push({ content: [{ type: 'text', text: text }], label: 'Text' });
12723
+ }
12724
+
12725
+ if (mediaInputs.length < 2) {
12726
+ showError('mmError', 'Provide at least 2 inputs to compare (e.g., image + text, video + text, or image + video)');
12727
+ return;
12728
+ }
11493
12729
 
11494
12730
  setLoading('mmCompareBtn', true);
11495
12731
  try {
11496
12732
  const model = document.getElementById('mmModel').value;
11497
12733
  const dimsVal = document.getElementById('mmDimensions').value;
11498
12734
  const body = {
11499
- inputs: [
11500
- { content: [{ type: 'image_base64', image_base64: mmImageData }] },
11501
- { content: [{ type: 'text', text: text }] }
11502
- ],
12735
+ inputs: mediaInputs.map(m => m.content ? { content: m.content } : m),
11503
12736
  model: model,
11504
12737
  input_type: 'document'
11505
12738
  };
@@ -11507,6 +12740,7 @@ window.doMultimodalCompare = async function() {
11507
12740
 
11508
12741
  const data = await apiPost('/api/multimodal-embed', body);
11509
12742
 
12743
+ // Compare first two inputs (primary comparison)
11510
12744
  const vecA = data.data[0].embedding;
11511
12745
  const vecB = data.data[1].embedding;
11512
12746
  const cosine = cosineSim(vecA, vecB);
@@ -11528,29 +12762,46 @@ window.doMultimodalCompare = async function() {
11528
12762
 
11529
12763
  // Stats
11530
12764
  const usage = data.usage || {};
11531
- const statsEl = document.getElementById('mmStats');
11532
- statsEl.innerHTML = `
12765
+ const pairLabel = mediaInputs[0].label + ' vs ' + mediaInputs[1].label;
12766
+ let statsHtml = `
11533
12767
  <span class="stat"><span class="stat-label">Model</span><span class="stat-value">${data.model || model}</span></span>
12768
+ <span class="stat"><span class="stat-label">Comparing</span><span class="stat-value">${pairLabel}</span></span>
11534
12769
  <span class="stat"><span class="stat-label">Dimensions</span><span class="stat-value">${vecA.length}</span></span>
11535
- <span class="stat"><span class="stat-label">Text Tokens</span><span class="stat-value">${usage.text_tokens || '—'}</span></span>
11536
- <span class="stat"><span class="stat-label">Image Pixels</span><span class="stat-value">${usage.image_pixels ? usage.image_pixels.toLocaleString() : '—'}</span></span>
11537
- <span class="stat"><span class="stat-label">Total Tokens</span><span class="stat-value">${usage.total_tokens || '—'}</span></span>
11538
12770
  `;
12771
+ if (usage.text_tokens) statsHtml += `<span class="stat"><span class="stat-label">Text Tokens</span><span class="stat-value">${usage.text_tokens}</span></span>`;
12772
+ if (usage.image_pixels) statsHtml += `<span class="stat"><span class="stat-label">Image Pixels</span><span class="stat-value">${usage.image_pixels.toLocaleString()}</span></span>`;
12773
+ statsHtml += `<span class="stat"><span class="stat-label">Total Tokens</span><span class="stat-value">${usage.total_tokens || '—'}</span></span>`;
12774
+
12775
+ // If 3 inputs, show all pairwise similarities
12776
+ if (mediaInputs.length === 3) {
12777
+ const vecC = data.data[2].embedding;
12778
+ const simAC = cosineSim(vecA, vecC);
12779
+ const simBC = cosineSim(vecB, vecC);
12780
+ statsHtml += `<br><span class="stat"><span class="stat-label">${mediaInputs[0].label} vs ${mediaInputs[2].label}</span><span class="stat-value">${simAC.toFixed(4)}</span></span>`;
12781
+ statsHtml += `<span class="stat"><span class="stat-label">${mediaInputs[1].label} vs ${mediaInputs[2].label}</span><span class="stat-value">${simBC.toFixed(4)}</span></span>`;
12782
+ }
12783
+
12784
+ document.getElementById('mmStats').innerHTML = statsHtml;
11539
12785
 
11540
12786
  // Insight note
11541
12787
  const noteEl = document.getElementById('mmNote');
11542
12788
  if (cosine > 0.7) {
11543
- noteEl.innerHTML = '💡 <strong>High similarity!</strong> The image and text are closely related in Voyage AI\'s multimodal embedding space. This means the text is a good semantic description of the image.';
12789
+ noteEl.innerHTML = '💡 <strong>High similarity!</strong> The inputs are closely related in Voyage AI\'s multimodal embedding space.';
11544
12790
  } else if (cosine > 0.4) {
11545
- noteEl.innerHTML = '💡 <strong>Moderate similarity.</strong> The image and text share some semantic overlap. They may be related but not a direct match.';
12791
+ noteEl.innerHTML = '💡 <strong>Moderate similarity.</strong> The inputs share some semantic overlap but are not a direct match.';
11546
12792
  } else {
11547
- noteEl.innerHTML = '💡 <strong>Low similarity.</strong> The image and text are semantically distant. Try a description that matches the image content more closely.';
12793
+ noteEl.innerHTML = '💡 <strong>Low similarity.</strong> The inputs are semantically distant. Try content that is more closely related.';
11548
12794
  }
11549
12795
 
11550
12796
  document.getElementById('mmResult').classList.add('visible');
11551
12797
  CostTracker.addOperation('multimodal-compare', data.model || model, usage.total_tokens || 0);
11552
12798
  } catch (err) {
11553
- showError('mmError', err.message);
12799
+ let msg = err.message;
12800
+ // Provide actionable guidance for context window errors
12801
+ if (msg.includes('context window') || msg.includes('exceed')) {
12802
+ msg += ' Try reducing video resolution, trimming to a shorter clip, or using a smaller image.';
12803
+ }
12804
+ showError('mmError', msg);
11554
12805
  } finally {
11555
12806
  setLoading('mmCompareBtn', false);
11556
12807
  }
@@ -11754,7 +13005,11 @@ window.doMultimodalSearch = async function() {
11754
13005
  document.getElementById('mmSearchResult').classList.add('visible');
11755
13006
  CostTracker.addOperation('multimodal-search', model, data.usage?.total_tokens || 0);
11756
13007
  } catch (err) {
11757
- showError('mmSearchError', err.message);
13008
+ let msg = err.message;
13009
+ if (msg.includes('context window') || msg.includes('exceed')) {
13010
+ msg += ' Try using smaller images or fewer corpus items per batch.';
13011
+ }
13012
+ showError('mmSearchError', msg);
11758
13013
  } finally {
11759
13014
  setLoading(btnId, false);
11760
13015
  }
@@ -13345,6 +14600,12 @@ const WF_NODE_META = {
13345
14600
  chunk: { icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M9 15h6M9 11h6M9 19h4', label: 'Chunk', color: '#00D4AA', category: 'processing' },
13346
14601
  aggregate: { icon: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3z', label: 'Aggregate', color: '#00D4AA', category: 'processing' },
13347
14602
  http: { icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zM2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z', label: 'HTTP Request', color: '#F5A623', category: 'integration' },
14603
+ // Code search tools (voyage-code-3)
14604
+ code_index: { icon: 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2zM12 11v6M9 14h6', label: 'Code Index', color: '#06B6D4', category: 'code' },
14605
+ code_search: { icon: 'M10 20l4-16M18 8l4 4-4 4M6 16l-4-4 4-4', label: 'Code Search', color: '#06B6D4', category: 'code' },
14606
+ code_query: { icon: 'M10 20l4-16M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z', label: 'Code Query', color: '#06B6D4', category: 'code' },
14607
+ code_find_similar: { icon: 'M16 3h5v5M8 3H3v5M3 16v5h5M21 16v5h-5M12 8v8M8 12h8', label: 'Find Similar', color: '#06B6D4', category: 'code' },
14608
+ code_status: { icon: 'M22 12h-4l-3 9L9 3l-3 9H2', label: 'Code Status', color: '#06B6D4', category: 'code' },
13348
14609
  };
13349
14610
 
13350
14611
  // Fallback icon (gear) for unknown workflow node types
@@ -13378,6 +14639,7 @@ let wfState = {
13378
14639
  draggingEdge: null,
13379
14640
  dragNode: null,
13380
14641
  dirtyFlag: false,
14642
+ outputFormat: 'auto',
13381
14643
  };
13382
14644
 
13383
14645
  // ── Library ──
@@ -13559,6 +14821,16 @@ function wfCloseInstallDialog() {
13559
14821
  if (modal) modal.style.display = 'none';
13560
14822
  }
13561
14823
 
14824
+ function wfCompareVersions(a, b) {
14825
+ const pa = (a || '0.0.0').split('.').map(Number);
14826
+ const pb = (b || '0.0.0').split('.').map(Number);
14827
+ for (let i = 0; i < 3; i++) {
14828
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1;
14829
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1;
14830
+ }
14831
+ return 0;
14832
+ }
14833
+
13562
14834
  async function wfSearchNpm() {
13563
14835
  const query = document.getElementById('wfInstallSearch').value.trim();
13564
14836
  const results = document.getElementById('wfInstallResults');
@@ -13574,15 +14846,27 @@ async function wfSearchNpm() {
13574
14846
  results.innerHTML = data.results.map(r => {
13575
14847
  const isOfficial = r.name.startsWith('@vaicli/');
13576
14848
  const badge = isOfficial ? '<span style="background:var(--accent);color:#fff;font-size:9px;padding:1px 5px;border-radius:3px;margin-left:6px;">OFFICIAL</span>' : '';
13577
- const installed = [...(wfState.official||[]), ...(wfState.community||[])].some(w => w.name === r.name);
13578
- const btn = installed
13579
- ? '<button disabled style="padding:4px 10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text-muted);font-size:11px;cursor:default;">Installed</button>'
13580
- : `<button onclick="wfInstallPkg('${r.name.replace(/'/g,"\\'")}')" style="padding:4px 10px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">Install</button>`;
14849
+ const installedPkg = [...(wfState.official||[]), ...(wfState.community||[])].find(w => w.name === r.name);
14850
+ const installed = !!installedPkg;
14851
+ const installedVersion = installedPkg?.version;
14852
+ const npmVersion = r.version;
14853
+ const hasUpdate = installed && npmVersion && installedVersion && npmVersion !== installedVersion && wfCompareVersions(npmVersion, installedVersion) > 0;
14854
+ let btn;
14855
+ if (hasUpdate) {
14856
+ btn = `<button onclick="wfInstallPkg('${r.name.replace(/'/g,"\\'")}')" style="padding:4px 10px;background:#F59E0B;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;" title="Installed: v${installedVersion}">Update</button>`;
14857
+ } else if (installed) {
14858
+ btn = '<button disabled style="padding:4px 10px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text-muted);font-size:11px;cursor:default;">Installed</button>';
14859
+ } else {
14860
+ btn = `<button onclick="wfInstallPkg('${r.name.replace(/'/g,"\\'")}')" style="padding:4px 10px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:11px;">Install</button>`;
14861
+ }
14862
+ const versionInfo = hasUpdate
14863
+ ? `v${installedVersion} → <span style="color:#F59E0B;font-weight:500;">v${npmVersion}</span>`
14864
+ : `v${npmVersion || '?'}`;
13581
14865
  return `<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;align-items:flex-start;justify-content:space-between;">
13582
14866
  <div style="flex:1;min-width:0;">
13583
14867
  <div style="font-size:13px;font-weight:500;">${r.name}${badge}</div>
13584
14868
  <div style="font-size:11px;color:var(--text-muted);margin-top:2px;">${r.description || ''}</div>
13585
- <div style="font-size:10px;color:var(--text-muted);margin-top:2px;">v${r.version || '?'}</div>
14869
+ <div style="font-size:10px;color:var(--text-muted);margin-top:2px;">${versionInfo}</div>
13586
14870
  </div>
13587
14871
  <div style="margin-left:12px;flex-shrink:0;">${btn}</div>
13588
14872
  </div>`;
@@ -13598,7 +14882,7 @@ async function wfInstallPkg(name) {
13598
14882
  const btns = results.querySelectorAll('button');
13599
14883
  let clickedBtn = null;
13600
14884
  btns.forEach(b => {
13601
- if (b.textContent === 'Install' && !clickedBtn) {
14885
+ if ((b.textContent === 'Install' || b.textContent === 'Update') && !clickedBtn) {
13602
14886
  // Find the button in the same row as the package name
13603
14887
  const row = b.closest('div[style*="border-bottom"]');
13604
14888
  if (row && row.textContent.includes(name)) {
@@ -13685,40 +14969,192 @@ function wfHandleFileLoad(event) {
13685
14969
  alert('Invalid workflow: missing "steps" array');
13686
14970
  return;
13687
14971
  }
13688
- // Validate via server
13689
- const valRes = await fetch('/api/workflows/validate', {
13690
- method: 'POST',
13691
- headers: { 'Content-Type': 'application/json' },
13692
- body: JSON.stringify({ definition }),
13693
- });
13694
- const valData = await valRes.json();
13695
- if (!valData.valid) {
13696
- alert('Workflow validation failed:\n' + (valData.errors || []).join('\n'));
13697
- return;
14972
+ // Validate via server
14973
+ const valRes = await fetch('/api/workflows/validate', {
14974
+ method: 'POST',
14975
+ headers: { 'Content-Type': 'application/json' },
14976
+ body: JSON.stringify({ definition }),
14977
+ });
14978
+ const valData = await valRes.json();
14979
+ if (!valData.valid) {
14980
+ alert('Workflow validation failed:\n' + (valData.errors || []).join('\n'));
14981
+ return;
14982
+ }
14983
+ // Deselect any library item
14984
+ document.querySelectorAll('.wf-library-item.active').forEach(el => el.classList.remove('active'));
14985
+ // Load the workflow
14986
+ wfState.activeWorkflow = definition;
14987
+ wfState.selectedNodeId = null;
14988
+ wfState.executionState = {};
14989
+ wfState.executionResults = {};
14990
+ wfSetToolbarEnabled(true);
14991
+ document.getElementById('wfCanvasEmpty').style.display = 'none';
14992
+ wfHideExecStatus();
14993
+ await wfRenderWorkflow(definition);
14994
+ wfOpenInspector();
14995
+ wfUpdateInspector();
14996
+ } catch (err) {
14997
+ alert('Failed to parse workflow file: ' + err.message);
14998
+ }
14999
+ };
15000
+ reader.readAsText(file);
15001
+ // Reset file input so the same file can be re-loaded
15002
+ event.target.value = '';
15003
+ }
15004
+
15005
+ // ── DAG Layout + SVG Rendering ──
15006
+ /**
15007
+ * Sugiyama-style DAG auto-layout: barycenter crossing minimization + neighbor-aware placement.
15008
+ * Takes layers (from Kahn's topological sort) and graph (dependency map),
15009
+ * reorders nodes within each layer to minimize edge crossings,
15010
+ * then assigns Y coordinates so nodes sit near their connected neighbors.
15011
+ *
15012
+ * @param {string[][]} layers - layers[i] = [stepIds that can run in parallel]
15013
+ * @param {Object} graph - { stepId: [depStepIds...] } (incoming edges)
15014
+ * @returns {Object} positions - { stepId: { x, y } }
15015
+ */
15016
+ function wfAutoLayout(layers, graph) {
15017
+ if (!layers || layers.length === 0) return {};
15018
+
15019
+ // Build adjacency: forward (outgoing) and backward (incoming) neighbor lists
15020
+ const incoming = {}; // stepId -> [ids in previous layers that connect to it]
15021
+ const outgoing = {}; // stepId -> [ids in next layers it connects to]
15022
+ const allIds = new Set(layers.flat());
15023
+ for (const id of allIds) { incoming[id] = []; outgoing[id] = []; }
15024
+
15025
+ for (const [stepId, deps] of Object.entries(graph)) {
15026
+ if (!deps || !Array.isArray(deps)) continue;
15027
+ for (const rawDep of deps) {
15028
+ const dep = rawDep.replace(/^!/, '');
15029
+ if (allIds.has(dep) && allIds.has(stepId)) {
15030
+ incoming[stepId].push(dep);
15031
+ outgoing[dep].push(stepId);
15032
+ }
15033
+ }
15034
+ }
15035
+
15036
+ // Index: which layer is each node in?
15037
+ const layerOf = {};
15038
+ layers.forEach((layer, li) => layer.forEach(id => { layerOf[id] = li; }));
15039
+
15040
+ // Work on mutable copies of each layer's ordering
15041
+ const ordered = layers.map(l => [...l]);
15042
+
15043
+ // Barycenter crossing minimization (multi-pass, alternating direction)
15044
+ // Barycenter = average position of a node's neighbors in the adjacent layer.
15045
+ // Sorting by barycenter within each layer reduces crossings.
15046
+ const NUM_PASSES = 4;
15047
+
15048
+ for (let pass = 0; pass < NUM_PASSES; pass++) {
15049
+ if (pass % 2 === 0) {
15050
+ // Forward pass: for each layer (left to right), sort by barycenter of incoming neighbors
15051
+ for (let li = 1; li < ordered.length; li++) {
15052
+ const prevOrder = {};
15053
+ ordered[li - 1].forEach((id, idx) => { prevOrder[id] = idx; });
15054
+
15055
+ ordered[li].sort((a, b) => {
15056
+ const baryA = wfBarycenter(a, incoming, prevOrder);
15057
+ const baryB = wfBarycenter(b, incoming, prevOrder);
15058
+ return baryA - baryB;
15059
+ });
15060
+ }
15061
+ } else {
15062
+ // Backward pass: for each layer (right to left), sort by barycenter of outgoing neighbors
15063
+ for (let li = ordered.length - 2; li >= 0; li--) {
15064
+ const nextOrder = {};
15065
+ ordered[li + 1].forEach((id, idx) => { nextOrder[id] = idx; });
15066
+
15067
+ ordered[li].sort((a, b) => {
15068
+ const baryA = wfBarycenter(a, outgoing, nextOrder);
15069
+ const baryB = wfBarycenter(b, outgoing, nextOrder);
15070
+ return baryA - baryB;
15071
+ });
15072
+ }
15073
+ }
15074
+ }
15075
+
15076
+ // Assign X coordinates (fixed per layer)
15077
+ // Assign Y coordinates using neighbor-aware placement:
15078
+ // First pass: assign evenly spaced Y (centered).
15079
+ // Second pass: nudge each node toward the average Y of its neighbors.
15080
+ const positions = {};
15081
+ const maxLayerSize = Math.max(...ordered.map(l => l.length));
15082
+ const totalH = maxLayerSize * (WF_NODE_H + WF_NODE_GAP);
15083
+
15084
+ // Initial even spacing (centered vertically)
15085
+ ordered.forEach((layer, li) => {
15086
+ const x = WF_PAD + li * WF_LAYER_GAP;
15087
+ const layerH = layer.length * WF_NODE_H + (layer.length - 1) * WF_NODE_GAP;
15088
+ const startY = WF_PAD + (totalH - layerH) / 2;
15089
+ layer.forEach((stepId, ni) => {
15090
+ positions[stepId] = { x, y: startY + ni * (WF_NODE_H + WF_NODE_GAP) };
15091
+ });
15092
+ });
15093
+
15094
+ // Neighbor-aware Y nudging (iterative relaxation)
15095
+ // Pulls nodes toward the average Y of their connected neighbors while maintaining order and minimum gap.
15096
+ const RELAX_PASSES = 3;
15097
+ const RELAX_STRENGTH = 0.4; // How much to move toward neighbor average (0=none, 1=full)
15098
+
15099
+ for (let rp = 0; rp < RELAX_PASSES; rp++) {
15100
+ for (let li = 0; li < ordered.length; li++) {
15101
+ const layer = ordered[li];
15102
+ if (layer.length <= 1) continue;
15103
+
15104
+ // Compute ideal Y for each node (average Y of all neighbors)
15105
+ const idealY = {};
15106
+ for (const id of layer) {
15107
+ const neighbors = [...(incoming[id] || []), ...(outgoing[id] || [])];
15108
+ if (neighbors.length === 0) { idealY[id] = positions[id].y; continue; }
15109
+ const avgY = neighbors.reduce((sum, nid) => sum + (positions[nid]?.y || 0), 0) / neighbors.length;
15110
+ idealY[id] = avgY;
15111
+ }
15112
+
15113
+ // Nudge toward ideal, then enforce minimum gap and original order
15114
+ for (const id of layer) {
15115
+ const current = positions[id].y;
15116
+ const target = idealY[id];
15117
+ positions[id].y = current + (target - current) * RELAX_STRENGTH;
15118
+ }
15119
+
15120
+ // Re-sort by Y to maintain the barycenter ordering, then enforce minimum gaps
15121
+ const sortedLayer = [...layer].sort((a, b) => positions[a].y - positions[b].y);
15122
+ const minGap = WF_NODE_H + WF_NODE_GAP;
15123
+
15124
+ // Push apart any overlaps (top-down)
15125
+ for (let i = 1; i < sortedLayer.length; i++) {
15126
+ const prev = positions[sortedLayer[i - 1]].y;
15127
+ const curr = positions[sortedLayer[i]].y;
15128
+ if (curr - prev < minGap) {
15129
+ positions[sortedLayer[i]].y = prev + minGap;
15130
+ }
15131
+ }
15132
+
15133
+ // Re-center the layer vertically to keep it balanced
15134
+ const layerTop = positions[sortedLayer[0]].y;
15135
+ const layerBottom = positions[sortedLayer[sortedLayer.length - 1]].y + WF_NODE_H;
15136
+ const layerMid = (layerTop + layerBottom) / 2;
15137
+ const canvasMid = WF_PAD + totalH / 2;
15138
+ const shift = canvasMid - layerMid;
15139
+ for (const id of sortedLayer) {
15140
+ positions[id].y += shift;
13698
15141
  }
13699
- // Deselect any library item
13700
- document.querySelectorAll('.wf-library-item.active').forEach(el => el.classList.remove('active'));
13701
- // Load the workflow
13702
- wfState.activeWorkflow = definition;
13703
- wfState.selectedNodeId = null;
13704
- wfState.executionState = {};
13705
- wfState.executionResults = {};
13706
- wfSetToolbarEnabled(true);
13707
- document.getElementById('wfCanvasEmpty').style.display = 'none';
13708
- wfHideExecStatus();
13709
- await wfRenderWorkflow(definition);
13710
- wfOpenInspector();
13711
- wfUpdateInspector();
13712
- } catch (err) {
13713
- alert('Failed to parse workflow file: ' + err.message);
13714
15142
  }
13715
- };
13716
- reader.readAsText(file);
13717
- // Reset file input so the same file can be re-loaded
13718
- event.target.value = '';
15143
+ }
15144
+
15145
+ return positions;
15146
+ }
15147
+
15148
+ /**
15149
+ * Compute barycenter (average position) of a node's neighbors in an adjacent layer.
15150
+ * If a node has no neighbors in the given orderMap, returns Infinity to push it to the end.
15151
+ */
15152
+ function wfBarycenter(nodeId, neighborMap, orderMap) {
15153
+ const neighbors = (neighborMap[nodeId] || []).filter(n => orderMap[n] !== undefined);
15154
+ if (neighbors.length === 0) return Infinity;
15155
+ return neighbors.reduce((sum, n) => sum + orderMap[n], 0) / neighbors.length;
13719
15156
  }
13720
15157
 
13721
- // ── DAG Layout + SVG Rendering ──
13722
15158
  async function wfRenderWorkflow(definition) {
13723
15159
  const svg = document.getElementById('wf-canvas');
13724
15160
  // Clear previous nodes and edges (keep defs)
@@ -13747,23 +15183,8 @@ async function wfRenderWorkflow(definition) {
13747
15183
  const stepMap = {};
13748
15184
  definition.steps.forEach(s => { stepMap[s.id] = s; });
13749
15185
 
13750
- // Calculate positions
13751
- const positions = {};
13752
- const maxLayerSize = Math.max(...layers.map(l => l.length));
13753
- const totalW = layers.length * WF_LAYER_GAP;
13754
- const totalH = maxLayerSize * (WF_NODE_H + WF_NODE_GAP);
13755
-
13756
- layers.forEach((layer, li) => {
13757
- const x = WF_PAD + li * WF_LAYER_GAP;
13758
- const layerH = layer.length * WF_NODE_H + (layer.length - 1) * WF_NODE_GAP;
13759
- const startY = WF_PAD + (totalH - layerH) / 2;
13760
- layer.forEach((stepId, ni) => {
13761
- positions[stepId] = {
13762
- x,
13763
- y: startY + ni * (WF_NODE_H + WF_NODE_GAP),
13764
- };
13765
- });
13766
- });
15186
+ // Auto-layout: crossing minimization + neighbor-aware placement
15187
+ const positions = wfAutoLayout(layers, graph);
13767
15188
  wfState.nodePositions = positions;
13768
15189
 
13769
15190
  // Build port-visibility maps: which nodes have input deps, which have dependents
@@ -14388,18 +15809,27 @@ function wfUpdateInspector() {
14388
15809
 
14389
15810
  // ── OUTPUT Accordion ──
14390
15811
  const outputExpanded = hasDone || accStates.output === true;
15812
+ const fmtSelectorHtml = hasDone ? `<select class="wf-output-format-select" onchange="wfChangeOutputFormat(this.value)" onclick="event.stopPropagation()">
15813
+ <option value="auto"${wfState.outputFormat === 'auto' ? ' selected' : ''}>Auto</option>
15814
+ <option value="json"${wfState.outputFormat === 'json' ? ' selected' : ''}>JSON</option>
15815
+ <option value="table"${wfState.outputFormat === 'table' ? ' selected' : ''}>Table</option>
15816
+ <option value="markdown"${wfState.outputFormat === 'markdown' ? ' selected' : ''}>Markdown</option>
15817
+ <option value="text"${wfState.outputFormat === 'text' ? ' selected' : ''}>Text</option>
15818
+ </select>` : '';
14391
15819
  html += `<div class="wf-accordion-header ${outputExpanded ? 'expanded' : ''}" onclick="wfToggleAccordion('output', this)">
14392
15820
  <span class="wf-acc-left"><span class="wf-chevron">&#9654;</span> OUTPUT</span>
14393
- <span class="wf-acc-summary"></span>
15821
+ <span class="wf-acc-summary">${fmtSelectorHtml}</span>
14394
15822
  </div>
14395
15823
  <div class="wf-accordion-body" data-acc="output" style="max-height:${outputExpanded ? 'none' : '0px'};">
14396
- <div class="wf-accordion-body-inner">`;
15824
+ <div class="wf-accordion-body-inner" id="wfOutputContent">`;
14397
15825
  if (hasDone) {
14398
15826
  const r = wfState.executionResults._done;
14399
- const doneJson = JSON.stringify(r.output, null, 2);
14400
- html += `<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">Completed in ${r.totalTimeMs}ms</div>
14401
- <div class="wf-inspector-code" style="max-height:200px;overflow:auto;color:#40E0FF;">${escapeHtml(doneJson)}</div>
14402
- <button class="wf-output-expand-btn" data-expand-step="_done">&#x2922; Expand</button>`;
15827
+ const hints = r.formatters || {};
15828
+ const fmt = wfState.outputFormat || 'auto';
15829
+ const effectiveFmt = fmt === 'auto' ? wfAutoFormat(r.output) : fmt;
15830
+ html += `<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">Completed in ${r.totalTimeMs}ms</div>`;
15831
+ html += wfRenderOutputAs(r.output, effectiveFmt, hints);
15832
+ html += `<button class="wf-output-expand-btn" data-expand-step="_done">&#x2922; Expand</button>`;
14403
15833
  } else if (def.output) {
14404
15834
  html += `<div class="wf-inspector-code" style="color:#40E0FF;">${escapeHtml(JSON.stringify(def.output, null, 2))}</div>`;
14405
15835
  } else {
@@ -14531,11 +15961,23 @@ function wfUpdateInspector() {
14531
15961
  if (state === 'completed' && result) {
14532
15962
  const outputJson = result.output ? JSON.stringify(result.output, null, 2) : '';
14533
15963
  const stepTitle = step.name || step.id;
15964
+ const stepShape = result.output ? wfDetectOutputShape(result.output) : null;
15965
+ const showStepFmtToggle = stepShape && (stepShape.type === 'array' || stepShape.type === 'comparison');
15966
+ const stepFmtToggle = showStepFmtToggle ?
15967
+ `<div style="margin-bottom:4px;"><select class="wf-output-format-select" style="width:auto;margin:0;" onchange="wfChangeStepFormat('${escapeHtml(step.id)}', this.value)">
15968
+ <option value="json">JSON</option>
15969
+ <option value="table" selected>Table</option>
15970
+ <option value="text">Text</option>
15971
+ </select></div>` : '';
15972
+ const stepResultContent = showStepFmtToggle
15973
+ ? wfRenderOutputAs(result.output, 'table', {})
15974
+ : (outputJson ? '<div class="wf-inspector-code" style="max-height:120px;overflow:auto;">' + escapeHtml(outputJson) + '</div>' : '');
14534
15975
  html += `<div class="wf-inspector-section">
14535
15976
  <div class="wf-inspector-section-title">Result</div>
14536
15977
  <div class="wf-inspector-result success">
14537
15978
  <div style="font-size:11px;color:var(--text-muted);margin-bottom:4px;">${result.timeMs}ms${result.summary ? ', ' + result.summary : ''}</div>
14538
- ${outputJson ? '<div class="wf-inspector-code" style="max-height:120px;overflow:auto;">' + escapeHtml(outputJson) + '</div>' : ''}
15979
+ ${stepFmtToggle}
15980
+ <div id="wf-step-result-${escapeHtml(step.id)}">${stepResultContent}</div>
14539
15981
  </div>
14540
15982
  ${outputJson ? '<button class="wf-output-expand-btn" data-expand-step="' + escapeHtml(step.id) + '">&#x2922; Expand</button>' : ''}
14541
15983
  </div>`;
@@ -14571,17 +16013,274 @@ function wfBindExpandButtons(container) {
14571
16013
  if (stepId === '_done') {
14572
16014
  title = 'Workflow Output';
14573
16015
  content = JSON.stringify(result.output, null, 2);
16016
+ // Rich modal rendering for workflow output
16017
+ const hints = result.formatters || {};
16018
+ const fmt = wfState.outputFormat || hints.default || 'auto';
16019
+ const effectiveFmt = fmt === 'auto' ? wfAutoFormat(result.output) : fmt;
16020
+ wfOpenOutputModal(title, content, effectiveFmt, result.output, hints);
16021
+ return;
14574
16022
  } else {
14575
16023
  const def = wfState.activeWorkflow;
14576
16024
  const step = def ? def.steps.find(s => s.id === stepId) : null;
14577
16025
  title = (step ? step.name || step.id : stepId) + ' Output';
14578
16026
  content = JSON.stringify(result.output, null, 2);
16027
+ // Rich modal for step outputs with array/comparison shapes
16028
+ const stepShape = result.output ? wfDetectOutputShape(result.output) : null;
16029
+ if (stepShape && (stepShape.type === 'array' || stepShape.type === 'comparison')) {
16030
+ wfOpenOutputModal(title, content, 'table', result.output, {});
16031
+ return;
16032
+ }
14579
16033
  }
14580
16034
  wfOpenOutputModal(title, content);
14581
16035
  });
14582
16036
  });
14583
16037
  }
14584
16038
 
16039
+ // ── Output Format Rendering ──
16040
+
16041
+ function wfStringify(val) {
16042
+ if (val == null) return '';
16043
+ if (typeof val === 'number') return val % 1 === 0 ? String(val) : val.toFixed(4);
16044
+ if (typeof val === 'boolean') return val ? 'true' : 'false';
16045
+ if (typeof val === 'object') {
16046
+ const s = JSON.stringify(val);
16047
+ return s.length > 80 ? s.slice(0, 77) + '...' : s;
16048
+ }
16049
+ return String(val);
16050
+ }
16051
+
16052
+ function wfDetectOutputShape(output) {
16053
+ if (output == null || typeof output !== 'object') return { type: 'scalar', value: output };
16054
+ const keys = Object.keys(output);
16055
+ for (const key of keys) {
16056
+ const val = output[key];
16057
+ if (Array.isArray(val) && val.length > 0 && typeof val[0] === 'object' && val[0] !== null) {
16058
+ return { type: 'array', arrayKey: key, columns: Object.keys(val[0]), totalRows: val.length };
16059
+ }
16060
+ }
16061
+ const objKeys = keys.filter(k => output[k] != null && typeof output[k] === 'object' && !Array.isArray(output[k]));
16062
+ if (objKeys.length >= 2) {
16063
+ return { type: 'comparison', objectKeys: objKeys, metricKeys: keys.filter(k => !objKeys.includes(k)) };
16064
+ }
16065
+ const textKeys = keys.filter(k => typeof output[k] === 'string' && output[k].length > 100);
16066
+ if (textKeys.length > 0) {
16067
+ return { type: 'text', textKeys, metricKeys: keys.filter(k => !textKeys.includes(k)) };
16068
+ }
16069
+ return { type: 'metrics', keys };
16070
+ }
16071
+
16072
+ function wfAutoFormat(output) {
16073
+ const shape = wfDetectOutputShape(output);
16074
+ if (shape.type === 'array' || shape.type === 'comparison') return 'table';
16075
+ return 'text';
16076
+ }
16077
+
16078
+ function wfRenderOutputAs(output, format, hints) {
16079
+ hints = hints || {};
16080
+ if (!output) return '<div class="wf-inspector-code" style="color:#40E0FF;">(no output)</div>';
16081
+ switch (format) {
16082
+ case 'json':
16083
+ return '<div class="wf-inspector-code" style="max-height:200px;overflow:auto;color:#40E0FF;">' + escapeHtml(JSON.stringify(output, null, 2)) + '</div>';
16084
+ case 'table':
16085
+ return wfRenderTable(output, hints);
16086
+ case 'markdown':
16087
+ return '<div class="wf-output-markdown">' + renderMarkdown(wfOutputToMarkdown(output, hints)) + '</div>';
16088
+ case 'text':
16089
+ return wfRenderText(output, hints);
16090
+ default:
16091
+ return '<div class="wf-inspector-code" style="max-height:200px;overflow:auto;color:#40E0FF;">' + escapeHtml(JSON.stringify(output, null, 2)) + '</div>';
16092
+ }
16093
+ }
16094
+
16095
+ function wfRenderTable(output, hints) {
16096
+ const shape = wfDetectOutputShape(output);
16097
+ let html = '';
16098
+
16099
+ if (shape.type === 'array') {
16100
+ const key = hints.arrayField || shape.arrayKey;
16101
+ const data = output[key] || [];
16102
+ if (data.length === 0) return '<div style="color:var(--text-muted);font-size:12px;">(empty results)</div>';
16103
+ const columns = hints.columns || shape.columns;
16104
+
16105
+ // Show non-array metrics above
16106
+ const metricKeys = Object.keys(output).filter(k => k !== key && !Array.isArray(output[k]));
16107
+ if (metricKeys.length > 0) {
16108
+ html += '<div style="margin-bottom:8px;">';
16109
+ metricKeys.forEach(k => {
16110
+ html += '<div class="wf-output-metric"><div class="wf-output-text-label">' + escapeHtml(k) + '</div><div class="wf-output-metric-val">' + escapeHtml(wfStringify(output[k])) + '</div></div>';
16111
+ });
16112
+ html += '</div>';
16113
+ }
16114
+
16115
+ html += '<table class="wf-output-table"><thead><tr>';
16116
+ columns.forEach(c => { html += '<th>' + escapeHtml(c) + '</th>'; });
16117
+ html += '</tr></thead><tbody>';
16118
+ data.forEach(row => {
16119
+ html += '<tr>';
16120
+ columns.forEach(c => { html += '<td>' + escapeHtml(wfStringify(row[c])) + '</td>'; });
16121
+ html += '</tr>';
16122
+ });
16123
+ html += '</tbody></table>';
16124
+ return html;
16125
+ }
16126
+
16127
+ if (shape.type === 'comparison') {
16128
+ const keys = shape.objectKeys;
16129
+ const allFields = new Set();
16130
+ keys.forEach(k => { if (output[k]) Object.keys(output[k]).forEach(f => allFields.add(f)); });
16131
+
16132
+ // Show non-object metrics above
16133
+ const metricKeys = (shape.metricKeys || []).filter(k => output[k] != null);
16134
+ if (metricKeys.length > 0) {
16135
+ html += '<div style="margin-bottom:8px;">';
16136
+ metricKeys.forEach(k => {
16137
+ html += '<div class="wf-output-metric"><div class="wf-output-text-label">' + escapeHtml(k) + '</div><div class="wf-output-metric-val">' + escapeHtml(wfStringify(output[k])) + '</div></div>';
16138
+ });
16139
+ html += '</div>';
16140
+ }
16141
+
16142
+ html += '<table class="wf-output-table"><thead><tr><th></th>';
16143
+ keys.forEach(k => { html += '<th>' + escapeHtml(String(k)) + '</th>'; });
16144
+ html += '</tr></thead><tbody>';
16145
+ allFields.forEach(f => {
16146
+ html += '<tr><td style="font-weight:600;">' + escapeHtml(f) + '</td>';
16147
+ keys.forEach(k => { html += '<td>' + escapeHtml(wfStringify(output[k] && output[k][f])) + '</td>'; });
16148
+ html += '</tr>';
16149
+ });
16150
+ html += '</tbody></table>';
16151
+ return html;
16152
+ }
16153
+
16154
+ // Fallback: key-value table
16155
+ html += '<table class="wf-output-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
16156
+ Object.entries(output).forEach(function(entry) {
16157
+ html += '<tr><td>' + escapeHtml(entry[0]) + '</td><td>' + escapeHtml(wfStringify(entry[1])) + '</td></tr>';
16158
+ });
16159
+ html += '</tbody></table>';
16160
+ return html;
16161
+ }
16162
+
16163
+ function wfRenderText(output, hints) {
16164
+ const shape = wfDetectOutputShape(output);
16165
+ if (shape.type === 'scalar') return '<div style="font-size:12px;color:var(--text);">' + escapeHtml(String(output || '')) + '</div>';
16166
+ let html = '';
16167
+ if (hints.title) {
16168
+ html += '<div style="font-weight:600;font-size:13px;color:var(--accent);margin-bottom:8px;">' + escapeHtml(hints.title) + '</div>';
16169
+ }
16170
+
16171
+ // Metrics
16172
+ const metricKeys = Object.keys(output).filter(k =>
16173
+ output[k] != null && (typeof output[k] !== 'object' || typeof output[k] === 'boolean') &&
16174
+ (typeof output[k] !== 'string' || output[k].length <= 100)
16175
+ );
16176
+ if (metricKeys.length > 0) {
16177
+ html += '<div style="margin-bottom:10px;">';
16178
+ metricKeys.forEach(k => {
16179
+ html += '<div class="wf-output-metric"><div class="wf-output-text-label">' + escapeHtml(k) + '</div><div class="wf-output-metric-val">' + escapeHtml(wfStringify(output[k])) + '</div></div>';
16180
+ });
16181
+ html += '</div>';
16182
+ }
16183
+
16184
+ // Object fields (comparison-like)
16185
+ Object.keys(output).forEach(k => {
16186
+ const v = output[k];
16187
+ if (v != null && typeof v === 'object' && !Array.isArray(v)) {
16188
+ html += '<div class="wf-output-text-field"><div class="wf-output-text-label">' + escapeHtml(k) + '</div>';
16189
+ Object.entries(v).forEach(function(entry) {
16190
+ html += '<div style="font-size:12px;margin-left:8px;"><span style="color:var(--text-muted);">' + escapeHtml(entry[0]) + ':</span> <span style="color:#40E0FF;">' + escapeHtml(wfStringify(entry[1])) + '</span></div>';
16191
+ });
16192
+ html += '</div>';
16193
+ }
16194
+ });
16195
+
16196
+ // Text fields
16197
+ Object.keys(output).forEach(k => {
16198
+ if (typeof output[k] === 'string' && output[k].length > 100) {
16199
+ html += '<div class="wf-output-text-field"><div class="wf-output-text-label">' + escapeHtml(k) + '</div><div class="wf-output-text-value">' + escapeHtml(output[k]) + '</div></div>';
16200
+ }
16201
+ });
16202
+
16203
+ // Arrays
16204
+ Object.keys(output).forEach(k => {
16205
+ if (Array.isArray(output[k])) {
16206
+ html += '<div class="wf-output-text-field"><div class="wf-output-text-label">' + escapeHtml(k) + ' (' + output[k].length + ' items)</div>';
16207
+ html += '<div class="wf-inspector-code" style="max-height:100px;overflow:auto;">' + escapeHtml(JSON.stringify(output[k], null, 2)) + '</div></div>';
16208
+ }
16209
+ });
16210
+
16211
+ return html || '<div class="wf-inspector-code" style="color:#40E0FF;">' + escapeHtml(JSON.stringify(output, null, 2)) + '</div>';
16212
+ }
16213
+
16214
+ function wfOutputToMarkdown(output, hints) {
16215
+ let md = '';
16216
+ if (hints.title) md += '## ' + hints.title + '\n\n';
16217
+ else md += '## Workflow Output\n\n';
16218
+ Object.entries(output).forEach(function(entry) {
16219
+ const k = entry[0], v = entry[1];
16220
+ if (v == null) return;
16221
+ if (typeof v !== 'object' || typeof v === 'boolean') {
16222
+ if (typeof v !== 'string' || v.length <= 100) {
16223
+ md += '- **' + k + ':** ' + wfStringify(v) + '\n';
16224
+ }
16225
+ }
16226
+ });
16227
+ md += '\n';
16228
+ // Arrays as tables
16229
+ Object.entries(output).forEach(function(entry) {
16230
+ const k = entry[0], v = entry[1];
16231
+ if (!Array.isArray(v) || v.length === 0 || typeof v[0] !== 'object') return;
16232
+ const cols = hints.columns || Object.keys(v[0]);
16233
+ md += '### ' + k + '\n\n| ' + cols.join(' | ') + ' |\n| ' + cols.map(function() { return '---'; }).join(' | ') + ' |\n';
16234
+ v.forEach(function(row) { md += '| ' + cols.map(function(c) { return wfStringify(row[c]); }).join(' | ') + ' |\n'; });
16235
+ md += '\n';
16236
+ });
16237
+ // Comparison objects
16238
+ const objKeys = Object.keys(output).filter(function(k) { return output[k] != null && typeof output[k] === 'object' && !Array.isArray(output[k]); });
16239
+ if (objKeys.length >= 2) {
16240
+ const allFields = new Set();
16241
+ objKeys.forEach(function(k) { Object.keys(output[k]).forEach(function(f) { allFields.add(f); }); });
16242
+ md += '| | ' + objKeys.join(' | ') + ' |\n| --- | ' + objKeys.map(function() { return '---'; }).join(' | ') + ' |\n';
16243
+ allFields.forEach(function(f) {
16244
+ md += '| **' + f + '** | ' + objKeys.map(function(k) { return wfStringify(output[k][f]); }).join(' | ') + ' |\n';
16245
+ });
16246
+ md += '\n';
16247
+ }
16248
+ // Long text
16249
+ Object.entries(output).forEach(function(entry) {
16250
+ if (typeof entry[1] === 'string' && entry[1].length > 100) {
16251
+ md += '### ' + entry[0] + '\n\n' + entry[1] + '\n\n';
16252
+ }
16253
+ });
16254
+ return md;
16255
+ }
16256
+
16257
+ function wfChangeOutputFormat(format) {
16258
+ wfState.outputFormat = format;
16259
+ const r = wfState.executionResults._done;
16260
+ if (!r) return;
16261
+ const hints = r.formatters || {};
16262
+ const effectiveFormat = format === 'auto' ? wfAutoFormat(r.output) : format;
16263
+ const container = document.getElementById('wfOutputContent');
16264
+ if (!container) return;
16265
+ let html = '<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">Completed in ' + r.totalTimeMs + 'ms</div>';
16266
+ html += wfRenderOutputAs(r.output, effectiveFormat, hints);
16267
+ html += '<button class="wf-output-expand-btn" data-expand-step="_done">&#x2922; Expand</button>';
16268
+ container.innerHTML = html;
16269
+ wfBindExpandButtons(container);
16270
+ }
16271
+
16272
+ function wfChangeStepFormat(stepId, format) {
16273
+ const result = wfState.executionResults[stepId];
16274
+ if (!result || !result.output) return;
16275
+ const container = document.getElementById('wf-step-result-' + stepId);
16276
+ if (!container) return;
16277
+ if (format === 'json') {
16278
+ container.innerHTML = '<div class="wf-inspector-code" style="max-height:120px;overflow:auto;">' + escapeHtml(JSON.stringify(result.output, null, 2)) + '</div>';
16279
+ } else {
16280
+ container.innerHTML = wfRenderOutputAs(result.output, format, {});
16281
+ }
16282
+ }
16283
+
14585
16284
  // escapeHtml is already defined globally — reuse it
14586
16285
 
14587
16286
  // ── Toolbar helpers ──
@@ -14783,6 +16482,31 @@ function wfStopExecution(reason) {
14783
16482
  wfUpdateInspector();
14784
16483
  }
14785
16484
 
16485
+ // ── Workflow Input Cache (localStorage) ──
16486
+
16487
+ function wfSlugify(name) {
16488
+ return String(name).toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
16489
+ }
16490
+
16491
+ function wfGetInputCache(workflowName) {
16492
+ try {
16493
+ const slug = wfSlugify(workflowName);
16494
+ if (!slug) return {};
16495
+ const raw = localStorage.getItem('vai-workflow-inputs-' + slug);
16496
+ if (!raw) return {};
16497
+ const parsed = JSON.parse(raw);
16498
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
16499
+ } catch { return {}; }
16500
+ }
16501
+
16502
+ function wfSaveInputCache(workflowName, inputs) {
16503
+ try {
16504
+ const slug = wfSlugify(workflowName);
16505
+ if (!slug || !inputs) return;
16506
+ localStorage.setItem('vai-workflow-inputs-' + slug, JSON.stringify(inputs));
16507
+ } catch { /* localStorage may be full or disabled */ }
16508
+ }
16509
+
14786
16510
  // ── Input Modal (pre-execution) ──
14787
16511
 
14788
16512
  function wfShowInputModal() {
@@ -14792,13 +16516,15 @@ function wfShowInputModal() {
14792
16516
  if (entries.length === 0) { wfExecuteWithInputs({}); return; }
14793
16517
 
14794
16518
  document.getElementById('wfInputModalTitle').textContent = (def.name || 'Workflow') + ' Inputs';
16519
+ const cached = wfGetInputCache(def.name || '');
14795
16520
  let html = '';
14796
16521
  for (const [key, spec] of entries) {
14797
16522
  const req = spec.required ? ' <span style="color:#e74c3c">*</span>' : '';
14798
16523
  const desc = spec.description ? `<div class="wf-input-modal-desc">${escapeHtml(spec.description)}</div>` : '';
14799
- // Pre-fill from inspector fields if available, then from defaults
16524
+ // Pre-fill priority: 1) inspector field, 2) cached from last run, 3) spec default
14800
16525
  const inspectorEl = document.getElementById('wf-input-' + key);
14801
16526
  let prefill = inspectorEl ? inspectorEl.value : '';
16527
+ if (!prefill && key in cached) prefill = String(cached[key]);
14802
16528
  if (!prefill && spec.default !== undefined) prefill = String(spec.default);
14803
16529
  const placeholder = spec.type === 'number' ? 'number' : (spec.type || 'string');
14804
16530
  html += `<div class="wf-input-modal-field">
@@ -14869,6 +16595,9 @@ function wfInputModalSubmit() {
14869
16595
 
14870
16596
  if (hasError) return;
14871
16597
 
16598
+ // Cache inputs for next run
16599
+ wfSaveInputCache(def.name || '', inputs);
16600
+
14872
16601
  // Also update inspector fields to keep them in sync
14873
16602
  for (const [key, val] of Object.entries(inputs)) {
14874
16603
  const inspEl = document.getElementById('wf-input-' + key);
@@ -14897,10 +16626,131 @@ async function wfExecute() {
14897
16626
  return;
14898
16627
  }
14899
16628
 
16629
+ // Check for empty required step inputs and prompt the user
16630
+ const missing = wfFindMissingStepInputs(def);
16631
+ if (missing.length > 0) {
16632
+ wfShowStepInputModal(missing);
16633
+ return;
16634
+ }
16635
+
14900
16636
  // No inputs needed, execute directly
14901
16637
  wfExecuteWithInputs({});
14902
16638
  }
14903
16639
 
16640
+ /**
16641
+ * Scan all steps for required inputs that are empty (and not filled by template refs).
16642
+ * Returns array of { stepId, stepName, tool, key, placeholder, type } for each missing input.
16643
+ */
16644
+ function wfFindMissingStepInputs(def) {
16645
+ const missing = [];
16646
+ for (const step of (def.steps || [])) {
16647
+ const inputDefs = WF_INPUT_DEFS[step.tool] || [];
16648
+ for (const d of inputDefs) {
16649
+ if (!d.required) continue;
16650
+ const val = step.inputs?.[d.key];
16651
+ // Skip if already filled (non-empty string, or any non-string value)
16652
+ if (val !== undefined && val !== null && val !== '') continue;
16653
+ missing.push({
16654
+ stepId: step.id,
16655
+ stepName: step.name || step.id,
16656
+ tool: step.tool,
16657
+ key: d.key,
16658
+ placeholder: d.placeholder || '',
16659
+ type: d.type || 'text',
16660
+ });
16661
+ }
16662
+ }
16663
+ return missing;
16664
+ }
16665
+
16666
+ /**
16667
+ * Show a modal prompting the user for missing required step inputs before execution.
16668
+ * Reuses the existing input modal UI.
16669
+ */
16670
+ function wfShowStepInputModal(missing) {
16671
+ document.getElementById('wfInputModalTitle').textContent = 'Required Inputs';
16672
+ let html = '';
16673
+ let lastStepId = '';
16674
+
16675
+ for (const m of missing) {
16676
+ // Group header per step
16677
+ if (m.stepId !== lastStepId) {
16678
+ const meta = WF_NODE_META[m.tool] || {};
16679
+ html += `<div style="margin-top:${lastStepId ? '16' : '0'}px;margin-bottom:8px;font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);display:flex;align-items:center;gap:6px;">
16680
+ <span style="color:${meta.color || 'var(--text-muted)'}">
16681
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${meta.icon || WF_FALLBACK_ICON}"/></svg>
16682
+ </span>
16683
+ ${escapeHtml(m.stepName)}
16684
+ </div>`;
16685
+ lastStepId = m.stepId;
16686
+ }
16687
+
16688
+ const inputId = 'wf-stepin-' + m.stepId + '-' + m.key;
16689
+ html += `<div class="wf-input-modal-field">
16690
+ <div class="wf-input-modal-label">${escapeHtml(m.key)} <span style="color:#e74c3c">*</span></div>
16691
+ <input class="wf-input-modal-input" id="${inputId}" placeholder="${escapeHtml(m.placeholder)}" data-step-id="${escapeHtml(m.stepId)}" data-key="${escapeHtml(m.key)}" data-type="${m.type}">
16692
+ <div class="wf-input-modal-error" id="${inputId}-err">This field is required</div>
16693
+ </div>`;
16694
+ }
16695
+
16696
+ document.getElementById('wfInputModalBody').innerHTML = html;
16697
+
16698
+ // Swap the footer button handler temporarily
16699
+ const runBtn = document.querySelector('.wf-input-modal-run');
16700
+ runBtn.textContent = 'Run Workflow';
16701
+ runBtn.onclick = () => wfStepInputModalSubmit(missing);
16702
+
16703
+ document.getElementById('wfInputModalBackdrop').style.display = '';
16704
+
16705
+ // Focus first input
16706
+ const firstId = 'wf-stepin-' + missing[0].stepId + '-' + missing[0].key;
16707
+ const firstEl = document.getElementById(firstId);
16708
+ if (firstEl) setTimeout(() => firstEl.focus(), 50);
16709
+ }
16710
+
16711
+ function wfStepInputModalSubmit(missing) {
16712
+ let hasError = false;
16713
+
16714
+ for (const m of missing) {
16715
+ const inputId = 'wf-stepin-' + m.stepId + '-' + m.key;
16716
+ const el = document.getElementById(inputId);
16717
+ const errEl = document.getElementById(inputId + '-err');
16718
+ if (!el) continue;
16719
+
16720
+ el.classList.remove('error');
16721
+ if (errEl) errEl.style.display = 'none';
16722
+
16723
+ const val = el.value.trim();
16724
+ if (!val) {
16725
+ el.classList.add('error');
16726
+ if (errEl) { errEl.textContent = 'This field is required'; errEl.style.display = ''; }
16727
+ hasError = true;
16728
+ continue;
16729
+ }
16730
+
16731
+ // Write the value into the step definition
16732
+ const def = wfState.activeWorkflow;
16733
+ const step = def.steps.find(s => s.id === m.stepId);
16734
+ if (step) {
16735
+ if (!step.inputs) step.inputs = {};
16736
+ step.inputs[m.key] = val;
16737
+ }
16738
+ }
16739
+
16740
+ if (hasError) return;
16741
+
16742
+ // Restore the default modal handler
16743
+ const runBtn = document.querySelector('.wf-input-modal-run');
16744
+ runBtn.onclick = () => wfInputModalSubmit();
16745
+
16746
+ wfCloseInputModal();
16747
+
16748
+ // Refresh inspector if a step is selected
16749
+ if (wfState.selectedNodeId) wfUpdateInspector();
16750
+
16751
+ wfExecuteWithInputs({});
16752
+ }
16753
+
14904
16754
  async function wfExecuteWithInputs(inputs) {
14905
16755
  const def = wfState.activeWorkflow;
14906
16756
  if (!def || wfState.executing) return;
@@ -14972,6 +16822,16 @@ async function wfExecuteWithInputs(inputs) {
14972
16822
  timeMs: data.timeMs,
14973
16823
  summary: data.summary || '',
14974
16824
  };
16825
+ // Update cost tracker with usage data from this step
16826
+ if (data._usage && Array.isArray(data._usage)) {
16827
+ data._usage.forEach(u => {
16828
+ if (u.op === 'llm') {
16829
+ CostTracker.addLLMOperation('wf-' + u.op, u.model, u.inputTokens || 0, u.outputTokens || 0);
16830
+ } else {
16831
+ CostTracker.addOperation('wf-' + u.op, u.model, u.tokens || 0);
16832
+ }
16833
+ });
16834
+ }
14975
16835
  wfRefreshNodes();
14976
16836
  if (wfState.selectedNodeId === data.stepId) wfUpdateInspector();
14977
16837
  } else if (currentEvent === 'step_skip') {
@@ -14988,6 +16848,7 @@ async function wfExecuteWithInputs(inputs) {
14988
16848
  if (wfState.selectedNodeId === data.stepId) wfUpdateInspector();
14989
16849
  } else if (currentEvent === 'done') {
14990
16850
  wfState.executionResults._done = data;
16851
+ wfState.outputFormat = 'auto';
14991
16852
  if (!wfState.selectedNodeId) wfUpdateInspector();
14992
16853
  } else if (currentEvent === 'error') {
14993
16854
  hasError = true;
@@ -15074,10 +16935,49 @@ function wfResetExecution() {
15074
16935
  wfUpdateInspector();
15075
16936
  }
15076
16937
 
16938
+ async function wfRelayout() {
16939
+ const def = wfState.activeWorkflow;
16940
+ if (!def) return;
16941
+
16942
+ // If layers/graph aren't populated yet (e.g. builder mode), fetch them
16943
+ if (!wfState.layers || !wfState.graph || wfState.layers.length === 0) {
16944
+ try {
16945
+ const res = await fetch('/api/workflows/plan', {
16946
+ method: 'POST',
16947
+ headers: { 'Content-Type': 'application/json' },
16948
+ body: JSON.stringify({ definition: def }),
16949
+ });
16950
+ const data = await res.json();
16951
+ if (data.layers) wfState.layers = data.layers;
16952
+ if (data.graph) wfState.graph = data.graph;
16953
+ } catch (err) {
16954
+ console.warn('Relayout: failed to fetch plan:', err.message);
16955
+ return;
16956
+ }
16957
+ }
16958
+
16959
+ if (!wfState.layers || wfState.layers.length === 0) return;
16960
+
16961
+ const positions = wfAutoLayout(wfState.layers, wfState.graph || {});
16962
+
16963
+ // Preserve positions for orphan nodes not in any layer (builder mode)
16964
+ if (def.steps) {
16965
+ for (const step of def.steps) {
16966
+ if (!positions[step.id] && wfState.nodePositions[step.id]) {
16967
+ positions[step.id] = wfState.nodePositions[step.id];
16968
+ }
16969
+ }
16970
+ }
16971
+
16972
+ wfState.nodePositions = positions;
16973
+ wfRefreshNodes();
16974
+ wfFitToView();
16975
+ }
16976
+
15077
16977
  // ── Output Modal ──
15078
16978
  let wfOutputModalData = '';
15079
16979
 
15080
- function wfOpenOutputModal(title, content) {
16980
+ function wfOpenOutputModal(title, content, format, outputObj, hints) {
15081
16981
  wfOutputModalData = content;
15082
16982
  const backdrop = document.getElementById('wfOutputModalBackdrop');
15083
16983
  const titleEl = document.getElementById('wfOutputModalTitle');
@@ -15085,8 +16985,14 @@ function wfOpenOutputModal(title, content) {
15085
16985
  const copyLabel = document.getElementById('wfOutputCopyLabel');
15086
16986
  if (!backdrop || !bodyEl) return;
15087
16987
  titleEl.textContent = title || 'Output';
15088
- bodyEl.textContent = content;
15089
16988
  copyLabel.textContent = 'Copy';
16989
+
16990
+ // Rich rendering if format + output object provided
16991
+ if (format && outputObj && format !== 'json') {
16992
+ bodyEl.innerHTML = wfRenderOutputAs(outputObj, format, hints || {});
16993
+ } else {
16994
+ bodyEl.textContent = content;
16995
+ }
15090
16996
  backdrop.style.display = 'flex';
15091
16997
  }
15092
16998
 
@@ -15325,7 +17231,7 @@ const WF_INPUT_DEFS = {
15325
17231
  search: [{ key: 'query', type: 'text', required: true, placeholder: 'Search query' }, { key: 'collection', type: 'text', required: false }, { key: 'db', type: 'text', required: false }, { key: 'limit', type: 'number', required: false, placeholder: '10' }, { key: 'filter', type: 'json', required: false, placeholder: '{}' }],
15326
17232
  rerank: [{ key: 'query', type: 'text', required: true }, { key: 'documents', type: 'json', required: true, placeholder: '["doc1","doc2"]' }, { key: 'model', type: 'text', required: false, placeholder: 'rerank-2.5' }],
15327
17233
  ingest: [{ key: 'text', type: 'textarea', required: true }, { key: 'collection', type: 'text', required: false }, { key: 'db', type: 'text', required: false }, { key: 'source', type: 'text', required: false }, { key: 'chunkSize', type: 'number', required: false, placeholder: '512' }, { key: 'chunkStrategy', type: 'select', required: false, options: ['fixed','sentence','paragraph','recursive','markdown'] }],
15328
- embed: [{ key: 'text', type: 'text', required: true, placeholder: 'Text to embed' }, { key: 'model', type: 'text', required: false, placeholder: 'voyage-3-large' }, { key: 'inputType', type: 'select', required: false, options: ['document','query'] }],
17234
+ embed: [{ key: 'text', type: 'text', required: true, placeholder: 'Text to embed' }, { key: 'model', type: 'text', required: false, placeholder: 'voyage-4-large' }, { key: 'inputType', type: 'select', required: false, options: ['document','query'] }],
15329
17235
  similarity: [{ key: 'text1', type: 'text', required: true }, { key: 'text2', type: 'text', required: true }, { key: 'model', type: 'text', required: false }],
15330
17236
  collections: [{ key: 'db', type: 'text', required: false }],
15331
17237
  models: [{ key: 'category', type: 'select', required: false, options: ['embedding','rerank','all'] }],
@@ -16111,10 +18017,9 @@ function wfEdgeDropOnInput(toId) {
16111
18017
  if (connected) {
16112
18018
  wfState.dirtyFlag = true;
16113
18019
  wfBuildGraph();
16114
- wfRelayout();
16115
18020
  wfRefreshNodes();
16116
18021
  if (wfState.selectedNodeId === toId) wfUpdateInspector();
16117
-
18022
+
16118
18023
  // Trigger draft validation immediately on edge connection
16119
18024
  wfTriggerDraftValidation(0);
16120
18025
  }
@@ -16125,42 +18030,9 @@ function wfEdgeDropOnInput(toId) {
16125
18030
  if (el) el.remove();
16126
18031
  }
16127
18032
 
16128
- // ── Builder: Relayout via topological sort ──
16129
- async function wfRelayout() {
16130
- const def = wfState.activeWorkflow;
16131
- if (!def || def.steps.length === 0) return;
16132
-
16133
- try {
16134
- const res = await fetch('/api/workflows/plan', {
16135
- method: 'POST',
16136
- headers: { 'Content-Type': 'application/json' },
16137
- body: JSON.stringify(def),
16138
- });
16139
- const data = await res.json();
16140
- if (data.layers && data.layers.length > 0) {
16141
- wfState.layers = data.layers;
16142
- // Reposition nodes based on layers
16143
- const positions = {};
16144
- data.layers.forEach((layer, li) => {
16145
- layer.forEach((stepId, ni) => {
16146
- positions[stepId] = {
16147
- x: WF_PAD + li * WF_LAYER_GAP,
16148
- y: WF_PAD + ni * (WF_NODE_H + WF_NODE_GAP),
16149
- };
16150
- });
16151
- });
16152
- // Keep orphan nodes (not in any layer) at their current position
16153
- for (const step of def.steps) {
16154
- if (!positions[step.id] && wfState.nodePositions[step.id]) {
16155
- positions[step.id] = wfState.nodePositions[step.id];
16156
- }
16157
- }
16158
- wfState.nodePositions = positions;
16159
- }
16160
- } catch (err) {
16161
- console.warn('Relayout failed:', err.message);
16162
- }
16163
- }
18033
+ /// ── Builder: Relayout via auto-layout algorithm ──
18034
+ // (delegates to wfRelayout defined earlier, which uses wfAutoLayout for
18035
+ // barycenter crossing minimization + neighbor-aware placement)
16164
18036
 
16165
18037
  // ── Docs shortcut (F1) ──
16166
18038
  const DOCS_URLS = {
@@ -16212,6 +18084,7 @@ document.addEventListener('keydown', (e) => {
16212
18084
  if (e.key === '+' || e.key === '=') { wfZoom(1); e.preventDefault(); }
16213
18085
  else if (e.key === '-') { wfZoom(-1); e.preventDefault(); }
16214
18086
  else if (e.key === '0') { wfFitToView(); e.preventDefault(); }
18087
+ else if (e.key === 'l' || e.key === 'L') { wfRelayout(); e.preventDefault(); }
16215
18088
  else if (e.key === 'Escape') { wfDeselectNode(); e.preventDefault(); }
16216
18089
  else if (e.key === 'ArrowLeft') { wfState.panX -= PAN_STEP / wfState.zoom; wfApplyViewBox(); e.preventDefault(); }
16217
18090
  else if (e.key === 'ArrowRight') { wfState.panX += PAN_STEP / wfState.zoom; wfApplyViewBox(); e.preventDefault(); }
@@ -18184,6 +20057,7 @@ const staticCommandRegistry = [
18184
20057
  { id: 'nav-generate', label: 'Generate', description: 'Code generation and templates', category: 'navigation', keywords: ['code', 'template', 'scaffold'], icon: '💻', shortcut: '⌘6', priority: 1, execute: () => switchTab('generate') },
18185
20058
  { id: 'nav-chat', label: 'Chat', description: 'Chat with your knowledge base', category: 'navigation', keywords: ['conversation', 'ask', 'rag', 'talk'], icon: '💬', shortcut: '⌘7', priority: 1, execute: () => switchTab('chat') },
18186
20059
  { id: 'nav-workflows', label: 'Workflows', description: 'Visual workflow canvas', category: 'navigation', keywords: ['pipeline', 'dag', 'canvas'], icon: '🔄', shortcut: '⌘8', priority: 1, execute: () => switchTab('workflows') },
20060
+ { id: 'nav-models', label: 'Models', description: 'Voyage AI model catalog and specs', category: 'navigation', keywords: ['model', 'voyage', 'catalog', 'specs', 'embedding', 'rerank', 'pricing'], icon: '🤖', priority: 1, execute: () => switchTab('models') },
18187
20061
  { id: 'nav-benchmark', label: 'Benchmark', description: 'Model comparison on your data', category: 'navigation', keywords: ['performance', 'test', 'evaluate', 'benchmark'], icon: '📊', shortcut: '⌘9', priority: 1, execute: () => switchTab('benchmark') },
18188
20062
  { id: 'nav-explore', label: 'Explore', description: 'Embedding space visualization', category: 'navigation', keywords: ['pca', 'tsne', 'visualize', 'scatter'], icon: '🎯', shortcut: '⌘0', priority: 1, execute: () => switchTab('explore') },
18189
20063
  { id: 'nav-about', label: 'About', description: 'Version and project information', category: 'navigation', keywords: ['version', 'info', 'credits'], icon: 'ℹ️', priority: 5, execute: () => switchTab('about') },
@@ -18239,7 +20113,7 @@ const staticCommandRegistry = [
18239
20113
  { id: 'explain-reranking', label: 'Learn: Reranking', description: 'Two-stage retrieval with rerankers', category: 'explainer', keywords: ['reranking', 'two-stage', 'retrieval', 'precision'], icon: '📚', priority: 7, execute: () => switchTab('explore') },
18240
20114
  { id: 'explain-vector-search', label: 'Learn: Vector Search', description: 'MongoDB Atlas Vector Search', category: 'explainer', keywords: ['vector', 'search', 'atlas', 'similarity', 'knn'], icon: '📚', priority: 7, execute: () => switchTab('explore') },
18241
20115
  { id: 'explain-rag', label: 'Learn: RAG Pipelines', description: 'Retrieval-Augmented Generation', category: 'explainer', keywords: ['rag', 'retrieval', 'augmented', 'generation', 'pipeline'], icon: '📚', priority: 7, execute: () => switchTab('explore') },
18242
- { id: 'explain-models', label: 'Learn: Voyage AI Models', description: 'Choosing the right model', category: 'explainer', keywords: ['models', 'voyage-4', 'large', 'lite', 'domain'], icon: '📚', priority: 7, execute: () => switchTab('explore') },
20116
+ { id: 'explain-models', label: 'Learn: Voyage AI Models', description: 'Choosing the right model', category: 'explainer', keywords: ['models', 'voyage-4', 'large', 'lite', 'domain'], icon: '📚', priority: 7, execute: () => switchTab('models') },
18243
20117
  { id: 'explain-quantization', label: 'Learn: Quantization', description: 'Reduce storage costs with lower-precision embeddings', category: 'explainer', keywords: ['quantization', 'int8', 'ubinary', 'compression', 'storage'], icon: '📚', priority: 7, execute: () => switchTab('explore') }
18244
20118
  ];
18245
20119