voyageai-cli 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1111 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>🧭 Voyage AI Playground</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #1a1a2e;
12
+ --bg-surface: #16213e;
13
+ --bg-card: #1e2a47;
14
+ --bg-input: #0f1629;
15
+ --accent: #00d4aa;
16
+ --accent-dim: #00a88a;
17
+ --accent-glow: rgba(0, 212, 170, 0.15);
18
+ --text: #e0e0e0;
19
+ --text-dim: #8892a4;
20
+ --text-muted: #5a6478;
21
+ --border: #2a3550;
22
+ --error: #ff6b6b;
23
+ --warning: #ffd93d;
24
+ --success: #00d4aa;
25
+ --red: #ff6b6b;
26
+ --yellow: #ffd93d;
27
+ --green: #00d4aa;
28
+ --radius: 8px;
29
+ --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
30
+ --mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
31
+ }
32
+
33
+ html, body { height: 100%; }
34
+
35
+ body {
36
+ font-family: var(--font);
37
+ background: var(--bg);
38
+ color: var(--text);
39
+ line-height: 1.6;
40
+ overflow-x: hidden;
41
+ }
42
+
43
+ /* Nav */
44
+ .nav {
45
+ background: var(--bg-surface);
46
+ border-bottom: 1px solid var(--border);
47
+ padding: 0 24px;
48
+ height: 56px;
49
+ display: flex;
50
+ align-items: center;
51
+ gap: 16px;
52
+ position: sticky;
53
+ top: 0;
54
+ z-index: 100;
55
+ }
56
+
57
+ .nav-title {
58
+ font-size: 18px;
59
+ font-weight: 700;
60
+ color: var(--accent);
61
+ white-space: nowrap;
62
+ }
63
+
64
+ .nav-spacer { flex: 1; }
65
+
66
+ .status-dot {
67
+ width: 8px; height: 8px;
68
+ border-radius: 50%;
69
+ background: var(--text-muted);
70
+ transition: background 0.3s;
71
+ }
72
+ .status-dot.connected { background: var(--success); box-shadow: 0 0 8px var(--accent-glow); }
73
+ .status-dot.error { background: var(--error); }
74
+
75
+ .status-label {
76
+ font-size: 12px;
77
+ color: var(--text-dim);
78
+ }
79
+
80
+ .nav-model-select {
81
+ background: var(--bg-input);
82
+ border: 1px solid var(--border);
83
+ color: var(--text);
84
+ padding: 6px 12px;
85
+ border-radius: var(--radius);
86
+ font-size: 13px;
87
+ font-family: var(--mono);
88
+ cursor: pointer;
89
+ }
90
+
91
+ /* Tabs */
92
+ .tab-bar {
93
+ display: flex;
94
+ background: var(--bg-surface);
95
+ border-bottom: 1px solid var(--border);
96
+ padding: 0 24px;
97
+ gap: 0;
98
+ }
99
+
100
+ .tab-btn {
101
+ background: none;
102
+ border: none;
103
+ color: var(--text-dim);
104
+ padding: 12px 20px;
105
+ font-size: 14px;
106
+ font-family: var(--font);
107
+ cursor: pointer;
108
+ border-bottom: 2px solid transparent;
109
+ transition: all 0.2s;
110
+ white-space: nowrap;
111
+ }
112
+ .tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.03); }
113
+ .tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
114
+
115
+ /* Main */
116
+ .main { padding: 24px; max-width: 1200px; margin: 0 auto; }
117
+
118
+ .tab-panel { display: none; }
119
+ .tab-panel.active { display: block; }
120
+
121
+ /* Shared Components */
122
+ .card {
123
+ background: var(--bg-card);
124
+ border: 1px solid var(--border);
125
+ border-radius: var(--radius);
126
+ padding: 20px;
127
+ margin-bottom: 16px;
128
+ }
129
+
130
+ .card-title {
131
+ font-size: 14px;
132
+ font-weight: 600;
133
+ color: var(--accent);
134
+ margin-bottom: 12px;
135
+ text-transform: uppercase;
136
+ letter-spacing: 0.5px;
137
+ }
138
+
139
+ textarea, input[type="text"], input[type="number"] {
140
+ width: 100%;
141
+ background: var(--bg-input);
142
+ border: 1px solid var(--border);
143
+ color: var(--text);
144
+ padding: 10px 14px;
145
+ border-radius: var(--radius);
146
+ font-family: var(--mono);
147
+ font-size: 13px;
148
+ resize: vertical;
149
+ transition: border-color 0.2s;
150
+ }
151
+ textarea:focus, input:focus {
152
+ outline: none;
153
+ border-color: var(--accent);
154
+ box-shadow: 0 0 0 2px var(--accent-glow);
155
+ }
156
+
157
+ select {
158
+ background: var(--bg-input);
159
+ border: 1px solid var(--border);
160
+ color: var(--text);
161
+ padding: 8px 12px;
162
+ border-radius: var(--radius);
163
+ font-size: 13px;
164
+ font-family: var(--font);
165
+ cursor: pointer;
166
+ }
167
+ select:focus { outline: none; border-color: var(--accent); }
168
+
169
+ .btn {
170
+ background: var(--accent);
171
+ color: #0a0a1a;
172
+ border: none;
173
+ padding: 10px 24px;
174
+ border-radius: var(--radius);
175
+ font-size: 14px;
176
+ font-weight: 600;
177
+ font-family: var(--font);
178
+ cursor: pointer;
179
+ transition: all 0.2s;
180
+ display: inline-flex;
181
+ align-items: center;
182
+ gap: 8px;
183
+ }
184
+ .btn:hover { background: #00eabb; transform: translateY(-1px); }
185
+ .btn:active { transform: translateY(0); }
186
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
187
+
188
+ .btn-secondary {
189
+ background: var(--bg-input);
190
+ color: var(--accent);
191
+ border: 1px solid var(--accent-dim);
192
+ }
193
+ .btn-secondary:hover { background: var(--accent-glow); }
194
+
195
+ .btn-small {
196
+ padding: 6px 14px;
197
+ font-size: 12px;
198
+ }
199
+
200
+ .options-row {
201
+ display: flex;
202
+ gap: 12px;
203
+ align-items: center;
204
+ flex-wrap: wrap;
205
+ margin: 12px 0;
206
+ }
207
+
208
+ .option-group {
209
+ display: flex;
210
+ flex-direction: column;
211
+ gap: 4px;
212
+ }
213
+ .option-label {
214
+ font-size: 11px;
215
+ color: var(--text-dim);
216
+ text-transform: uppercase;
217
+ letter-spacing: 0.5px;
218
+ }
219
+
220
+ .error-msg {
221
+ background: rgba(255, 107, 107, 0.1);
222
+ border: 1px solid rgba(255, 107, 107, 0.3);
223
+ color: var(--error);
224
+ padding: 10px 14px;
225
+ border-radius: var(--radius);
226
+ font-size: 13px;
227
+ margin-top: 12px;
228
+ display: none;
229
+ }
230
+ .error-msg.visible { display: block; }
231
+
232
+ .spinner {
233
+ display: inline-block;
234
+ width: 16px; height: 16px;
235
+ border: 2px solid transparent;
236
+ border-top-color: currentColor;
237
+ border-radius: 50%;
238
+ animation: spin 0.6s linear infinite;
239
+ }
240
+ @keyframes spin { to { transform: rotate(360deg); } }
241
+
242
+ .result-section {
243
+ margin-top: 16px;
244
+ display: none;
245
+ }
246
+ .result-section.visible { display: block; }
247
+
248
+ .stat {
249
+ display: inline-flex;
250
+ align-items: center;
251
+ gap: 6px;
252
+ background: var(--bg-input);
253
+ padding: 6px 12px;
254
+ border-radius: var(--radius);
255
+ font-size: 13px;
256
+ margin-right: 8px;
257
+ margin-bottom: 8px;
258
+ }
259
+ .stat-label { color: var(--text-dim); }
260
+ .stat-value { color: var(--accent); font-weight: 600; font-family: var(--mono); }
261
+
262
+ .vector-preview {
263
+ font-family: var(--mono);
264
+ font-size: 12px;
265
+ color: var(--text-dim);
266
+ background: var(--bg-input);
267
+ padding: 12px;
268
+ border-radius: var(--radius);
269
+ overflow-x: auto;
270
+ white-space: nowrap;
271
+ margin-top: 8px;
272
+ }
273
+
274
+ .heatmap {
275
+ height: 24px;
276
+ border-radius: 4px;
277
+ margin-top: 8px;
278
+ overflow: hidden;
279
+ display: flex;
280
+ }
281
+ .heatmap-bar {
282
+ flex: 1;
283
+ min-width: 1px;
284
+ }
285
+
286
+ /* Compare tab */
287
+ .compare-grid {
288
+ display: grid;
289
+ grid-template-columns: 1fr 1fr;
290
+ gap: 16px;
291
+ }
292
+
293
+ .similarity-display {
294
+ text-align: center;
295
+ padding: 32px 0;
296
+ }
297
+ .similarity-score {
298
+ font-size: 72px;
299
+ font-weight: 800;
300
+ font-family: var(--mono);
301
+ line-height: 1;
302
+ }
303
+ .similarity-label {
304
+ font-size: 14px;
305
+ color: var(--text-dim);
306
+ margin-top: 8px;
307
+ }
308
+ .similarity-bar-outer {
309
+ width: 100%;
310
+ max-width: 400px;
311
+ height: 8px;
312
+ background: var(--bg-input);
313
+ border-radius: 4px;
314
+ margin: 16px auto 0;
315
+ overflow: hidden;
316
+ }
317
+ .similarity-bar-inner {
318
+ height: 100%;
319
+ border-radius: 4px;
320
+ transition: width 0.6s ease, background 0.6s ease;
321
+ }
322
+
323
+ /* Search tab */
324
+ .search-results {
325
+ display: grid;
326
+ grid-template-columns: 1fr 1fr;
327
+ gap: 16px;
328
+ }
329
+ .search-results.single-col {
330
+ grid-template-columns: 1fr;
331
+ }
332
+
333
+ .result-item {
334
+ display: flex;
335
+ gap: 12px;
336
+ padding: 12px;
337
+ background: var(--bg-input);
338
+ border-radius: var(--radius);
339
+ margin-bottom: 8px;
340
+ border-left: 3px solid var(--border);
341
+ transition: border-color 0.3s;
342
+ }
343
+ .result-item.moved-up { border-left-color: var(--green); }
344
+ .result-item.moved-down { border-left-color: var(--red); }
345
+
346
+ .result-rank {
347
+ font-size: 20px;
348
+ font-weight: 700;
349
+ color: var(--accent);
350
+ font-family: var(--mono);
351
+ min-width: 30px;
352
+ }
353
+ .result-body { flex: 1; min-width: 0; }
354
+ .result-text {
355
+ font-size: 13px;
356
+ color: var(--text);
357
+ overflow: hidden;
358
+ text-overflow: ellipsis;
359
+ white-space: nowrap;
360
+ }
361
+ .result-score-bar {
362
+ height: 4px;
363
+ background: var(--bg-surface);
364
+ border-radius: 2px;
365
+ margin-top: 6px;
366
+ overflow: hidden;
367
+ }
368
+ .result-score-fill {
369
+ height: 100%;
370
+ border-radius: 2px;
371
+ background: var(--accent);
372
+ }
373
+ .result-score-text {
374
+ font-size: 11px;
375
+ font-family: var(--mono);
376
+ color: var(--text-dim);
377
+ margin-top: 4px;
378
+ }
379
+ .result-movement {
380
+ font-size: 11px;
381
+ font-family: var(--mono);
382
+ margin-top: 2px;
383
+ }
384
+
385
+ /* Explore tab */
386
+ .explore-grid {
387
+ display: grid;
388
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
389
+ gap: 16px;
390
+ }
391
+
392
+ .explore-card {
393
+ background: var(--bg-card);
394
+ border: 1px solid var(--border);
395
+ border-radius: var(--radius);
396
+ padding: 20px;
397
+ cursor: pointer;
398
+ transition: all 0.2s;
399
+ }
400
+ .explore-card:hover {
401
+ border-color: var(--accent);
402
+ transform: translateY(-2px);
403
+ box-shadow: 0 4px 20px rgba(0, 212, 170, 0.1);
404
+ }
405
+ .explore-card.expanded {
406
+ grid-column: 1 / -1;
407
+ cursor: default;
408
+ }
409
+
410
+ .explore-card-icon {
411
+ font-size: 28px;
412
+ margin-bottom: 8px;
413
+ }
414
+ .explore-card-title {
415
+ font-size: 16px;
416
+ font-weight: 600;
417
+ color: var(--text);
418
+ margin-bottom: 4px;
419
+ }
420
+ .explore-card-summary {
421
+ font-size: 13px;
422
+ color: var(--text-dim);
423
+ }
424
+ .explore-card-content {
425
+ display: none;
426
+ margin-top: 16px;
427
+ font-size: 14px;
428
+ line-height: 1.7;
429
+ color: var(--text);
430
+ white-space: pre-wrap;
431
+ }
432
+ .explore-card.expanded .explore-card-content { display: block; }
433
+
434
+ .explore-card-actions {
435
+ display: none;
436
+ margin-top: 16px;
437
+ gap: 8px;
438
+ }
439
+ .explore-card.expanded .explore-card-actions { display: flex; }
440
+
441
+ @media (max-width: 768px) {
442
+ .compare-grid, .search-results { grid-template-columns: 1fr; }
443
+ .nav { padding: 0 12px; }
444
+ .main { padding: 16px; }
445
+ .tab-btn { padding: 10px 14px; font-size: 13px; }
446
+ }
447
+ </style>
448
+ </head>
449
+ <body>
450
+
451
+ <!-- Nav -->
452
+ <nav class="nav">
453
+ <div class="nav-title">🧭 Voyage AI Playground</div>
454
+ <div class="nav-spacer"></div>
455
+ <div class="option-group">
456
+ <span class="option-label">Default Model</span>
457
+ <select id="globalModel" class="nav-model-select"></select>
458
+ </div>
459
+ <div style="display:flex;align-items:center;gap:6px;">
460
+ <div class="status-dot" id="statusDot"></div>
461
+ <span class="status-label" id="statusLabel">Checking...</span>
462
+ </div>
463
+ </nav>
464
+
465
+ <!-- Tabs -->
466
+ <div class="tab-bar">
467
+ <button class="tab-btn active" data-tab="embed">⚡ Embed</button>
468
+ <button class="tab-btn" data-tab="compare">⚖️ Compare</button>
469
+ <button class="tab-btn" data-tab="search">🔍 Search</button>
470
+ <button class="tab-btn" data-tab="explore">📚 Explore</button>
471
+ </div>
472
+
473
+ <div class="main">
474
+
475
+ <!-- ========== EMBED TAB ========== -->
476
+ <div class="tab-panel active" id="tab-embed">
477
+ <div class="card">
478
+ <div class="card-title">Input Text</div>
479
+ <textarea id="embedInput" rows="5" placeholder="Enter text to embed...">MongoDB Atlas provides powerful vector search capabilities for AI applications.</textarea>
480
+ </div>
481
+
482
+ <div class="options-row">
483
+ <div class="option-group">
484
+ <span class="option-label">Model</span>
485
+ <select id="embedModel"></select>
486
+ </div>
487
+ <div class="option-group">
488
+ <span class="option-label">Input Type</span>
489
+ <select id="embedInputType">
490
+ <option value="">None</option>
491
+ <option value="query">Query</option>
492
+ <option value="document">Document</option>
493
+ </select>
494
+ </div>
495
+ <div class="option-group">
496
+ <span class="option-label">Dimensions</span>
497
+ <select id="embedDimensions">
498
+ <option value="">Default</option>
499
+ <option value="256">256</option>
500
+ <option value="512">512</option>
501
+ <option value="1024">1024</option>
502
+ <option value="2048">2048</option>
503
+ </select>
504
+ </div>
505
+ <button class="btn" id="embedBtn" onclick="doEmbed()">⚡ Embed</button>
506
+ </div>
507
+
508
+ <div class="error-msg" id="embedError"></div>
509
+
510
+ <div class="result-section" id="embedResult">
511
+ <div class="card">
512
+ <div class="card-title">Result</div>
513
+ <div id="embedStats"></div>
514
+ <div class="vector-preview" id="embedVector"></div>
515
+ <div style="margin-top:8px;">
516
+ <button class="btn btn-secondary btn-small" onclick="copyVector()">📋 Copy Full Vector</button>
517
+ </div>
518
+ <div class="card-title" style="margin-top:16px;">Vector Heatmap</div>
519
+ <div class="heatmap" id="embedHeatmap"></div>
520
+ </div>
521
+ </div>
522
+ </div>
523
+
524
+ <!-- ========== COMPARE TAB ========== -->
525
+ <div class="tab-panel" id="tab-compare">
526
+ <div class="compare-grid">
527
+ <div class="card">
528
+ <div class="card-title">Text A</div>
529
+ <textarea id="compareA" rows="5" placeholder="Enter first text...">MongoDB is a popular NoSQL database</textarea>
530
+ </div>
531
+ <div class="card">
532
+ <div class="card-title">Text B</div>
533
+ <textarea id="compareB" rows="5" placeholder="Enter second text...">Document databases store data as JSON-like objects</textarea>
534
+ </div>
535
+ </div>
536
+
537
+ <div class="options-row">
538
+ <div class="option-group">
539
+ <span class="option-label">Model</span>
540
+ <select id="compareModel"></select>
541
+ </div>
542
+ <div class="option-group">
543
+ <span class="option-label">Dimensions</span>
544
+ <select id="compareDimensions">
545
+ <option value="">Default</option>
546
+ <option value="256">256</option>
547
+ <option value="512">512</option>
548
+ <option value="1024">1024</option>
549
+ <option value="2048">2048</option>
550
+ </select>
551
+ </div>
552
+ <button class="btn" id="compareBtn" onclick="doCompare()">⚖️ Compare</button>
553
+ </div>
554
+
555
+ <div class="error-msg" id="compareError"></div>
556
+
557
+ <div class="result-section" id="compareResult">
558
+ <div class="card">
559
+ <div class="similarity-display">
560
+ <div class="similarity-score" id="simScore">—</div>
561
+ <div class="similarity-label">Cosine Similarity</div>
562
+ <div class="similarity-bar-outer">
563
+ <div class="similarity-bar-inner" id="simBar" style="width:0%"></div>
564
+ </div>
565
+ </div>
566
+ <div id="compareStats" style="text-align:center;"></div>
567
+ </div>
568
+ </div>
569
+ </div>
570
+
571
+ <!-- ========== SEARCH TAB ========== -->
572
+ <div class="tab-panel" id="tab-search">
573
+ <div class="card">
574
+ <div class="card-title">Query</div>
575
+ <input type="text" id="searchQuery" placeholder="Enter your search query..." value="How do I build AI-powered search?">
576
+ </div>
577
+
578
+ <div class="card">
579
+ <div class="card-title">Documents (one per line)</div>
580
+ <textarea id="searchDocs" rows="8" placeholder="Enter documents, one per line...">MongoDB Atlas provides vector search capabilities
581
+ The recipe calls for two cups of flour and three eggs
582
+ Voyage AI embeddings power semantic retrieval for modern applications
583
+ Python is a popular programming language for data science
584
+ Atlas Search combines full-text and vector search in one platform
585
+ Machine learning models can be deployed at the edge
586
+ Semantic search understands meaning beyond keyword matching</textarea>
587
+ </div>
588
+
589
+ <div class="options-row">
590
+ <div class="option-group">
591
+ <span class="option-label">Embedding Model</span>
592
+ <select id="searchEmbedModel"></select>
593
+ </div>
594
+ <div class="option-group">
595
+ <span class="option-label">Rerank Model</span>
596
+ <select id="searchRerankModel"></select>
597
+ </div>
598
+ <div class="option-group">
599
+ <span class="option-label">Top K</span>
600
+ <select id="searchTopK">
601
+ <option value="3">3</option>
602
+ <option value="5" selected>5</option>
603
+ <option value="10">10</option>
604
+ </select>
605
+ </div>
606
+ <button class="btn" id="searchBtn" onclick="doSearch(false)">🔍 Search</button>
607
+ <button class="btn btn-secondary" id="searchRerankBtn" onclick="doSearch(true)">🔍+ Rerank</button>
608
+ </div>
609
+
610
+ <div class="error-msg" id="searchError"></div>
611
+
612
+ <div class="result-section" id="searchResult">
613
+ <div class="search-results" id="searchResultGrid"></div>
614
+ </div>
615
+ </div>
616
+
617
+ <!-- ========== EXPLORE TAB ========== -->
618
+ <div class="tab-panel" id="tab-explore">
619
+ <div class="explore-grid" id="exploreGrid"></div>
620
+ </div>
621
+
622
+ </div><!-- .main -->
623
+
624
+ <script>
625
+ (function() {
626
+ 'use strict';
627
+
628
+ // ── State ──
629
+ let allModels = [];
630
+ let embedModels = [];
631
+ let rerankModels = [];
632
+ let lastEmbedding = null;
633
+
634
+ // ── Init ──
635
+ async function init() {
636
+ setupTabs();
637
+ await loadConfig();
638
+ await loadModels();
639
+ populateModelSelects();
640
+ buildExploreCards();
641
+ }
642
+
643
+ // ── Tabs ──
644
+ function setupTabs() {
645
+ document.querySelectorAll('.tab-btn').forEach(btn => {
646
+ btn.addEventListener('click', () => {
647
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
648
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
649
+ btn.classList.add('active');
650
+ document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
651
+ });
652
+ });
653
+ }
654
+
655
+ function switchTab(tab) {
656
+ document.querySelectorAll('.tab-btn').forEach(b => {
657
+ b.classList.toggle('active', b.dataset.tab === tab);
658
+ });
659
+ document.querySelectorAll('.tab-panel').forEach(p => {
660
+ p.classList.toggle('active', p.id === 'tab-' + tab);
661
+ });
662
+ }
663
+
664
+ // ── Config ──
665
+ async function loadConfig() {
666
+ try {
667
+ const res = await fetch('/api/config');
668
+ const data = await res.json();
669
+ const dot = document.getElementById('statusDot');
670
+ const label = document.getElementById('statusLabel');
671
+ if (data.hasKey) {
672
+ dot.className = 'status-dot connected';
673
+ label.textContent = 'Connected';
674
+ } else {
675
+ dot.className = 'status-dot error';
676
+ label.textContent = 'No API Key';
677
+ }
678
+ } catch {
679
+ document.getElementById('statusDot').className = 'status-dot error';
680
+ document.getElementById('statusLabel').textContent = 'Error';
681
+ }
682
+ }
683
+
684
+ // ── Models ──
685
+ async function loadModels() {
686
+ try {
687
+ const res = await fetch('/api/models');
688
+ const data = await res.json();
689
+ allModels = data.models || [];
690
+ embedModels = allModels.filter(m => m.type === 'embedding');
691
+ rerankModels = allModels.filter(m => m.type === 'reranking');
692
+ } catch {
693
+ console.error('Failed to load models');
694
+ }
695
+ }
696
+
697
+ function populateModelSelects() {
698
+ const saved = localStorage.getItem('vai-playground-model');
699
+ const embedSelects = ['globalModel', 'embedModel', 'compareModel', 'searchEmbedModel'];
700
+ embedSelects.forEach(id => {
701
+ const sel = document.getElementById(id);
702
+ sel.innerHTML = '';
703
+ embedModels.forEach(m => {
704
+ const opt = document.createElement('option');
705
+ opt.value = m.name;
706
+ opt.textContent = m.name + ' — ' + m.shortFor;
707
+ sel.appendChild(opt);
708
+ });
709
+ if (saved) sel.value = saved;
710
+ });
711
+
712
+ const rerankSel = document.getElementById('searchRerankModel');
713
+ rerankSel.innerHTML = '';
714
+ rerankModels.forEach(m => {
715
+ const opt = document.createElement('option');
716
+ opt.value = m.name;
717
+ opt.textContent = m.name + ' — ' + m.shortFor;
718
+ rerankSel.appendChild(opt);
719
+ });
720
+
721
+ // Sync global model to others
722
+ document.getElementById('globalModel').addEventListener('change', function() {
723
+ const v = this.value;
724
+ localStorage.setItem('vai-playground-model', v);
725
+ ['embedModel', 'compareModel', 'searchEmbedModel'].forEach(id => {
726
+ document.getElementById(id).value = v;
727
+ });
728
+ });
729
+ }
730
+
731
+ // ── API Helpers ──
732
+ async function apiPost(url, body) {
733
+ const res = await fetch(url, {
734
+ method: 'POST',
735
+ headers: { 'Content-Type': 'application/json' },
736
+ body: JSON.stringify(body),
737
+ });
738
+ const data = await res.json();
739
+ if (!res.ok) throw new Error(data.error || data.detail || 'API error');
740
+ return data;
741
+ }
742
+
743
+ function showError(id, msg) {
744
+ const el = document.getElementById(id);
745
+ el.textContent = '⚠ ' + msg;
746
+ el.classList.add('visible');
747
+ }
748
+
749
+ function hideError(id) {
750
+ document.getElementById(id).classList.remove('visible');
751
+ }
752
+
753
+ function setLoading(btnId, loading) {
754
+ const btn = document.getElementById(btnId);
755
+ if (loading) {
756
+ btn.disabled = true;
757
+ btn._origHTML = btn.innerHTML;
758
+ btn.innerHTML = '<span class="spinner"></span> Working...';
759
+ } else {
760
+ btn.disabled = false;
761
+ btn.innerHTML = btn._origHTML || btn.innerHTML;
762
+ }
763
+ }
764
+
765
+ // ── Embed ──
766
+ window.doEmbed = async function() {
767
+ hideError('embedError');
768
+ const text = document.getElementById('embedInput').value.trim();
769
+ if (!text) { showError('embedError', 'Enter some text to embed'); return; }
770
+
771
+ setLoading('embedBtn', true);
772
+ try {
773
+ const model = document.getElementById('embedModel').value;
774
+ const inputType = document.getElementById('embedInputType').value || undefined;
775
+ const dims = document.getElementById('embedDimensions').value;
776
+ const dimensions = dims ? parseInt(dims, 10) : undefined;
777
+
778
+ const data = await apiPost('/api/embed', { texts: [text], model, inputType, dimensions });
779
+ const emb = data.data[0].embedding;
780
+ lastEmbedding = emb;
781
+
782
+ // Stats
783
+ const statsEl = document.getElementById('embedStats');
784
+ statsEl.innerHTML = `
785
+ <span class="stat"><span class="stat-label">Model</span><span class="stat-value">${data.model}</span></span>
786
+ <span class="stat"><span class="stat-label">Dimensions</span><span class="stat-value">${emb.length}</span></span>
787
+ <span class="stat"><span class="stat-label">Tokens</span><span class="stat-value">${data.usage?.total_tokens || '—'}</span></span>
788
+ `;
789
+
790
+ // Vector preview
791
+ const preview = emb.slice(0, 20).map(v => v.toFixed(6)).join(', ');
792
+ document.getElementById('embedVector').textContent = `[${preview}, ... ] (${emb.length} values)`;
793
+
794
+ // Heatmap
795
+ buildHeatmap(emb, document.getElementById('embedHeatmap'));
796
+
797
+ document.getElementById('embedResult').classList.add('visible');
798
+ } catch (err) {
799
+ showError('embedError', err.message);
800
+ } finally {
801
+ setLoading('embedBtn', false);
802
+ }
803
+ };
804
+
805
+ window.copyVector = function() {
806
+ if (!lastEmbedding) return;
807
+ navigator.clipboard.writeText(JSON.stringify(lastEmbedding)).then(() => {
808
+ const btn = document.querySelector('[onclick="copyVector()"]');
809
+ const orig = btn.textContent;
810
+ btn.textContent = '✅ Copied!';
811
+ setTimeout(() => btn.textContent = orig, 1500);
812
+ });
813
+ };
814
+
815
+ function buildHeatmap(vec, container) {
816
+ container.innerHTML = '';
817
+ // Sample down to ~200 bars for visual
818
+ const step = Math.max(1, Math.floor(vec.length / 200));
819
+ const sampled = [];
820
+ for (let i = 0; i < vec.length; i += step) sampled.push(vec[i]);
821
+
822
+ const min = Math.min(...sampled);
823
+ const max = Math.max(...sampled);
824
+ const range = max - min || 1;
825
+
826
+ sampled.forEach(v => {
827
+ const bar = document.createElement('div');
828
+ bar.className = 'heatmap-bar';
829
+ const norm = (v - min) / range; // 0-1
830
+ const h = Math.round(norm * 160); // hue: 0=red, 80=yellow-green, 160=cyan/teal
831
+ bar.style.background = `hsl(${h}, 80%, 50%)`;
832
+ container.appendChild(bar);
833
+ });
834
+ }
835
+
836
+ // ── Compare ──
837
+ window.doCompare = async function() {
838
+ hideError('compareError');
839
+ const a = document.getElementById('compareA').value.trim();
840
+ const b = document.getElementById('compareB').value.trim();
841
+ if (!a || !b) { showError('compareError', 'Enter text in both fields'); return; }
842
+
843
+ setLoading('compareBtn', true);
844
+ try {
845
+ const model = document.getElementById('compareModel').value;
846
+ const dims = document.getElementById('compareDimensions').value;
847
+ const dimensions = dims ? parseInt(dims, 10) : undefined;
848
+
849
+ const data = await apiPost('/api/similarity', { texts: [a, b], model, dimensions });
850
+ const sim = data.matrix[0][1];
851
+ const pct = Math.max(0, sim * 100);
852
+
853
+ // Color
854
+ let color;
855
+ if (sim > 0.7) color = 'var(--green)';
856
+ else if (sim > 0.4) color = 'var(--yellow)';
857
+ else color = 'var(--red)';
858
+
859
+ const scoreEl = document.getElementById('simScore');
860
+ scoreEl.textContent = sim.toFixed(4);
861
+ scoreEl.style.color = color;
862
+
863
+ const barEl = document.getElementById('simBar');
864
+ barEl.style.width = pct + '%';
865
+ barEl.style.background = color;
866
+
867
+ // Stats
868
+ const statsEl = document.getElementById('compareStats');
869
+ statsEl.innerHTML = `
870
+ <span class="stat"><span class="stat-label">Model</span><span class="stat-value">${data.model}</span></span>
871
+ <span class="stat"><span class="stat-label">Tokens</span><span class="stat-value">${data.usage?.total_tokens || '—'}</span></span>
872
+ `;
873
+
874
+ document.getElementById('compareResult').classList.add('visible');
875
+ } catch (err) {
876
+ showError('compareError', err.message);
877
+ } finally {
878
+ setLoading('compareBtn', false);
879
+ }
880
+ };
881
+
882
+ // ── Search ──
883
+ window.doSearch = async function(withRerank) {
884
+ hideError('searchError');
885
+ const query = document.getElementById('searchQuery').value.trim();
886
+ const docsText = document.getElementById('searchDocs').value.trim();
887
+ if (!query || !docsText) { showError('searchError', 'Enter a query and documents'); return; }
888
+
889
+ const documents = docsText.split('\n').map(d => d.trim()).filter(d => d);
890
+ if (documents.length < 2) { showError('searchError', 'Enter at least 2 documents'); return; }
891
+
892
+ const btnId = withRerank ? 'searchRerankBtn' : 'searchBtn';
893
+ setLoading('searchBtn', true);
894
+ setLoading('searchRerankBtn', true);
895
+
896
+ try {
897
+ const embedModel = document.getElementById('searchEmbedModel').value;
898
+ const topK = parseInt(document.getElementById('searchTopK').value, 10);
899
+
900
+ // Embed query + docs
901
+ const allTexts = [query, ...documents];
902
+ const embedData = await apiPost('/api/embed', { texts: allTexts, model: embedModel });
903
+ const vecs = embedData.data.map(d => d.embedding);
904
+ const queryVec = vecs[0];
905
+ const docVecs = vecs.slice(1);
906
+
907
+ // Compute similarity
908
+ const scores = docVecs.map((dv, i) => ({
909
+ index: i,
910
+ text: documents[i],
911
+ score: cosineSim(queryVec, dv),
912
+ }));
913
+ scores.sort((a, b) => b.score - a.score);
914
+ const embeddingResults = scores.slice(0, topK);
915
+
916
+ let rerankResults = null;
917
+ if (withRerank) {
918
+ const rerankModel = document.getElementById('searchRerankModel').value;
919
+ const rerankData = await apiPost('/api/rerank', { query, documents, model: rerankModel, topK });
920
+ rerankResults = rerankData.data.map(r => ({
921
+ index: r.index,
922
+ text: documents[r.index],
923
+ score: r.relevance_score,
924
+ }));
925
+ }
926
+
927
+ renderSearchResults(embeddingResults, rerankResults);
928
+ document.getElementById('searchResult').classList.add('visible');
929
+ } catch (err) {
930
+ showError('searchError', err.message);
931
+ } finally {
932
+ setLoading('searchBtn', false);
933
+ setLoading('searchRerankBtn', false);
934
+ }
935
+ };
936
+
937
+ function cosineSim(a, b) {
938
+ let dot = 0, normA = 0, normB = 0;
939
+ for (let i = 0; i < a.length; i++) {
940
+ dot += a[i] * b[i];
941
+ normA += a[i] * a[i];
942
+ normB += b[i] * b[i];
943
+ }
944
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
945
+ }
946
+
947
+ function renderSearchResults(embResults, rerankResults) {
948
+ const grid = document.getElementById('searchResultGrid');
949
+ grid.innerHTML = '';
950
+ grid.className = rerankResults ? 'search-results' : 'search-results single-col';
951
+
952
+ const maxEmbScore = Math.max(...embResults.map(r => r.score));
953
+
954
+ // Embedding column
955
+ const embCol = document.createElement('div');
956
+ embCol.innerHTML = '<div class="card-title">Embedding Search</div>';
957
+ embResults.forEach((r, i) => {
958
+ embCol.appendChild(createResultItem(i + 1, r, maxEmbScore));
959
+ });
960
+ grid.appendChild(embCol);
961
+
962
+ // Rerank column
963
+ if (rerankResults) {
964
+ const maxRerankScore = Math.max(...rerankResults.map(r => r.score));
965
+ const embRankMap = {};
966
+ embResults.forEach((r, i) => { embRankMap[r.index] = i + 1; });
967
+
968
+ const rerankCol = document.createElement('div');
969
+ rerankCol.innerHTML = '<div class="card-title">After Reranking</div>';
970
+ rerankResults.forEach((r, i) => {
971
+ const newRank = i + 1;
972
+ const oldRank = embRankMap[r.index];
973
+ const movement = oldRank !== undefined ? oldRank - newRank : 0;
974
+ rerankCol.appendChild(createResultItem(newRank, r, maxRerankScore, movement));
975
+ });
976
+ grid.appendChild(rerankCol);
977
+ }
978
+ }
979
+
980
+ function createResultItem(rank, result, maxScore, movement) {
981
+ const item = document.createElement('div');
982
+ let moveClass = '';
983
+ if (movement > 0) moveClass = ' moved-up';
984
+ else if (movement < 0) moveClass = ' moved-down';
985
+
986
+ const pct = maxScore > 0 ? (result.score / maxScore * 100) : 0;
987
+ const truncText = result.text.length > 80 ? result.text.slice(0, 77) + '...' : result.text;
988
+ let moveHTML = '';
989
+ if (movement !== undefined && movement !== 0) {
990
+ const arrow = movement > 0 ? '↑' : '↓';
991
+ const color = movement > 0 ? 'var(--green)' : 'var(--red)';
992
+ moveHTML = `<div class="result-movement" style="color:${color}">${arrow}${Math.abs(movement)}</div>`;
993
+ }
994
+
995
+ item.className = 'result-item' + moveClass;
996
+ item.innerHTML = `
997
+ <div class="result-rank">#${rank}</div>
998
+ <div class="result-body">
999
+ <div class="result-text" title="${result.text.replace(/"/g, '&quot;')}">${truncText}</div>
1000
+ <div class="result-score-bar"><div class="result-score-fill" style="width:${pct}%"></div></div>
1001
+ <div class="result-score-text">${result.score.toFixed(4)}</div>
1002
+ ${moveHTML}
1003
+ </div>
1004
+ `;
1005
+ return item;
1006
+ }
1007
+
1008
+ // ── Explore ──
1009
+ const exploreTopics = [
1010
+ {
1011
+ key: 'embeddings', icon: '🧮', title: 'Embeddings',
1012
+ summary: 'Numerical representations that capture meaning',
1013
+ content: 'Vector embeddings are arrays of floating-point numbers (typically 256–2048 dimensions) that capture the semantic meaning of text. When you embed text, a neural network reads the input and produces a fixed-size vector. Texts with similar meanings end up close together in this high-dimensional space, even if they share no words.\n\nHigher dimensions capture more nuance but cost more to store and search. Voyage 4 models default to 1024 dimensions but support 256–2048 via Matryoshka representation learning — you can truncate embeddings without retraining.',
1014
+ tab: 'embed', prefill: () => { document.getElementById('embedInput').value = 'Artificial intelligence is transforming how we build software applications.'; }
1015
+ },
1016
+ {
1017
+ key: 'reranking', icon: '🏆', title: 'Reranking',
1018
+ summary: 'Second-stage precision with cross-attention',
1019
+ content: 'Reranking re-scores candidate documents against a query using cross-attention — it reads the query and each document together, producing much more accurate relevance scores than embedding similarity alone.\n\nThe two-stage pattern: embedding search retrieves a broad set (high recall), then the reranker re-orders them (high precision). This adds ~50-200ms but dramatically improves result quality.',
1020
+ tab: 'search', prefill: () => {
1021
+ document.getElementById('searchQuery').value = 'How do I implement semantic search?';
1022
+ document.getElementById('searchDocs').value = 'MongoDB Atlas provides vector search capabilities\nThe recipe calls for two cups of flour\nSemantic search uses embeddings to find meaning\nVector databases store high-dimensional data\nThe weather forecast predicts rain tomorrow';
1023
+ }
1024
+ },
1025
+ {
1026
+ key: 'vector-search', icon: '🔎', title: 'Vector Search',
1027
+ summary: 'Finding documents by meaning, not keywords',
1028
+ content: 'Vector search finds documents whose embeddings are closest to a query embedding. Instead of matching keywords, it matches meaning. MongoDB Atlas Vector Search uses $vectorSearch with HNSW (Hierarchical Navigable Small World) graph indexes for fast approximate nearest neighbor search.\n\nSimilarity functions: cosine (direction, ignoring magnitude — best default), dotProduct (magnitude-sensitive), euclidean (straight-line distance).',
1029
+ tab: 'search', prefill: () => {}
1030
+ },
1031
+ {
1032
+ key: 'rag', icon: '🤖', title: 'RAG',
1033
+ summary: 'Retrieval-Augmented Generation',
1034
+ content: 'RAG combines retrieval with LLM generation: instead of relying on the LLM\'s training data alone, you retrieve relevant context from your own data and include it in the prompt.\n\nThe pattern: 1) Embed your corpus and store vectors, 2) Embed the user\'s question and run vector search, 3) Pass retrieved documents + question to an LLM. Adding reranking between steps 2 and 3 dramatically improves answer quality.',
1035
+ tab: 'search', prefill: () => {}
1036
+ },
1037
+ {
1038
+ key: 'cosine', icon: '📐', title: 'Cosine Similarity',
1039
+ summary: 'Measuring the angle between vectors',
1040
+ content: 'Cosine similarity measures the angle between two vectors, ignoring magnitude. Vectors pointing the same direction score 1, perpendicular score 0, opposite score -1.\n\nFor text embeddings (which are typically normalized), cosine similarity and dot product give identical rankings. Cosine is preferred because it\'s intuitive: it measures how similar the direction (meaning) is, regardless of scale.',
1041
+ tab: 'compare', prefill: () => {
1042
+ document.getElementById('compareA').value = 'The database stores information efficiently';
1043
+ document.getElementById('compareB').value = 'Data is saved in an optimized storage system';
1044
+ }
1045
+ },
1046
+ {
1047
+ key: 'two-stage', icon: '🎯', title: 'Two-Stage Retrieval',
1048
+ summary: 'Embed → Search → Rerank for best results',
1049
+ content: 'Two-stage retrieval combines a fast first stage (embedding search for recall) with a precise second stage (reranking for precision).\n\nStage 1: Embed query, run ANN search, retrieve top-100 candidates (fast, milliseconds). Stage 2: Feed query + candidates to a reranker with cross-attention, return top-5-10 (precise, ~100ms extra). This gives you both speed and accuracy.',
1050
+ tab: 'search', prefill: () => {}
1051
+ },
1052
+ {
1053
+ key: 'input-types', icon: '🏷️', title: 'Input Types',
1054
+ summary: 'Query vs document — why it matters',
1055
+ content: 'The input_type parameter tells the model whether text is a search query or a document being indexed. The model internally prepends different prompt prefixes for each, optimizing embeddings for asymmetric retrieval.\n\nAlways use input_type="query" for search queries and input_type="document" for corpus text. Omitting this parameter degrades retrieval accuracy.',
1056
+ tab: 'embed', prefill: () => {
1057
+ document.getElementById('embedInput').value = 'What is vector search and how does it work?';
1058
+ document.getElementById('embedInputType').value = 'query';
1059
+ }
1060
+ },
1061
+ {
1062
+ key: 'models', icon: '🧠', title: 'Models',
1063
+ summary: 'Choosing the right model for your task',
1064
+ content: 'Voyage 4 Series: voyage-4-large (best quality, $0.12/1M tokens), voyage-4 (balanced, $0.06), voyage-4-lite (budget, $0.02). All share the same embedding space — you can mix models.\n\nDomain-specific: voyage-code-3 (code), voyage-finance-2 (financial), voyage-law-2 (legal). Rerankers: rerank-2.5 (best quality), rerank-2.5-lite (faster). Start with voyage-4 for general use.',
1065
+ tab: 'embed', prefill: () => {}
1066
+ },
1067
+ ];
1068
+
1069
+ function buildExploreCards() {
1070
+ const grid = document.getElementById('exploreGrid');
1071
+ grid.innerHTML = '';
1072
+ exploreTopics.forEach(topic => {
1073
+ const card = document.createElement('div');
1074
+ card.className = 'explore-card';
1075
+ card.innerHTML = `
1076
+ <div class="explore-card-icon">${topic.icon}</div>
1077
+ <div class="explore-card-title">${topic.title}</div>
1078
+ <div class="explore-card-summary">${topic.summary}</div>
1079
+ <div class="explore-card-content">${topic.content}</div>
1080
+ <div class="explore-card-actions">
1081
+ <button class="btn btn-small" onclick="tryTopic('${topic.key}')">Try it →</button>
1082
+ <button class="btn btn-secondary btn-small" onclick="collapseTopic(this)">Collapse</button>
1083
+ </div>
1084
+ `;
1085
+ card.addEventListener('click', function(e) {
1086
+ if (e.target.tagName === 'BUTTON') return;
1087
+ if (!this.classList.contains('expanded')) {
1088
+ this.classList.add('expanded');
1089
+ }
1090
+ });
1091
+ grid.appendChild(card);
1092
+ });
1093
+ }
1094
+
1095
+ window.tryTopic = function(key) {
1096
+ const topic = exploreTopics.find(t => t.key === key);
1097
+ if (!topic) return;
1098
+ if (topic.prefill) topic.prefill();
1099
+ switchTab(topic.tab);
1100
+ };
1101
+
1102
+ window.collapseTopic = function(btn) {
1103
+ btn.closest('.explore-card').classList.remove('expanded');
1104
+ };
1105
+
1106
+ // ── Start ──
1107
+ init();
1108
+ })();
1109
+ </script>
1110
+ </body>
1111
+ </html>