voyageai-cli 1.24.0 → 1.26.1

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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +2 -0
  3. package/src/commands/about.js +1 -1
  4. package/src/commands/bug.js +1 -1
  5. package/src/commands/chat.js +281 -78
  6. package/src/commands/playground.js +73 -19
  7. package/src/commands/scaffold.js +23 -1
  8. package/src/commands/workflow.js +336 -0
  9. package/src/lib/chat.js +170 -4
  10. package/src/lib/explanations.js +53 -0
  11. package/src/lib/llm.js +304 -2
  12. package/src/lib/mongo.js +6 -6
  13. package/src/lib/prompt.js +60 -1
  14. package/src/lib/scaffold-structure.js +8 -9
  15. package/src/lib/telemetry.js +1 -1
  16. package/src/lib/template-engine.js +240 -0
  17. package/src/lib/templates/nextjs/README.md.tpl +78 -55
  18. package/src/lib/templates/nextjs/favicon.svg.tpl +11 -0
  19. package/src/lib/templates/nextjs/footer.jsx.tpl +49 -0
  20. package/src/lib/templates/nextjs/layout.jsx.tpl +16 -10
  21. package/src/lib/templates/nextjs/lib-mongo.js.tpl +5 -5
  22. package/src/lib/templates/nextjs/lib-voyage.js.tpl +13 -8
  23. package/src/lib/templates/nextjs/navbar.jsx.tpl +98 -0
  24. package/src/lib/templates/nextjs/page-home.jsx.tpl +201 -0
  25. package/src/lib/templates/nextjs/page-search.jsx.tpl +184 -82
  26. package/src/lib/templates/nextjs/theme-registry.jsx.tpl +51 -0
  27. package/src/lib/templates/nextjs/theme.js.tpl +138 -65
  28. package/src/lib/templates/nextjs/vai-logo-256.png +0 -0
  29. package/src/lib/tool-registry.js +194 -0
  30. package/src/lib/workflow-utils.js +65 -0
  31. package/src/lib/workflow.js +1259 -0
  32. package/src/mcp/tools/embedding.js +55 -43
  33. package/src/mcp/tools/ingest.js +74 -67
  34. package/src/mcp/tools/management.js +54 -101
  35. package/src/mcp/tools/retrieval.js +181 -163
  36. package/src/mcp/tools/utility.js +171 -153
  37. package/src/playground/icons/dark/128.png +0 -0
  38. package/src/playground/icons/dark/16.png +0 -0
  39. package/src/playground/icons/dark/256.png +0 -0
  40. package/src/playground/icons/dark/32.png +0 -0
  41. package/src/playground/icons/dark/64.png +0 -0
  42. package/src/playground/icons/light/128.png +0 -0
  43. package/src/playground/icons/light/16.png +0 -0
  44. package/src/playground/icons/light/256.png +0 -0
  45. package/src/playground/icons/light/32.png +0 -0
  46. package/src/playground/icons/light/64.png +0 -0
  47. package/src/playground/icons/watermark.png +0 -0
  48. package/src/playground/index.html +633 -83
  49. package/src/workflows/consistency-check.json +64 -0
  50. package/src/workflows/cost-analysis.json +69 -0
  51. package/src/workflows/multi-collection-search.json +80 -0
  52. package/src/workflows/research-and-summarize.json +46 -0
  53. package/src/workflows/smart-ingest.json +63 -0
@@ -10,56 +10,56 @@
10
10
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
11
 
12
12
  :root {
13
- /* MongoDB Design System — Dark Mode Palette (default) */
13
+ /* Vai Dark Mode Palette (default) — Teal/Cyan brand */
14
14
  --bg: #001E2B; /* MDB Black */
15
15
  --bg-surface: #112733; /* Gray Dark 4 */
16
16
  --bg-card: #1C2D38; /* Gray Dark 3 */
17
17
  --bg-input: #112733; /* Gray Dark 4 */
18
- --accent: #00ED64; /* Green Base — interactive elements only */
18
+ --accent: #00D4AA; /* Teal — interactive elements */
19
19
  --accent-text: #FFFFFF; /* Bright white — headings/labels in dark mode */
20
- --accent-dim: #00A35C; /* Green Dark 1 */
21
- --accent-glow: rgba(0, 237, 100, 0.12);
20
+ --accent-dim: #009E80; /* Teal Dark */
21
+ --accent-glow: rgba(0, 212, 170, 0.10);
22
22
  --text: #E8EDEB; /* Gray Light 2 */
23
23
  --text-dim: #C1C7C6; /* Gray Light 1 */
24
24
  --text-muted: #889397; /* Gray Base */
25
25
  --border: #3D4F58; /* Gray Dark 2 */
26
26
  --error: #FF6960; /* Red Light 1 */
27
27
  --warning: #FFC010; /* Yellow Base */
28
- --success: #00ED64; /* Green Base */
28
+ --success: #00D4AA; /* Teal */
29
29
  --red: #FF6960; /* Red Light 1 */
30
30
  --yellow: #FFC010; /* Yellow Base */
31
- --green: #00ED64; /* Green Base */
32
- --blue: #0498EC; /* Blue Light 1 (links) */
31
+ --green: #00D4AA; /* Teal */
32
+ --blue: #40E0FF; /* Cyan (links) */
33
33
  --purple: #B45AF2; /* Purple Base */
34
- --green-dark: #023430; /* Green Dark 3 */
34
+ --green-dark: #00241E; /* Teal Dark 3 */
35
35
  --radius: 8px;
36
36
  --font: 'Euclid Circular A', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
37
37
  --mono: 'Source Code Pro', 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
38
38
  }
39
39
 
40
- /* MongoDB Design SystemLight Mode Palette */
40
+ /* Vai Light Mode Palette Teal/Cyan brand */
41
41
  [data-theme="light"] {
42
42
  --bg: #FFFFFF; /* White */
43
43
  --bg-surface: #F9FBFA; /* Gray Light 3 */
44
44
  --bg-card: #FFFFFF; /* White */
45
45
  --bg-input: #F9FBFA; /* Gray Light 3 */
46
- --accent: #00A35C; /* Green Dark 1 — interactive elements */
46
+ --accent: #009E80; /* Teal Dark — interactive elements */
47
47
  --accent-text: #001E2B; /* MDB Black — headings/labels in light mode */
48
- --accent-dim: #00684A; /* Green Dark 2 */
49
- --accent-glow: rgba(0, 163, 92, 0.08);
48
+ --accent-dim: #007A63; /* Teal Darker */
49
+ --accent-glow: rgba(0, 158, 128, 0.08);
50
50
  --text: #001E2B; /* MDB Black */
51
51
  --text-dim: #5C6C75; /* Gray Dark 1 */
52
52
  --text-muted: #889397; /* Gray Base */
53
53
  --border: #E8EDEB; /* Gray Light 2 */
54
54
  --error: #DB3030; /* Red Base */
55
55
  --warning: #944F01; /* Yellow Dark 2 */
56
- --success: #00684A; /* Green Dark 2 */
56
+ --success: #009E80; /* Teal Dark */
57
57
  --red: #DB3030; /* Red Base */
58
58
  --yellow: #944F01; /* Yellow Dark 2 */
59
- --green: #00684A; /* Green Dark 2 */
60
- --blue: #016BF8; /* Blue Base */
59
+ --green: #009E80; /* Teal Dark */
60
+ --blue: #0088CC; /* Cyan Dark (links) */
61
61
  --purple: #5E0C9E; /* Purple Dark 2 */
62
- --green-dark: #023430; /* Green Dark 3 */
62
+ --green-dark: #00241E; /* Teal Dark 3 */
63
63
  }
64
64
  /* Light mode shadow + card adjustments */
65
65
  [data-theme="light"] .explore-card,
@@ -69,7 +69,7 @@
69
69
  box-shadow: 0 1px 4px rgba(0, 30, 43, 0.08);
70
70
  }
71
71
  [data-theme="light"] .explore-card:hover {
72
- box-shadow: 0 4px 16px rgba(0, 163, 92, 0.12);
72
+ box-shadow: 0 4px 16px rgba(0, 158, 128, 0.12);
73
73
  }
74
74
  [data-theme="light"] .cost-modal,
75
75
  [data-theme="light"] .explore-modal {
@@ -83,14 +83,14 @@
83
83
  box-shadow: 1px 0 3px rgba(0, 30, 43, 0.06);
84
84
  }
85
85
  /* Light mode gradient overrides */
86
- [data-theme="light"] .quant-bar-fill.storage { background: linear-gradient(90deg, #00A35C, #00ED64); }
87
- [data-theme="light"] .quant-bar-fill.latency { background: linear-gradient(90deg, #016BF8, #0498EC); }
88
- [data-theme="light"] .quant-meter-fill.perfect { background: linear-gradient(90deg, #00A35C, #00ED64); }
86
+ [data-theme="light"] .quant-bar-fill.storage { background: linear-gradient(90deg, #009E80, #00D4AA); }
87
+ [data-theme="light"] .quant-bar-fill.latency { background: linear-gradient(90deg, #0088CC, #40E0FF); }
88
+ [data-theme="light"] .quant-meter-fill.perfect { background: linear-gradient(90deg, #009E80, #00D4AA); }
89
89
  [data-theme="light"] .quant-meter-fill.good { background: linear-gradient(90deg, #944F01, #FFC010); }
90
90
  [data-theme="light"] .quant-meter-fill.degraded { background: linear-gradient(90deg, #DB3030, #FF6960); }
91
91
  /* Light mode button text */
92
92
  [data-theme="light"] .btn { color: #FFFFFF; }
93
- [data-theme="light"] .btn:hover { background: #00684A; }
93
+ [data-theme="light"] .btn:hover { background: #007A63; }
94
94
 
95
95
  html, body { height: 100%; }
96
96
 
@@ -108,8 +108,8 @@ body {
108
108
  align-items: center;
109
109
  gap: 10px;
110
110
  padding: 8px 16px;
111
- background: linear-gradient(135deg, rgba(0,163,92,0.12), rgba(4,152,236,0.12));
112
- border-bottom: 1px solid rgba(0,237,100,0.2);
111
+ background: linear-gradient(135deg, rgba(0,158,128,0.12), rgba(4,152,236,0.12));
112
+ border-bottom: 1px solid rgba(0,212,170,0.2);
113
113
  font-size: 13px;
114
114
  color: var(--text);
115
115
  flex-shrink: 0;
@@ -178,7 +178,7 @@ body {
178
178
  padding: 24px 28px 20px;
179
179
  width: 380px;
180
180
  max-width: 90vw;
181
- box-shadow: 0 12px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(0,237,100,0.15);
181
+ box-shadow: 0 12px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(0,212,170,0.15);
182
182
  transition: all 0.4s cubic-bezier(0.4,0,0.2,1);
183
183
  opacity: 0;
184
184
  transform: translateY(10px);
@@ -296,7 +296,7 @@ body {
296
296
  width: 460px;
297
297
  max-width: 90vw;
298
298
  text-align: center;
299
- box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 80px rgba(0,237,100,0.08);
299
+ box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 80px rgba(0,212,170,0.08);
300
300
  pointer-events: auto;
301
301
  opacity: 0;
302
302
  transform: scale(0.95);
@@ -324,10 +324,10 @@ body {
324
324
  margin-bottom: 28px;
325
325
  }
326
326
  [data-theme="light"] .onboarding-tooltip {
327
- box-shadow: 0 12px 40px rgba(0,30,43,0.15), 0 0 0 1px rgba(0,163,92,0.2);
327
+ box-shadow: 0 12px 40px rgba(0,30,43,0.15), 0 0 0 1px rgba(0,158,128,0.2);
328
328
  }
329
329
  [data-theme="light"] .onboarding-welcome-card {
330
- box-shadow: 0 20px 60px rgba(0,30,43,0.18), 0 0 80px rgba(0,163,92,0.06);
330
+ box-shadow: 0 20px 60px rgba(0,30,43,0.18), 0 0 80px rgba(0,158,128,0.06);
331
331
  }
332
332
  [data-theme="light"] .onboarding-backdrop {
333
333
  background: rgba(0,30,43,0.45);
@@ -480,7 +480,7 @@ body:not(.is-electron) .sidebar-drag-region {
480
480
  height: 16px;
481
481
  }
482
482
  [data-theme="light"] .tab-btn:hover { background: rgba(0,30,43,0.04); }
483
- [data-theme="light"] .tab-btn.active { background: rgba(0,163,92,0.08); }
483
+ [data-theme="light"] .tab-btn.active { background: rgba(0,158,128,0.08); }
484
484
 
485
485
  .sidebar-footer {
486
486
  padding: 12px 16px;
@@ -558,6 +558,27 @@ body:not(.is-electron) .sidebar-drag-region {
558
558
  overflow-y: auto;
559
559
  display: flex;
560
560
  flex-direction: column;
561
+ position: relative;
562
+ }
563
+
564
+ .content-area::after {
565
+ content: '';
566
+ position: fixed;
567
+ bottom: 2rem;
568
+ right: 2rem;
569
+ width: 200px;
570
+ height: 200px;
571
+ background-image: url('/icons/watermark.png');
572
+ background-size: contain;
573
+ background-repeat: no-repeat;
574
+ opacity: 0.05;
575
+ pointer-events: none;
576
+ z-index: 0;
577
+ filter: invert(1);
578
+ }
579
+
580
+ [data-theme="light"] .content-area::after {
581
+ filter: none;
561
582
  }
562
583
 
563
584
  .content-drag-region {
@@ -644,7 +665,7 @@ select:focus { outline: none; border-color: var(--accent); }
644
665
  align-items: center;
645
666
  gap: 8px;
646
667
  }
647
- .btn:hover { background: #71F6BA; transform: translateY(-1px); }
668
+ .btn:hover { background: #5CE8CC; transform: translateY(-1px); }
648
669
  .btn:active { transform: translateY(0); }
649
670
  .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
650
671
 
@@ -689,7 +710,7 @@ select:focus { outline: none; border-color: var(--accent); }
689
710
  }
690
711
  .subtab:hover {
691
712
  color: var(--text);
692
- background: rgba(0, 237, 100, 0.05);
713
+ background: rgba(0, 212, 170, 0.05);
693
714
  }
694
715
  .subtab.active {
695
716
  background: var(--accent);
@@ -964,7 +985,7 @@ select:focus { outline: none; border-color: var(--accent); }
964
985
  .explore-card:hover {
965
986
  border-color: var(--accent);
966
987
  transform: translateY(-2px);
967
- box-shadow: 0 4px 20px rgba(0, 237, 100, 0.1);
988
+ box-shadow: 0 4px 20px rgba(0, 212, 170, 0.1);
968
989
  }
969
990
  .explore-card.expanded {
970
991
  border-color: var(--accent);
@@ -1252,8 +1273,8 @@ select:focus { outline: none; border-color: var(--accent); }
1252
1273
  font-family: var(--mono); font-size: 12px; font-weight: 600;
1253
1274
  color: var(--green-dark); white-space: nowrap; min-width: fit-content;
1254
1275
  }
1255
- .quant-bar-fill.storage { background: linear-gradient(90deg, #00ED64, #71F6BA); }
1256
- .quant-bar-fill.latency { background: linear-gradient(90deg, #0498EC, #016BF8); }
1276
+ .quant-bar-fill.storage { background: linear-gradient(90deg, #00D4AA, #5CE8CC); }
1277
+ .quant-bar-fill.latency { background: linear-gradient(90deg, #40E0FF, #0088CC); }
1257
1278
  .quant-bar-badge {
1258
1279
  position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
1259
1280
  font-size: 12px; color: var(--text-dim); font-family: var(--mono);
@@ -1278,7 +1299,7 @@ select:focus { outline: none; border-color: var(--accent); }
1278
1299
  height: 100%; border-radius: 5px;
1279
1300
  transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1);
1280
1301
  }
1281
- .quant-meter-fill.perfect { background: linear-gradient(90deg, #00ED64, #71F6BA); }
1302
+ .quant-meter-fill.perfect { background: linear-gradient(90deg, #00D4AA, #5CE8CC); }
1282
1303
  .quant-meter-fill.good { background: linear-gradient(90deg, #FFC010, #FFEC9E); }
1283
1304
  .quant-meter-fill.degraded { background: linear-gradient(90deg, #FF6960, #FFCDC7); }
1284
1305
  .quant-meter-detail { font-size: 11px; color: var(--text-muted); margin-top: 4px; font-family: var(--mono); }
@@ -1374,7 +1395,7 @@ select:focus { outline: none; border-color: var(--accent); }
1374
1395
  font-weight: 600;
1375
1396
  }
1376
1397
  .cost-mode-btn:hover:not(.active) {
1377
- background: rgba(0, 237, 100, 0.1);
1398
+ background: rgba(0, 212, 170, 0.1);
1378
1399
  color: var(--text);
1379
1400
  }
1380
1401
  .cost-select {
@@ -1514,7 +1535,7 @@ select:focus { outline: none; border-color: var(--accent); }
1514
1535
  border-bottom: 1px solid rgba(42, 53, 80, 0.3);
1515
1536
  font-family: var(--mono);
1516
1537
  }
1517
- .cost-table tr:hover { background: rgba(0, 237, 100, 0.03); }
1538
+ .cost-table tr:hover { background: rgba(0, 212, 170, 0.03); }
1518
1539
  .cost-highlight {
1519
1540
  color: var(--accent-text);
1520
1541
  font-weight: 600;
@@ -1541,7 +1562,7 @@ select:focus { outline: none; border-color: var(--accent); }
1541
1562
  .cost-tip {
1542
1563
  font-size: 12px;
1543
1564
  color: var(--text-muted);
1544
- background: rgba(0, 237, 100, 0.05);
1565
+ background: rgba(0, 212, 170, 0.05);
1545
1566
  border-left: 3px solid var(--accent);
1546
1567
  padding: 10px 14px;
1547
1568
  border-radius: 0 6px 6px 0;
@@ -1661,7 +1682,7 @@ select:focus { outline: none; border-color: var(--accent); }
1661
1682
  }
1662
1683
  .cost-modal .formula .accent { color: var(--accent); font-weight: 600; }
1663
1684
  .cost-modal .example {
1664
- background: rgba(0, 237, 100, 0.05);
1685
+ background: rgba(0, 212, 170, 0.05);
1665
1686
  border-left: 3px solid var(--accent);
1666
1687
  border-radius: 0 8px 8px 0;
1667
1688
  padding: 12px 16px;
@@ -1843,8 +1864,8 @@ select:focus { outline: none; border-color: var(--accent); }
1843
1864
  line-height: 1.5;
1844
1865
  }
1845
1866
  .settings-api-banner.success {
1846
- background: rgba(0,237,100,0.06);
1847
- border-color: rgba(0,237,100,0.2);
1867
+ background: rgba(0,212,170,0.06);
1868
+ border-color: rgba(0,212,170,0.2);
1848
1869
  color: var(--success);
1849
1870
  }
1850
1871
  [data-theme="light"] .settings-api-banner {
@@ -1932,8 +1953,8 @@ select:focus { outline: none; border-color: var(--accent); }
1932
1953
  font-size: 13px;
1933
1954
  font-weight: 600;
1934
1955
  color: var(--accent);
1935
- background: rgba(0,237,100,0.08);
1936
- border: 1px solid rgba(0,237,100,0.2);
1956
+ background: rgba(0,212,170,0.08);
1957
+ border: 1px solid rgba(0,212,170,0.2);
1937
1958
  border-radius: 6px;
1938
1959
  padding: 4px 12px;
1939
1960
  letter-spacing: 0.3px;
@@ -1941,7 +1962,7 @@ select:focus { outline: none; border-color: var(--accent); }
1941
1962
  [data-theme="light"] .version-badge {
1942
1963
  background: rgba(0,104,74,0.06);
1943
1964
  border-color: rgba(0,104,74,0.2);
1944
- color: #00684A;
1965
+ color: #007A63;
1945
1966
  }
1946
1967
  .settings-api-field .save-btn {
1947
1968
  color: var(--accent);
@@ -2174,7 +2195,7 @@ select:focus { outline: none; border-color: var(--accent); }
2174
2195
  }
2175
2196
  [data-theme="light"] .settings-origin.origin-env {
2176
2197
  background: rgba(4,120,190,0.10);
2177
- color: #016BF8;
2198
+ color: #0088CC;
2178
2199
  }
2179
2200
  .settings-origin.origin-config {
2180
2201
  background: rgba(180,90,242,0.12);
@@ -2185,12 +2206,12 @@ select:focus { outline: none; border-color: var(--accent); }
2185
2206
  color: #7B3DB5;
2186
2207
  }
2187
2208
  .settings-origin.origin-project {
2188
- background: rgba(0,237,100,0.12);
2209
+ background: rgba(0,212,170,0.12);
2189
2210
  color: var(--accent);
2190
2211
  }
2191
2212
  [data-theme="light"] .settings-origin.origin-project {
2192
- background: rgba(0,163,92,0.10);
2193
- color: #00A35C;
2213
+ background: rgba(0,158,128,0.10);
2214
+ color: #009E80;
2194
2215
  }
2195
2216
  .settings-origin.origin-default {
2196
2217
  background: rgba(136,147,151,0.12);
@@ -2313,6 +2334,108 @@ select:focus { outline: none; border-color: var(--accent); }
2313
2334
  .chat-sources summary { cursor: pointer; }
2314
2335
  .chat-sources ul { margin: 4px 0 0 16px; padding: 0; }
2315
2336
  .chat-sources li { margin: 2px 0; }
2337
+ /* Markdown rendering in assistant messages */
2338
+ .chat-message.assistant .chat-message-content.rendered {
2339
+ white-space: normal;
2340
+ }
2341
+ .chat-message.assistant .chat-message-content.rendered p {
2342
+ margin: 0 0 8px 0;
2343
+ }
2344
+ .chat-message.assistant .chat-message-content.rendered p:last-child {
2345
+ margin-bottom: 0;
2346
+ }
2347
+ .chat-message.assistant .chat-message-content.rendered h1,
2348
+ .chat-message.assistant .chat-message-content.rendered h2,
2349
+ .chat-message.assistant .chat-message-content.rendered h3,
2350
+ .chat-message.assistant .chat-message-content.rendered h4 {
2351
+ margin: 12px 0 6px 0;
2352
+ font-weight: 600;
2353
+ color: var(--accent-text, #fff);
2354
+ }
2355
+ .chat-message.assistant .chat-message-content.rendered h1 { font-size: 1.3em; }
2356
+ .chat-message.assistant .chat-message-content.rendered h2 { font-size: 1.15em; }
2357
+ .chat-message.assistant .chat-message-content.rendered h3 { font-size: 1.05em; }
2358
+ .chat-message.assistant .chat-message-content.rendered code {
2359
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
2360
+ font-size: 0.9em;
2361
+ background: var(--bg-input, #112733);
2362
+ padding: 2px 5px;
2363
+ border-radius: 4px;
2364
+ }
2365
+ .chat-message.assistant .chat-message-content.rendered pre {
2366
+ background: var(--bg-input, #112733);
2367
+ border: 1px solid var(--border);
2368
+ border-radius: 8px;
2369
+ padding: 12px;
2370
+ margin: 8px 0;
2371
+ overflow-x: auto;
2372
+ white-space: pre;
2373
+ }
2374
+ .chat-message.assistant .chat-message-content.rendered pre code {
2375
+ background: none;
2376
+ padding: 0;
2377
+ border-radius: 0;
2378
+ font-size: 13px;
2379
+ line-height: 1.5;
2380
+ }
2381
+ .chat-message.assistant .chat-message-content.rendered ul,
2382
+ .chat-message.assistant .chat-message-content.rendered ol {
2383
+ margin: 6px 0;
2384
+ padding-left: 20px;
2385
+ }
2386
+ .chat-message.assistant .chat-message-content.rendered li {
2387
+ margin: 3px 0;
2388
+ }
2389
+ .chat-message.assistant .chat-message-content.rendered blockquote {
2390
+ border-left: 3px solid var(--accent, #00D4AA);
2391
+ margin: 8px 0;
2392
+ padding: 4px 12px;
2393
+ color: var(--text-muted);
2394
+ }
2395
+ .chat-message.assistant .chat-message-content.rendered a {
2396
+ color: var(--accent, #00D4AA);
2397
+ text-decoration: none;
2398
+ }
2399
+ .chat-message.assistant .chat-message-content.rendered a:hover {
2400
+ text-decoration: underline;
2401
+ }
2402
+ .chat-message.assistant .chat-message-content.rendered table {
2403
+ border-collapse: collapse;
2404
+ margin: 8px 0;
2405
+ font-size: 13px;
2406
+ width: 100%;
2407
+ }
2408
+ .chat-message.assistant .chat-message-content.rendered th,
2409
+ .chat-message.assistant .chat-message-content.rendered td {
2410
+ border: 1px solid var(--border);
2411
+ padding: 6px 10px;
2412
+ text-align: left;
2413
+ }
2414
+ .chat-message.assistant .chat-message-content.rendered th {
2415
+ background: var(--bg-input, #112733);
2416
+ font-weight: 600;
2417
+ }
2418
+ .chat-message.assistant .chat-message-content.rendered hr {
2419
+ border: none;
2420
+ border-top: 1px solid var(--border);
2421
+ margin: 12px 0;
2422
+ }
2423
+
2424
+ .chat-tool-calls {
2425
+ display: flex; flex-direction: column; gap: 4px;
2426
+ margin-bottom: 4px; align-self: flex-start; max-width: 85%;
2427
+ }
2428
+ .chat-tool-call {
2429
+ font-size: 12px; color: var(--text-muted);
2430
+ padding: 6px 12px; border-radius: 8px;
2431
+ background: var(--bg-card); border: 1px dashed var(--border);
2432
+ display: flex; align-items: center; gap: 6px;
2433
+ }
2434
+ .chat-tool-call .tool-icon { opacity: 0.5; font-size: 11px; }
2435
+ .chat-tool-call .tool-name { font-weight: 600; }
2436
+ .chat-tool-call .tool-time { opacity: 0.5; }
2437
+ .chat-tool-call.error { border-color: #e74c3c; }
2438
+ .chat-tool-call .tool-error { color: #e74c3c; font-size: 11px; }
2316
2439
  .chat-input-area {
2317
2440
  padding: 12px 16px;
2318
2441
  border-top: 1px solid var(--border);
@@ -4025,6 +4148,39 @@ Reranking models rescore initial search results to improve relevance ordering.</
4025
4148
  </select>
4026
4149
  </div>
4027
4150
  </div>
4151
+ <div class="settings-row" id="chatApiKeyRow" style="display:none">
4152
+ <div class="settings-label">
4153
+ <span class="settings-label-text">API Key</span>
4154
+ <span class="settings-label-hint">Provider API key (stored securely in OS keychain)</span>
4155
+ </div>
4156
+ <div class="settings-control" style="display:flex;gap:8px;align-items:center">
4157
+ <input
4158
+ type="password"
4159
+ class="settings-input"
4160
+ id="chatApiKey"
4161
+ placeholder="Enter your API key"
4162
+ style="flex:1;font-family:monospace;font-size:13px"
4163
+ >
4164
+ <button
4165
+ class="btn btn-secondary"
4166
+ id="chatApiKeyToggle"
4167
+ onclick="toggleChatApiKeyVisibility()"
4168
+ title="Show/hide API key"
4169
+ style="padding:8px 12px;min-width:auto"
4170
+ >
4171
+ 👁️
4172
+ </button>
4173
+ <button
4174
+ class="btn"
4175
+ id="chatApiKeySave"
4176
+ onclick="saveChatApiKey()"
4177
+ title="Save API key"
4178
+ style="padding:8px 12px;min-width:auto"
4179
+ >
4180
+ 💾
4181
+ </button>
4182
+ </div>
4183
+ </div>
4028
4184
  </div>
4029
4185
  <div class="settings-section">
4030
4186
  <div class="settings-section-title">Knowledge Base</div>
@@ -4064,6 +4220,18 @@ Reranking models rescore initial search results to improve relevance ordering.</
4064
4220
  <button class="settings-toggle active" id="chatRerank" type="button"></button>
4065
4221
  </div>
4066
4222
  </div>
4223
+ <div class="settings-row">
4224
+ <div class="settings-label">
4225
+ <span class="settings-label-text">Chat Mode</span>
4226
+ <span class="settings-label-hint">Pipeline: fixed RAG retrieval. Agent: LLM chooses which tools to call.</span>
4227
+ </div>
4228
+ <div class="settings-control">
4229
+ <select class="settings-select" id="chatMode">
4230
+ <option value="pipeline">Pipeline (fixed RAG)</option>
4231
+ <option value="agent">Agent (tool-calling)</option>
4232
+ </select>
4233
+ </div>
4234
+ </div>
4067
4235
  </div>
4068
4236
  <div class="settings-section">
4069
4237
  <div class="settings-section-title">Custom Instructions</div>
@@ -4280,7 +4448,7 @@ let lastEmbedding = null;
4280
4448
 
4281
4449
  // ── Init ──
4282
4450
  // ── Anonymous Telemetry ──
4283
- const TELEMETRY_URL = 'https://vai.mlynn.org/api/telemetry';
4451
+ const TELEMETRY_URL = 'https://vaicli.com/api/telemetry';
4284
4452
  const TELEMETRY_KEY = 'vai-telemetry-enabled';
4285
4453
 
4286
4454
  function isTelemetryEnabled() {
@@ -5318,8 +5486,8 @@ const BENCH_SAMPLE_TEXTS = [
5318
5486
  ];
5319
5487
 
5320
5488
  const MODEL_COLORS = [
5321
- '#00ED64', '#71F6BA', '#0498EC', '#B45AF2', '#FFC010',
5322
- '#FF6960', '#B45AF2', '#FFC010', '#016BF8', '#C0FAE6',
5489
+ '#00D4AA', '#5CE8CC', '#40E0FF', '#B45AF2', '#FFC010',
5490
+ '#FF6960', '#B45AF2', '#FFC010', '#0088CC', '#A8F0E0',
5323
5491
  ];
5324
5492
 
5325
5493
  window.doBenchLatency = async function() {
@@ -5660,7 +5828,7 @@ window.doBenchQuantization = async function() {
5660
5828
  const baseline = completed.find(r => r.dtype === 'float') || completed[0];
5661
5829
  const maxBytes = Math.max(...completed.map(r => r.bytesPerVec));
5662
5830
  const maxLatency = Math.max(...completed.map(r => r.latency));
5663
- const DTYPE_COLORS = { float: '#00ED64', int8: '#71F6BA', uint8: '#0498EC', ubinary: '#FFC010', binary: '#FF6960' };
5831
+ const DTYPE_COLORS = { float: '#00D4AA', int8: '#5CE8CC', uint8: '#40E0FF', ubinary: '#FFC010', binary: '#FF6960' };
5664
5832
 
5665
5833
  // ── Storage Bar Chart ──
5666
5834
  let storageHTML = '';
@@ -5671,7 +5839,7 @@ window.doBenchQuantization = async function() {
5671
5839
  const savings = r.bytesPerVec < baseline.bytesPerVec
5672
5840
  ? `${(baseline.bytesPerVec / r.bytesPerVec).toFixed(0)}× smaller`
5673
5841
  : 'baseline';
5674
- const color = DTYPE_COLORS[r.dtype] || '#0498EC';
5842
+ const color = DTYPE_COLORS[r.dtype] || '#40E0FF';
5675
5843
  storageHTML += `<div class="quant-bar-group">
5676
5844
  <div class="quant-bar-label">
5677
5845
  <span class="dtype-name">${r.dtype}</span>
@@ -5689,7 +5857,7 @@ window.doBenchQuantization = async function() {
5689
5857
  const minLatency = Math.min(...completed.map(r => r.latency));
5690
5858
  for (const r of completed) {
5691
5859
  const pct = Math.max(8, (r.latency / maxLatency) * 100);
5692
- const color = DTYPE_COLORS[r.dtype] || '#0498EC';
5860
+ const color = DTYPE_COLORS[r.dtype] || '#40E0FF';
5693
5861
  const badge = r.latency === minLatency ? ' ⚡' : '';
5694
5862
  latencyHTML += `<div class="quant-bar-group">
5695
5863
  <div class="quant-bar-label">
@@ -6476,6 +6644,8 @@ function initSettings() {
6476
6644
  }
6477
6645
  const chatModel = document.getElementById('chatModel');
6478
6646
  if (chatModel) chatModel.addEventListener('change', saveChatSettings);
6647
+ const chatMode = document.getElementById('chatMode');
6648
+ if (chatMode) chatMode.addEventListener('change', saveChatSettings);
6479
6649
  const chatMaxDocs = document.getElementById('chatMaxDocs');
6480
6650
  if (chatMaxDocs) chatMaxDocs.addEventListener('change', saveChatSettings);
6481
6651
 
@@ -6487,6 +6657,23 @@ function initSettings() {
6487
6657
  const chatSystemPrompt = document.getElementById('chatSystemPrompt');
6488
6658
  if (chatSystemPrompt) chatSystemPrompt.addEventListener('input', saveChatSettingsDebounced);
6489
6659
 
6660
+ // API key input listeners
6661
+ const chatApiKey = document.getElementById('chatApiKey');
6662
+ if (chatApiKey) {
6663
+ // Mark as modified when user types
6664
+ chatApiKey.addEventListener('input', () => {
6665
+ delete chatApiKey.dataset.isMasked;
6666
+ delete chatApiKey.dataset.actualKey;
6667
+ });
6668
+ // Save on Enter key
6669
+ chatApiKey.addEventListener('keydown', (e) => {
6670
+ if (e.key === 'Enter') {
6671
+ e.preventDefault();
6672
+ saveChatApiKey();
6673
+ }
6674
+ });
6675
+ }
6676
+
6490
6677
  // Set up settings sub-navigation
6491
6678
  setupSettingsNav();
6492
6679
  }
@@ -6860,10 +7047,29 @@ function initMultimodal() {
6860
7047
  const dropZone = document.getElementById('mmDropZone');
6861
7048
  const fileInput = document.getElementById('mmFileInput');
6862
7049
 
6863
- // Click to browse
6864
- dropZone.addEventListener('click', () => fileInput.click());
7050
+ // Click to browse — in Electron, use native dialog (more reliable);
7051
+ // in browser, use <input type="file"> with a re-trigger guard.
7052
+ let fileDialogOpen = false;
7053
+ dropZone.addEventListener('click', async () => {
7054
+ if (fileDialogOpen) return;
7055
+ fileDialogOpen = true;
7056
+ try {
7057
+ if (window.vai && window.vai.isElectron && window.vai.openImageDialog) {
7058
+ const result = await window.vai.openImageDialog();
7059
+ if (!result.canceled && result.dataUrl) {
7060
+ handleMultimodalImageFromData(result.dataUrl, result.name, result.size);
7061
+ }
7062
+ } else {
7063
+ fileInput.click();
7064
+ }
7065
+ } finally {
7066
+ setTimeout(() => { fileDialogOpen = false; }, 300);
7067
+ }
7068
+ });
6865
7069
  fileInput.addEventListener('change', (e) => {
7070
+ fileDialogOpen = false;
6866
7071
  if (e.target.files && e.target.files[0]) handleMultimodalImage(e.target.files[0]);
7072
+ fileInput.value = '';
6867
7073
  });
6868
7074
 
6869
7075
  // Drag and drop
@@ -6915,6 +7121,23 @@ function initMultimodal() {
6915
7121
  });
6916
7122
  }
6917
7123
 
7124
+ // Handle image from Electron native dialog (already has dataUrl)
7125
+ function handleMultimodalImageFromData(dataUrl, name, size) {
7126
+ mmImageData = dataUrl;
7127
+ const img = document.getElementById('mmPreviewImg');
7128
+ img.src = mmImageData;
7129
+ img.onload = () => {
7130
+ const info = document.getElementById('mmFileInfo');
7131
+ const sizeStr = size > 1024 * 1024
7132
+ ? (size / (1024 * 1024)).toFixed(1) + ' MB'
7133
+ : (size / 1024).toFixed(0) + ' KB';
7134
+ info.textContent = `${name} · ${img.naturalWidth}×${img.naturalHeight} · ${sizeStr}`;
7135
+ };
7136
+ document.getElementById('mmDropZone').style.display = 'none';
7137
+ document.getElementById('mmPreview').classList.add('visible');
7138
+ hideError('mmError');
7139
+ }
7140
+
6918
7141
  function handleMultimodalImage(file) {
6919
7142
  const VALID_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
6920
7143
  if (!VALID_TYPES.includes(file.type)) {
@@ -7052,13 +7275,33 @@ function renderGalleryGrid() {
7052
7275
  const addSlot = document.createElement('div');
7053
7276
  addSlot.className = 'mm-gallery-slot';
7054
7277
  addSlot.innerHTML = '<span class="mm-slot-add">+</span>';
7055
- addSlot.addEventListener('click', () => {
7056
- document.getElementById('mmGalleryFileInput').click();
7278
+ addSlot.addEventListener('click', async () => {
7279
+ if (window._galleryDialogOpen) return;
7280
+ window._galleryDialogOpen = true;
7281
+ try {
7282
+ if (window.vai && window.vai.isElectron && window.vai.openImageDialog) {
7283
+ const result = await window.vai.openImageDialog();
7284
+ if (!result.canceled && result.dataUrl) {
7285
+ addGalleryImageFromData(result.dataUrl, result.name, result.size);
7286
+ }
7287
+ } else {
7288
+ document.getElementById('mmGalleryFileInput').click();
7289
+ }
7290
+ } finally {
7291
+ setTimeout(() => { window._galleryDialogOpen = false; }, 300);
7292
+ }
7057
7293
  });
7058
7294
  grid.appendChild(addSlot);
7059
7295
  }
7060
7296
  }
7061
7297
 
7298
+ // Add gallery image from Electron native dialog (already has dataUrl)
7299
+ function addGalleryImageFromData(dataUrl, name, size) {
7300
+ if (mmGalleryImages.length >= 6) return;
7301
+ mmGalleryImages.push({ dataUrl, name, size });
7302
+ renderGalleryGrid();
7303
+ }
7304
+
7062
7305
  function addGalleryImage(file) {
7063
7306
  const VALID_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'];
7064
7307
  if (!VALID_TYPES.includes(file.type)) return;
@@ -7229,7 +7472,22 @@ init = async function() {
7229
7472
  // ── Start ──
7230
7473
  init();
7231
7474
 
7232
- // ── Vector Space Invaders (Easter Egg) ──
7475
+ // ── Vector Space Invaders (Easter Egg) ──
7476
+
7477
+ // VSI Telemetry helper
7478
+ function sendVSITelemetry(event, extra) {
7479
+ try {
7480
+ fetch('https://vaicli.com/api/telemetry', {
7481
+ method: 'POST',
7482
+ headers: { 'Content-Type': 'application/json' },
7483
+ body: JSON.stringify({ event, ...extra, sentAt: new Date().toISOString() }),
7484
+ }).catch(() => {});
7485
+ } catch (_) {}
7486
+ }
7487
+
7488
+ let _vsiGameStartTime = 0;
7489
+ let _vsiGameTrigger = 'unknown';
7490
+
7233
7491
  (function initEasterEgg() {
7234
7492
  const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
7235
7493
  let konamiIdx = 0;
@@ -7237,7 +7495,7 @@ init();
7237
7495
 
7238
7496
  document.addEventListener('keydown', (e) => {
7239
7497
  if (document.getElementById('vsiOverlay')?.style.display === 'flex') return;
7240
- if (e.key === KONAMI[konamiIdx]) { konamiIdx++; if (konamiIdx === KONAMI.length) { konamiIdx = 0; launchVSI(); } }
7498
+ if (e.key === KONAMI[konamiIdx]) { konamiIdx++; if (konamiIdx === KONAMI.length) { konamiIdx = 0; _vsiGameTrigger = 'konami'; launchVSI(); } }
7241
7499
  else { konamiIdx = 0; }
7242
7500
  });
7243
7501
 
@@ -7247,7 +7505,7 @@ init();
7247
7505
  const now = Date.now();
7248
7506
  logoClicks.push(now);
7249
7507
  logoClicks = logoClicks.filter(t => now - t < 2000);
7250
- if (logoClicks.length >= 7) { logoClicks = []; launchVSI(); }
7508
+ if (logoClicks.length >= 7) { logoClicks = []; _vsiGameTrigger = 'logo_click'; launchVSI(); }
7251
7509
  });
7252
7510
  }
7253
7511
 
@@ -7256,6 +7514,22 @@ init();
7256
7514
  const canvas = document.getElementById('vsiCanvas');
7257
7515
  if (!overlay || !canvas) return;
7258
7516
  overlay.style.display = 'flex';
7517
+ _vsiGameStartTime = Date.now();
7518
+
7519
+ // Detect version from page
7520
+ const versionEl = document.querySelector('.version-label, [data-version]');
7521
+ const version = versionEl ? (versionEl.dataset?.version || versionEl.textContent?.trim()) : 'unknown';
7522
+ const ua = navigator.userAgent;
7523
+ const platform = /Mac/.test(ua) ? 'macOS' : /Win/.test(ua) ? 'Windows' : /Linux/.test(ua) ? 'Linux' : /Android/.test(ua) ? 'Android' : /iPhone|iPad/.test(ua) ? 'iOS' : 'unknown';
7524
+
7525
+ sendVSITelemetry('vsi_game_start', {
7526
+ version,
7527
+ context: 'playground',
7528
+ platform,
7529
+ locale: navigator.language || 'unknown',
7530
+ trigger: _vsiGameTrigger,
7531
+ });
7532
+
7259
7533
  startVSI(canvas, () => { overlay.style.display = 'none'; });
7260
7534
  }
7261
7535
  })();
@@ -7277,7 +7551,7 @@ function startVSI(canvas, onExit) {
7277
7551
  ctx.quadraticCurveTo(x - size * 0.6, y - size * 0.3, x, y - size);
7278
7552
  ctx.fill();
7279
7553
  // Central vein
7280
- ctx.strokeStyle = color === '#00ED64' ? '#00A35C' : color;
7554
+ ctx.strokeStyle = color === '#00D4AA' ? '#009E80' : color;
7281
7555
  ctx.lineWidth = 1;
7282
7556
  ctx.beginPath();
7283
7557
  ctx.moveTo(x, y - size);
@@ -7312,7 +7586,7 @@ function startVSI(canvas, onExit) {
7312
7586
  let waveQueued = false;
7313
7587
 
7314
7588
  const keys = {};
7315
- const keyDown = (e) => { keys[e.key] = true; if (['ArrowLeft','ArrowRight',' ','Escape'].includes(e.key)) e.preventDefault(); if (e.key === 'Escape') { cleanup(); onExit(); } };
7589
+ const keyDown = (e) => { keys[e.key] = true; if (['ArrowLeft','ArrowRight',' ','Escape'].includes(e.key)) e.preventDefault(); if (e.key === 'Escape') { sendVSITelemetry('vsi_game_over', { score, wave, durationMs: Date.now() - _vsiGameStartTime, livesRemaining: lives, exitReason: 'esc' }); cleanup(); onExit(); } };
7316
7590
  const keyUp = (e) => { keys[e.key] = false; };
7317
7591
  document.addEventListener('keydown', keyDown);
7318
7592
  document.addEventListener('keyup', keyUp);
@@ -7353,7 +7627,7 @@ function startVSI(canvas, onExit) {
7353
7627
  }
7354
7628
 
7355
7629
  function addExplosion(x, y, sim) {
7356
- const color = sim > 0.7 ? '#00ED64' : sim > 0.4 ? '#FFC010' : '#FF6960';
7630
+ const color = sim > 0.7 ? '#00D4AA' : sim > 0.4 ? '#FFC010' : '#FF6960';
7357
7631
  for (let i = 0; i < 8; i++) {
7358
7632
  const angle = (Math.PI * 2 / 8) * i + Math.random() * 0.5;
7359
7633
  const speed = 1.5 + Math.random() * 2;
@@ -7445,7 +7719,7 @@ function startVSI(canvas, onExit) {
7445
7719
  if (enemies[i].y > H - 50) {
7446
7720
  enemies.splice(i, 1);
7447
7721
  lives--;
7448
- if (lives <= 0) gameOver = true;
7722
+ if (lives <= 0) { gameOver = true; sendVSITelemetry('vsi_game_over', { score, wave, durationMs: Date.now() - _vsiGameStartTime, livesRemaining: 0, exitReason: 'death' }); }
7449
7723
  }
7450
7724
  }
7451
7725
 
@@ -7482,16 +7756,16 @@ function startVSI(canvas, onExit) {
7482
7756
  }
7483
7757
 
7484
7758
  // Ship (Voyage AI leaf)
7485
- drawLeaf(ctx, ship.x, H - 32, 12, '#00ED64');
7759
+ drawLeaf(ctx, ship.x, H - 32, 12, '#00D4AA');
7486
7760
  // Ship label
7487
- ctx.fillStyle = '#00ED64';
7761
+ ctx.fillStyle = '#00D4AA';
7488
7762
  ctx.font = '9px monospace';
7489
7763
  ctx.textAlign = 'center';
7490
7764
  ctx.fillText('voyage', ship.x, H - 14);
7491
7765
 
7492
7766
  // Bullets (small leaf shapes)
7493
7767
  for (const b of bullets) {
7494
- drawLeaf(ctx, b.x, b.y, 3, '#00ED64');
7768
+ drawLeaf(ctx, b.x, b.y, 3, '#00D4AA');
7495
7769
  }
7496
7770
 
7497
7771
  // Boss
@@ -7516,7 +7790,7 @@ function startVSI(canvas, onExit) {
7516
7790
  for (const e of enemies) {
7517
7791
  ctx.fillStyle = 'rgba(61,79,88,0.6)';
7518
7792
  ctx.fillRect(e.x - e.w / 2, e.y - e.h / 2, e.w, e.h);
7519
- ctx.strokeStyle = '#0498EC';
7793
+ ctx.strokeStyle = '#40E0FF';
7520
7794
  ctx.lineWidth = 1;
7521
7795
  ctx.strokeRect(e.x - e.w / 2, e.y - e.h / 2, e.w, e.h);
7522
7796
  ctx.fillStyle = '#E8EDEB';
@@ -7556,19 +7830,19 @@ function startVSI(canvas, onExit) {
7556
7830
 
7557
7831
  // Lives (leaf icons)
7558
7832
  for (let i = 0; i < lives; i++) {
7559
- drawLeaf(ctx, W - 20 - (i * 18), 15, 6, '#00ED64');
7833
+ drawLeaf(ctx, W - 20 - (i * 18), 15, 6, '#00D4AA');
7560
7834
  }
7561
7835
 
7562
7836
  // Title with Voyage AI branding
7563
- ctx.fillStyle = '#00ED64';
7837
+ ctx.fillStyle = '#00D4AA';
7564
7838
  ctx.font = 'bold 10px monospace';
7565
7839
  ctx.textAlign = 'center';
7566
7840
  ctx.fillText('VECTOR SPACE INVADERS', W / 2, H - 4);
7567
7841
 
7568
7842
  // Voyage AI watermark (top right corner)
7569
7843
  ctx.globalAlpha = 0.3;
7570
- drawLeaf(ctx, W - 25, 25, 8, '#00ED64');
7571
- ctx.fillStyle = '#00ED64';
7844
+ drawLeaf(ctx, W - 25, 25, 8, '#00D4AA');
7845
+ ctx.fillStyle = '#00D4AA';
7572
7846
  ctx.font = 'bold 9px monospace';
7573
7847
  ctx.textAlign = 'right';
7574
7848
  ctx.fillText('VOYAGE AI', W - 38, 28);
@@ -7578,7 +7852,7 @@ function startVSI(canvas, onExit) {
7578
7852
  if (gameOver) {
7579
7853
  ctx.fillStyle = 'rgba(0,30,43,0.8)';
7580
7854
  ctx.fillRect(0, 0, W, H);
7581
- ctx.fillStyle = '#00ED64';
7855
+ ctx.fillStyle = '#00D4AA';
7582
7856
  ctx.font = 'bold 28px monospace';
7583
7857
  ctx.textAlign = 'center';
7584
7858
  ctx.fillText('GAME OVER', W / 2, H / 2 - 30);
@@ -7679,6 +7953,7 @@ function saveChatSettings() {
7679
7953
  maxDocs: parseInt(document.getElementById('chatMaxDocs').value) || 5,
7680
7954
  rerank: document.getElementById('chatRerank').classList.contains('active'),
7681
7955
  systemPrompt: document.getElementById('chatSystemPrompt').value.trim(),
7956
+ mode: document.getElementById('chatMode').value,
7682
7957
  };
7683
7958
  fetch('/api/chat/config', {
7684
7959
  method: 'POST',
@@ -7727,6 +8002,7 @@ async function loadChatConfig() {
7727
8002
  if (data.chat?.maxContextDocs) document.getElementById('chatMaxDocs').value = data.chat.maxContextDocs;
7728
8003
  if (data.chat?.rerank === false) document.getElementById('chatRerank').classList.remove('active');
7729
8004
  if (data.chat?.systemPrompt) document.getElementById('chatSystemPrompt').value = data.chat.systemPrompt;
8005
+ if (data.mode || data.chat?.mode) document.getElementById('chatMode').value = data.mode || data.chat?.mode || 'pipeline';
7730
8006
  updateChatStatus();
7731
8007
  // Show not-configured banner if incomplete
7732
8008
  const notConfigured = document.getElementById('chatNotConfigured');
@@ -7740,23 +8016,137 @@ async function loadChatConfig() {
7740
8016
  }
7741
8017
  }
7742
8018
 
8019
+ // ── Chat API Key Management ──
8020
+
8021
+ async function loadChatApiKey() {
8022
+ if (!window.vai?.llmKey) return;
8023
+ const apiKeyInput = document.getElementById('chatApiKey');
8024
+ if (!apiKeyInput) return;
8025
+
8026
+ try {
8027
+ const key = await window.vai.llmKey.get();
8028
+ if (key) {
8029
+ // Mask the key by default (show first 4 + last 4)
8030
+ const masked = key.slice(0, 4) + '•'.repeat(Math.max(0, key.length - 8)) + key.slice(-4);
8031
+ apiKeyInput.value = masked;
8032
+ apiKeyInput.dataset.actualKey = key;
8033
+ apiKeyInput.dataset.isMasked = 'true';
8034
+ apiKeyInput.type = 'password';
8035
+ } else {
8036
+ apiKeyInput.value = '';
8037
+ delete apiKeyInput.dataset.actualKey;
8038
+ delete apiKeyInput.dataset.isMasked;
8039
+ }
8040
+ } catch (err) {
8041
+ console.error('Failed to load LLM API key:', err);
8042
+ }
8043
+ }
8044
+
8045
+ function toggleChatApiKeyVisibility() {
8046
+ const apiKeyInput = document.getElementById('chatApiKey');
8047
+ const toggleBtn = document.getElementById('chatApiKeyToggle');
8048
+ if (!apiKeyInput || !toggleBtn) return;
8049
+
8050
+ if (apiKeyInput.dataset.isMasked === 'true' && apiKeyInput.dataset.actualKey) {
8051
+ // Show actual key
8052
+ apiKeyInput.value = apiKeyInput.dataset.actualKey;
8053
+ apiKeyInput.type = 'text';
8054
+ apiKeyInput.dataset.isMasked = 'false';
8055
+ toggleBtn.textContent = '🙈';
8056
+ toggleBtn.title = 'Hide API key';
8057
+ } else if (apiKeyInput.dataset.actualKey) {
8058
+ // Re-mask the key
8059
+ const key = apiKeyInput.dataset.actualKey;
8060
+ const masked = key.slice(0, 4) + '•'.repeat(Math.max(0, key.length - 8)) + key.slice(-4);
8061
+ apiKeyInput.value = masked;
8062
+ apiKeyInput.type = 'password';
8063
+ apiKeyInput.dataset.isMasked = 'true';
8064
+ toggleBtn.textContent = '👁️';
8065
+ toggleBtn.title = 'Show API key';
8066
+ } else {
8067
+ // No stored key, just toggle between text/password for new input
8068
+ apiKeyInput.type = apiKeyInput.type === 'password' ? 'text' : 'password';
8069
+ toggleBtn.textContent = apiKeyInput.type === 'password' ? '👁️' : '🙈';
8070
+ toggleBtn.title = apiKeyInput.type === 'password' ? 'Show API key' : 'Hide API key';
8071
+ }
8072
+ }
8073
+
8074
+ async function saveChatApiKey() {
8075
+ if (!window.vai?.llmKey) return;
8076
+ const apiKeyInput = document.getElementById('chatApiKey');
8077
+ const toggleBtn = document.getElementById('chatApiKeyToggle');
8078
+ if (!apiKeyInput) return;
8079
+
8080
+ let key = apiKeyInput.value.trim();
8081
+
8082
+ // If showing masked value and it hasn't been modified, no need to save
8083
+ if (apiKeyInput.dataset.isMasked === 'true' && apiKeyInput.dataset.actualKey) {
8084
+ const currentMasked = apiKeyInput.dataset.actualKey.slice(0, 4) + '•'.repeat(Math.max(0, apiKeyInput.dataset.actualKey.length - 8)) + apiKeyInput.dataset.actualKey.slice(-4);
8085
+ if (key === currentMasked) {
8086
+ flashSaved();
8087
+ return;
8088
+ }
8089
+ }
8090
+
8091
+ if (!key) {
8092
+ // Delete the key if empty
8093
+ try {
8094
+ await window.vai.llmKey.delete();
8095
+ delete apiKeyInput.dataset.actualKey;
8096
+ delete apiKeyInput.dataset.isMasked;
8097
+ flashSaved();
8098
+ } catch (err) {
8099
+ console.error('Failed to delete LLM API key:', err);
8100
+ alert('Failed to delete API key: ' + err.message);
8101
+ }
8102
+ return;
8103
+ }
8104
+
8105
+ try {
8106
+ await window.vai.llmKey.set(key);
8107
+ // Mask the newly saved key
8108
+ const masked = key.slice(0, 4) + '•'.repeat(Math.max(0, key.length - 8)) + key.slice(-4);
8109
+ apiKeyInput.value = masked;
8110
+ apiKeyInput.dataset.actualKey = key;
8111
+ apiKeyInput.dataset.isMasked = 'true';
8112
+ apiKeyInput.type = 'password';
8113
+ toggleBtn.textContent = '👁️';
8114
+ toggleBtn.title = 'Show API key';
8115
+ flashSaved();
8116
+ } catch (err) {
8117
+ console.error('Failed to save LLM API key:', err);
8118
+ alert('Failed to save API key: ' + err.message);
8119
+ }
8120
+ }
8121
+
7743
8122
  function updateChatStatus() {
7744
8123
  const provider = document.getElementById('chatProvider');
7745
8124
  const db = document.getElementById('chatDb').value;
7746
8125
  const collection = document.getElementById('chatCollection').value;
8126
+ const mode = document.getElementById('chatMode')?.value || 'pipeline';
7747
8127
  const providerLabel = provider.value
7748
8128
  ? provider.options[provider.selectedIndex].text
7749
8129
  : 'No provider';
7750
- document.getElementById('chatStatusProvider').textContent = providerLabel;
8130
+ const modeLabel = mode === 'agent' ? 'Agent' : 'Pipeline';
8131
+ document.getElementById('chatStatusProvider').textContent = `${providerLabel} (${modeLabel})`;
7751
8132
  document.getElementById('chatStatusDb').textContent =
7752
- (db && collection) ? `${db}.${collection}` : 'No database';
8133
+ (db && collection) ? `${db}.${collection}` : (mode === 'agent' ? 'Agent discovers' : 'No database');
7753
8134
  }
7754
8135
 
7755
8136
  async function chatProviderChanged() {
7756
8137
  updateChatStatus();
7757
8138
  const provider = document.getElementById('chatProvider').value;
7758
8139
  const modelSelect = document.getElementById('chatModel');
7759
-
8140
+ const apiKeyRow = document.getElementById('chatApiKeyRow');
8141
+
8142
+ // Show/hide API key row based on provider (not needed for Ollama)
8143
+ if (apiKeyRow) {
8144
+ apiKeyRow.style.display = (provider === 'anthropic' || provider === 'openai') ? 'flex' : 'none';
8145
+ if (provider === 'anthropic' || provider === 'openai') {
8146
+ await loadChatApiKey();
8147
+ }
8148
+ }
8149
+
7760
8150
  if (!provider) {
7761
8151
  modelSelect.innerHTML = '<option value="">Select provider first</option>';
7762
8152
  return;
@@ -7834,6 +8224,123 @@ function chatInputKeydown(e) {
7834
8224
  }
7835
8225
  }
7836
8226
 
8227
+ /**
8228
+ * Lightweight markdown-to-HTML renderer for assistant chat messages.
8229
+ * Handles: fenced code blocks, inline code, headings, bold, italic,
8230
+ * links, images, unordered/ordered lists, blockquotes, tables, and <hr>.
8231
+ * No external dependencies.
8232
+ */
8233
+ function renderMarkdown(md) {
8234
+ // Escape HTML to prevent XSS, then selectively render markdown
8235
+ function esc(s) {
8236
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
8237
+ }
8238
+
8239
+ // Extract fenced code blocks first (preserve content as-is)
8240
+ const codeBlocks = [];
8241
+ let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
8242
+ const idx = codeBlocks.length;
8243
+ codeBlocks.push(`<pre><code${lang ? ' data-lang="' + esc(lang) + '"' : ''}>${esc(code.replace(/\n$/, ''))}</code></pre>`);
8244
+ return '\x00CB' + idx + '\x00';
8245
+ });
8246
+
8247
+ // Escape remaining HTML
8248
+ text = esc(text);
8249
+
8250
+ // Restore code block placeholders
8251
+ text = text.replace(/\x00CB(\d+)\x00/g, (_, i) => codeBlocks[i]);
8252
+
8253
+ // Inline code (must come after escape but before other inline formatting)
8254
+ text = text.replace(/`([^`\n]+)`/g, '<code>$1</code>');
8255
+
8256
+ // Headings (must be at line start)
8257
+ text = text.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
8258
+ text = text.replace(/^### (.+)$/gm, '<h3>$1</h3>');
8259
+ text = text.replace(/^## (.+)$/gm, '<h2>$1</h2>');
8260
+ text = text.replace(/^# (.+)$/gm, '<h1>$1</h1>');
8261
+
8262
+ // Horizontal rule
8263
+ text = text.replace(/^---+$/gm, '<hr>');
8264
+
8265
+ // Bold and italic
8266
+ text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
8267
+ text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
8268
+ text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
8269
+
8270
+ // Links and images
8271
+ text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img alt="$1" src="$2" style="max-width:100%;border-radius:4px;">');
8272
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
8273
+
8274
+ // Tables (pipe-delimited)
8275
+ text = text.replace(/((?:^\|.+\|$\n?)+)/gm, (block) => {
8276
+ const rows = block.trim().split('\n').filter(r => r.trim());
8277
+ if (rows.length < 2) return block;
8278
+ // Check if second row is separator
8279
+ const sep = rows[1];
8280
+ if (!/^\|[\s:-]+\|$/.test(sep.trim().replace(/\|/g, '|'))) return block;
8281
+ const headerCells = rows[0].split('|').filter(c => c.trim() !== '').map(c => c.trim());
8282
+ let html = '<table><thead><tr>' + headerCells.map(c => '<th>' + c + '</th>').join('') + '</tr></thead><tbody>';
8283
+ for (let i = 2; i < rows.length; i++) {
8284
+ const cells = rows[i].split('|').filter(c => c.trim() !== '').map(c => c.trim());
8285
+ html += '<tr>' + cells.map(c => '<td>' + c + '</td>').join('') + '</tr>';
8286
+ }
8287
+ html += '</tbody></table>';
8288
+ return html;
8289
+ });
8290
+
8291
+ // Blockquotes
8292
+ text = text.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
8293
+ // Merge adjacent blockquotes
8294
+ text = text.replace(/<\/blockquote>\n<blockquote>/g, '\n');
8295
+
8296
+ // Unordered lists (lines starting with - or *)
8297
+ text = text.replace(/((?:^[\s]*[-*] .+$\n?)+)/gm, (block) => {
8298
+ const items = block.trim().split('\n').map(line => {
8299
+ const content = line.replace(/^[\s]*[-*] /, '');
8300
+ return '<li>' + content + '</li>';
8301
+ });
8302
+ return '<ul>' + items.join('') + '</ul>';
8303
+ });
8304
+
8305
+ // Ordered lists (lines starting with number.)
8306
+ text = text.replace(/((?:^[\s]*\d+\. .+$\n?)+)/gm, (block) => {
8307
+ const items = block.trim().split('\n').map(line => {
8308
+ const content = line.replace(/^[\s]*\d+\. /, '');
8309
+ return '<li>' + content + '</li>';
8310
+ });
8311
+ return '<ol>' + items.join('') + '</ol>';
8312
+ });
8313
+
8314
+ // Paragraphs: wrap remaining text lines that aren't already wrapped in block elements
8315
+ const blockTags = ['<h1', '<h2', '<h3', '<h4', '<hr', '<pre', '<ul', '<ol', '<table', '<blockquote'];
8316
+ const lines = text.split('\n');
8317
+ const result = [];
8318
+ let paraLines = [];
8319
+
8320
+ function flushPara() {
8321
+ if (paraLines.length > 0) {
8322
+ const content = paraLines.join('\n').trim();
8323
+ if (content) result.push('<p>' + content + '</p>');
8324
+ paraLines = [];
8325
+ }
8326
+ }
8327
+
8328
+ for (const line of lines) {
8329
+ const trimmed = line.trim();
8330
+ if (trimmed === '') {
8331
+ flushPara();
8332
+ } else if (blockTags.some(tag => trimmed.startsWith(tag))) {
8333
+ flushPara();
8334
+ result.push(line);
8335
+ } else {
8336
+ paraLines.push(line);
8337
+ }
8338
+ }
8339
+ flushPara();
8340
+
8341
+ return result.join('\n');
8342
+ }
8343
+
7837
8344
  function addChatMessage(role, content, sources) {
7838
8345
  const container = document.getElementById('chatMessages');
7839
8346
  const div = document.createElement('div');
@@ -7877,12 +8384,14 @@ async function sendChatMessage() {
7877
8384
  const maxDocs = parseInt(document.getElementById('chatMaxDocs').value) || 5;
7878
8385
  const rerank = document.getElementById('chatRerank').classList.contains('active');
7879
8386
  const systemPrompt = document.getElementById('chatSystemPrompt').value.trim() || undefined;
8387
+ const mode = document.getElementById('chatMode')?.value || 'pipeline';
8388
+ const isAgent = mode === 'agent';
7880
8389
 
7881
8390
  if (!provider) {
7882
8391
  addChatMessage('system-msg', 'Please select an LLM provider in <a href="#" onclick="openChatSettings();return false;">Chat Settings</a>.');
7883
8392
  return;
7884
8393
  }
7885
- if (!db || !collection) {
8394
+ if (!isAgent && (!db || !collection)) {
7886
8395
  addChatMessage('system-msg', 'Please configure a database and collection in <a href="#" onclick="openChatSettings();return false;">Chat Settings</a>.');
7887
8396
  return;
7888
8397
  }
@@ -7895,7 +8404,7 @@ async function sendChatMessage() {
7895
8404
  // Show typing indicator
7896
8405
  const typing = document.createElement('div');
7897
8406
  typing.className = 'chat-typing';
7898
- typing.textContent = 'Thinking';
8407
+ typing.textContent = isAgent ? 'Agent working' : 'Thinking';
7899
8408
  document.getElementById('chatMessages').appendChild(typing);
7900
8409
 
7901
8410
  // Disable input
@@ -7907,7 +8416,7 @@ async function sendChatMessage() {
7907
8416
  const res = await fetch('/api/chat/message', {
7908
8417
  method: 'POST',
7909
8418
  headers: { 'Content-Type': 'application/json' },
7910
- body: JSON.stringify({ query, db, collection, provider, model, maxDocs, rerank, systemPrompt }),
8419
+ body: JSON.stringify({ query, db, collection, provider, model, maxDocs, rerank, systemPrompt, mode }),
7911
8420
  });
7912
8421
 
7913
8422
  if (!res.ok) {
@@ -7924,6 +8433,7 @@ async function sendChatMessage() {
7924
8433
  let assistantDiv = null;
7925
8434
  let fullText = '';
7926
8435
  let sources = [];
8436
+ let toolCallsDiv = null;
7927
8437
 
7928
8438
  while (true) {
7929
8439
  const { done, value } = await reader.read();
@@ -7946,6 +8456,37 @@ async function sendChatMessage() {
7946
8456
  typing.textContent = `Retrieved ${data.docs?.length || 0} docs (${data.timeMs}ms)`;
7947
8457
  }
7948
8458
 
8459
+ if (currentEvent === 'tool_call') {
8460
+ if (!toolCallsDiv) {
8461
+ toolCallsDiv = document.createElement('div');
8462
+ toolCallsDiv.className = 'chat-tool-calls';
8463
+ document.getElementById('chatMessages').appendChild(toolCallsDiv);
8464
+ }
8465
+ const tc = document.createElement('div');
8466
+ tc.className = 'chat-tool-call' + (data.error ? ' error' : '');
8467
+ const icon = document.createElement('span');
8468
+ icon.className = 'tool-icon';
8469
+ icon.textContent = '\u2699';
8470
+ tc.appendChild(icon);
8471
+ const nameSpan = document.createElement('span');
8472
+ nameSpan.className = 'tool-name';
8473
+ nameSpan.textContent = data.name;
8474
+ tc.appendChild(nameSpan);
8475
+ const timeSpan = document.createElement('span');
8476
+ timeSpan.className = 'tool-time';
8477
+ timeSpan.textContent = (data.timeMs || 0) + 'ms';
8478
+ tc.appendChild(timeSpan);
8479
+ if (data.error) {
8480
+ const errSpan = document.createElement('span');
8481
+ errSpan.className = 'tool-error';
8482
+ errSpan.textContent = data.error;
8483
+ tc.appendChild(errSpan);
8484
+ }
8485
+ toolCallsDiv.appendChild(tc);
8486
+ typing.textContent = 'Calling ' + data.name + '...';
8487
+ document.getElementById('chatMessages').scrollTop = document.getElementById('chatMessages').scrollHeight;
8488
+ }
8489
+
7949
8490
  if (currentEvent === 'chunk') {
7950
8491
  if (!assistantDiv) {
7951
8492
  typing.remove();
@@ -7957,6 +8498,12 @@ async function sendChatMessage() {
7957
8498
  }
7958
8499
 
7959
8500
  if (currentEvent === 'done') {
8501
+ // Render accumulated text as markdown for assistant messages
8502
+ if (assistantDiv && fullText) {
8503
+ const contentEl = assistantDiv.querySelector('.chat-message-content');
8504
+ contentEl.innerHTML = renderMarkdown(fullText);
8505
+ contentEl.classList.add('rendered');
8506
+ }
7960
8507
  sources = data.sources || [];
7961
8508
  if (sources.length > 0 && assistantDiv) {
7962
8509
  const details = document.createElement('details');
@@ -8022,6 +8569,9 @@ window.switchSettingsSection = switchSettingsSection;
8022
8569
  window.updateChatStatus = updateChatStatus;
8023
8570
  window.chatInputKeydown = chatInputKeydown;
8024
8571
  window.chatProviderChanged = chatProviderChanged;
8572
+ window.loadChatApiKey = loadChatApiKey;
8573
+ window.toggleChatApiKeyVisibility = toggleChatApiKeyVisibility;
8574
+ window.saveChatApiKey = saveChatApiKey;
8025
8575
 
8026
8576
  // ── Start ──
8027
8577
  init();
@@ -8141,13 +8691,13 @@ init();
8141
8691
  .bug-error{padding:10px 12px;background:rgba(255,107,107,0.15);border:1px solid rgba(255,107,107,0.3);border-radius:8px;color:var(--error);font-size:13px;margin-bottom:16px}
8142
8692
  .bug-actions{display:flex;gap:12px}
8143
8693
  .bug-actions button{flex:1;padding:12px 16px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s;border:none}
8144
- .bug-actions .primary{background:linear-gradient(135deg,var(--accent),#00c853);color:#000}
8145
- .bug-actions .primary:hover{box-shadow:0 4px 12px rgba(0,237,100,0.4)}
8694
+ .bug-actions .primary{background:linear-gradient(135deg,var(--accent),#5CE8CC);color:#000}
8695
+ .bug-actions .primary:hover{box-shadow:0 4px 12px rgba(0,212,170,0.4)}
8146
8696
  .bug-actions .primary:disabled{opacity:0.6;cursor:not-allowed}
8147
8697
  .bug-actions .secondary{background:rgba(255,255,255,0.1);color:var(--text);border:1px solid var(--border)}
8148
8698
  .bug-actions .secondary:hover{background:rgba(255,255,255,0.15)}
8149
8699
  .bug-success{padding:40px 20px;text-align:center}
8150
- .bug-success .icon{width:60px;height:60px;border-radius:50%;background:linear-gradient(135deg,var(--accent),#00c853);color:#000;font-size:32px;display:flex;align-items:center;justify-content:center;margin:0 auto 20px}
8700
+ .bug-success .icon{width:60px;height:60px;border-radius:50%;background:linear-gradient(135deg,var(--accent),#5CE8CC);color:#000;font-size:32px;display:flex;align-items:center;justify-content:center;margin:0 auto 20px}
8151
8701
  .bug-success h3{margin:0 0 12px;color:var(--accent-text);font-size:20px}
8152
8702
  .bug-success p{margin:8px 0;color:var(--text-muted)}
8153
8703
  .bug-success code{background:rgba(255,255,255,0.1);padding:4px 8px;border-radius:4px;font-size:12px;color:var(--accent)}
@@ -8203,7 +8753,7 @@ init();
8203
8753
 
8204
8754
  <script>
8205
8755
  (function() {
8206
- const BUG_API = 'https://vai.mlynn.org/api/bugs';
8756
+ const BUG_API = 'https://vaicli.com/api/bugs';
8207
8757
  const GITHUB_URL = 'https://github.com/mrlynn/voyageai-cli/issues/new';
8208
8758
 
8209
8759
  function getEnv() {