voyageai-cli 1.20.0 → 1.20.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.
- package/package.json +1 -1
- package/src/cli.js +9 -0
- package/src/commands/playground.js +11 -0
- package/src/lib/explanations.js +4 -1
- package/src/lib/telemetry.js +72 -0
- package/src/playground/index.html +590 -21
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -67,6 +67,9 @@ ${pc.dim('Community tool — not an official MongoDB or Voyage AI product.')}
|
|
|
67
67
|
${pc.dim('Docs: https://www.mongodb.com/docs/voyageai/')}
|
|
68
68
|
`);
|
|
69
69
|
|
|
70
|
+
// Anonymous telemetry (fire-and-forget, non-blocking)
|
|
71
|
+
const telemetry = require('./lib/telemetry');
|
|
72
|
+
|
|
70
73
|
// If no args (just `vai`), show banner + quick start + help
|
|
71
74
|
if (process.argv.length <= 2) {
|
|
72
75
|
showBanner();
|
|
@@ -75,4 +78,10 @@ if (process.argv.length <= 2) {
|
|
|
75
78
|
process.exit(0);
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
// Track command usage after parsing
|
|
82
|
+
program.hook('preAction', (thisCommand) => {
|
|
83
|
+
const cmd = thisCommand.args?.[0] || thisCommand.name();
|
|
84
|
+
telemetry.send('cli_command', { command: cmd });
|
|
85
|
+
});
|
|
86
|
+
|
|
78
87
|
program.parse();
|
|
@@ -149,6 +149,17 @@ function createPlaygroundServer() {
|
|
|
149
149
|
|
|
150
150
|
// Parse JSON body for POST routes
|
|
151
151
|
if (req.method === 'POST') {
|
|
152
|
+
// Check for API key before processing any API calls
|
|
153
|
+
const apiKeyConfigured = !!(process.env.VOYAGE_API_KEY || getConfigValue('apiKey'));
|
|
154
|
+
if (!apiKeyConfigured) {
|
|
155
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
156
|
+
res.end(JSON.stringify({
|
|
157
|
+
error: 'No API key configured. Run: vai config set api-key <your-key>',
|
|
158
|
+
code: 'NO_API_KEY',
|
|
159
|
+
}));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
152
163
|
const body = await readBody(req);
|
|
153
164
|
let parsed;
|
|
154
165
|
try {
|
package/src/lib/explanations.js
CHANGED
|
@@ -816,7 +816,10 @@ const concepts = {
|
|
|
816
816
|
'https://arxiv.org/abs/2203.02053',
|
|
817
817
|
'https://blog.voyageai.com/2024/11/12/voyage-multimodal-3/',
|
|
818
818
|
],
|
|
819
|
-
tryIt: [
|
|
819
|
+
tryIt: [
|
|
820
|
+
'vai explain multimodal-embeddings',
|
|
821
|
+
'vai explain cross-modal-search',
|
|
822
|
+
],
|
|
820
823
|
},
|
|
821
824
|
|
|
822
825
|
'multimodal-rag': {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Anonymous telemetry for Vai CLI.
|
|
5
|
+
* Sends minimal, non-identifying data to help improve the tool.
|
|
6
|
+
* Respects: VAI_TELEMETRY=0 env var or `vai config set telemetry false`
|
|
7
|
+
*
|
|
8
|
+
* No API keys, no file contents, no PII. Just:
|
|
9
|
+
* - command name, version, platform, locale
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const TELEMETRY_URL = 'https://vai.mlynn.org/api/telemetry';
|
|
13
|
+
const TIMEOUT_MS = 3000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if telemetry is enabled.
|
|
17
|
+
* Disabled by: VAI_TELEMETRY=0, or config telemetry=false
|
|
18
|
+
*/
|
|
19
|
+
function isEnabled() {
|
|
20
|
+
// Env var override (highest priority)
|
|
21
|
+
if (process.env.VAI_TELEMETRY === '0' || process.env.VAI_TELEMETRY === 'false') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
// Config file check
|
|
25
|
+
try {
|
|
26
|
+
const { getConfigValue } = require('./config');
|
|
27
|
+
const val = getConfigValue('telemetry');
|
|
28
|
+
if (val === false || val === 'false') return false;
|
|
29
|
+
} catch { /* config not available, default to enabled */ }
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Send a telemetry event. Fire-and-forget, never throws.
|
|
35
|
+
* @param {string} event - Event name (e.g., 'cli_command')
|
|
36
|
+
* @param {object} [extra] - Additional fields
|
|
37
|
+
*/
|
|
38
|
+
function send(event, extra = {}) {
|
|
39
|
+
if (!isEnabled()) return;
|
|
40
|
+
|
|
41
|
+
const { getVersion } = require('./banner');
|
|
42
|
+
|
|
43
|
+
const payload = JSON.stringify({
|
|
44
|
+
event,
|
|
45
|
+
version: getVersion(),
|
|
46
|
+
context: 'cli',
|
|
47
|
+
platform: `${process.platform}-${process.arch}`,
|
|
48
|
+
locale: Intl.DateTimeFormat().resolvedOptions().locale || undefined,
|
|
49
|
+
...extra,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// Use native https, fire-and-forget
|
|
54
|
+
const { request } = require('https');
|
|
55
|
+
const url = new URL(TELEMETRY_URL);
|
|
56
|
+
const req = request({
|
|
57
|
+
hostname: url.hostname,
|
|
58
|
+
path: url.pathname,
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
63
|
+
},
|
|
64
|
+
timeout: TIMEOUT_MS,
|
|
65
|
+
});
|
|
66
|
+
req.on('error', () => {}); // swallow
|
|
67
|
+
req.on('timeout', () => req.destroy());
|
|
68
|
+
req.end(payload);
|
|
69
|
+
} catch { /* telemetry should never break the CLI */ }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { send, isEnabled };
|
|
@@ -141,6 +141,11 @@ body {
|
|
|
141
141
|
padding: 0 4px;
|
|
142
142
|
}
|
|
143
143
|
.update-banner-dismiss:hover { color: var(--text); }
|
|
144
|
+
.update-progress { display: flex; align-items: center; gap: 8px; flex: 1; }
|
|
145
|
+
.update-progress-bar { flex: 1; height: 6px; background: rgba(255,255,255,0.15); border-radius: 3px; overflow: hidden; }
|
|
146
|
+
[data-theme="light"] .update-progress-bar { background: rgba(0,0,0,0.1); }
|
|
147
|
+
.update-progress-fill { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; width: 0%; }
|
|
148
|
+
.update-progress-label { font-size: 12px; font-family: var(--mono); color: var(--accent); min-width: 40px; text-align: right; }
|
|
144
149
|
|
|
145
150
|
/* ── Onboarding Walkthrough ── */
|
|
146
151
|
.onboarding-overlay {
|
|
@@ -357,6 +362,12 @@ body {
|
|
|
357
362
|
align-items: center;
|
|
358
363
|
gap: 10px;
|
|
359
364
|
}
|
|
365
|
+
body:not(.is-electron) .sidebar-drag-region {
|
|
366
|
+
height: auto;
|
|
367
|
+
min-height: auto;
|
|
368
|
+
padding-top: 16px;
|
|
369
|
+
padding-bottom: 12px;
|
|
370
|
+
}
|
|
360
371
|
|
|
361
372
|
.sidebar-logo {
|
|
362
373
|
width: 28px;
|
|
@@ -372,8 +383,27 @@ body {
|
|
|
372
383
|
color: var(--accent-text);
|
|
373
384
|
white-space: nowrap;
|
|
374
385
|
letter-spacing: -0.2px;
|
|
386
|
+
flex: 1;
|
|
375
387
|
}
|
|
376
388
|
|
|
389
|
+
.sidebar-settings-btn {
|
|
390
|
+
-webkit-app-region: no-drag;
|
|
391
|
+
background: none;
|
|
392
|
+
border: none;
|
|
393
|
+
cursor: pointer;
|
|
394
|
+
color: var(--text-muted);
|
|
395
|
+
padding: 4px;
|
|
396
|
+
border-radius: 4px;
|
|
397
|
+
display: flex;
|
|
398
|
+
align-items: center;
|
|
399
|
+
justify-content: center;
|
|
400
|
+
transition: all 0.15s;
|
|
401
|
+
flex-shrink: 0;
|
|
402
|
+
}
|
|
403
|
+
.sidebar-settings-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
|
404
|
+
.sidebar-settings-btn.active { color: var(--accent); }
|
|
405
|
+
[data-theme="light"] .sidebar-settings-btn:hover { background: rgba(0,0,0,0.04); }
|
|
406
|
+
|
|
377
407
|
.sidebar-nav {
|
|
378
408
|
flex: 1;
|
|
379
409
|
overflow-y: auto;
|
|
@@ -537,6 +567,10 @@ body {
|
|
|
537
567
|
flex-shrink: 0;
|
|
538
568
|
}
|
|
539
569
|
|
|
570
|
+
/* Web mode: hide Electron-only elements */
|
|
571
|
+
body:not(.is-electron) .content-drag-region { display: none; }
|
|
572
|
+
body:not(.is-electron) .update-banner { display: none !important; }
|
|
573
|
+
|
|
540
574
|
/* Main */
|
|
541
575
|
.main { padding: 24px; max-width: 1200px; margin: 0 auto; width: 100%; }
|
|
542
576
|
|
|
@@ -2261,7 +2295,7 @@ select:focus { outline: none; border-color: var(--accent); }
|
|
|
2261
2295
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2 3C2 2.44772 2.44772 2 3 2H13C13.5523 2 14 2.44772 14 3V13C14 13.5523 13.5523 14 13 14H3C2.44772 14 2 13.5523 2 13V3ZM4 4V9.586L5.793 7.793C6.183 7.403 6.817 7.403 7.207 7.793L8.5 9.086L10.293 7.293C10.683 6.903 11.317 6.903 11.707 7.293L12 7.586V4H4ZM12 10.414L10.5 8.914L8.207 11.207C7.817 11.597 7.183 11.597 6.793 11.207L5.5 9.914L4 11.414V12H12V10.414ZM10 5.5C10 6.328 10.672 7 11.5 7C11.776 7 12 6.776 12 6.5V5C12 4.724 11.776 4.5 11.5 4.5H10.5C10.224 4.5 10 4.724 10 5V5.5Z" fill="currentColor"/>
|
|
2262
2296
|
</symbol>
|
|
2263
2297
|
<symbol id="lg-config" viewBox="0 0 16 16">
|
|
2264
|
-
<path d="
|
|
2298
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 1.5A.5.5 0 0 1 7 1h2a.5.5 0 0 1 .5.5v1.05a5 5 0 0 1 1.37.57l.74-.74a.5.5 0 0 1 .7 0l1.42 1.42a.5.5 0 0 1 0 .7l-.74.74c.25.43.44.89.57 1.37H14.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-1.05a5 5 0 0 1-.57 1.37l.74.74a.5.5 0 0 1 0 .7l-1.42 1.42a.5.5 0 0 1-.7 0l-.74-.74a5 5 0 0 1-1.37.57V14.5a.5.5 0 0 1-.5.5H7a.5.5 0 0 1-.5-.5v-1.05a5 5 0 0 1-1.37-.57l-.74.74a.5.5 0 0 1-.7 0L2.27 12.2a.5.5 0 0 1 0-.7l.74-.74a5 5 0 0 1-.57-1.37H1.5A.5.5 0 0 1 1 9V7a.5.5 0 0 1 .5-.5h1.05c.13-.48.32-.94.57-1.37l-.74-.74a.5.5 0 0 1 0-.7L3.8 2.27a.5.5 0 0 1 .7 0l.74.74A5 5 0 0 1 6.6 2.44V1.5zM8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z" fill="currentColor"/>
|
|
2265
2299
|
</symbol>
|
|
2266
2300
|
</svg>
|
|
2267
2301
|
|
|
@@ -2272,6 +2306,7 @@ select:focus { outline: none; border-color: var(--accent); }
|
|
|
2272
2306
|
<div class="sidebar-drag-region">
|
|
2273
2307
|
<img class="sidebar-logo" id="sidebarLogo" src="/icons/dark/64.png" alt="Vai">
|
|
2274
2308
|
<span class="sidebar-title">Vai</span>
|
|
2309
|
+
<button class="sidebar-settings-btn" data-tab="settings" title="Settings"><svg width="16" height="16" viewBox="0 0 16 16"><use href="#lg-config"/></svg></button>
|
|
2275
2310
|
</div>
|
|
2276
2311
|
<nav class="sidebar-nav">
|
|
2277
2312
|
<div class="sidebar-nav-group">
|
|
@@ -2288,8 +2323,6 @@ select:focus { outline: none; border-color: var(--accent); }
|
|
|
2288
2323
|
<button class="tab-btn" data-tab="explore"><span class="tab-btn-icon"><svg><use href="#lg-bulb"/></svg></span><span>Explore</span></button>
|
|
2289
2324
|
<button class="tab-btn" data-tab="about"><span class="tab-btn-icon"><svg><use href="#lg-info"/></svg></span><span>About</span></button>
|
|
2290
2325
|
</div>
|
|
2291
|
-
<div class="sidebar-nav-divider"></div>
|
|
2292
|
-
<button class="tab-btn" data-tab="settings"><span class="tab-btn-icon"><svg><use href="#lg-config"/></svg></span><span>Settings</span></button>
|
|
2293
2326
|
</nav>
|
|
2294
2327
|
<div class="sidebar-footer">
|
|
2295
2328
|
<div style="display:flex;align-items:center;gap:8px;justify-content:space-between;">
|
|
@@ -2308,11 +2341,16 @@ select:focus { outline: none; border-color: var(--accent); }
|
|
|
2308
2341
|
<div class="content-drag-region"></div>
|
|
2309
2342
|
<div class="update-banner" id="updateBanner">
|
|
2310
2343
|
<span class="update-banner-icon">🚀</span>
|
|
2311
|
-
<span class="update-banner-text">
|
|
2344
|
+
<span class="update-banner-text" id="updateBannerText">
|
|
2312
2345
|
<strong>Vai <span id="updateVersion"></span></strong> is available.
|
|
2313
2346
|
You're on <span id="currentVersion"></span>.
|
|
2314
2347
|
</span>
|
|
2315
|
-
<
|
|
2348
|
+
<div class="update-progress" id="updateProgress" style="display:none;">
|
|
2349
|
+
<div class="update-progress-bar"><div class="update-progress-fill" id="updateProgressFill"></div></div>
|
|
2350
|
+
<span class="update-progress-label" id="updateProgressLabel">0%</span>
|
|
2351
|
+
</div>
|
|
2352
|
+
<button class="update-banner-btn" id="updateDownloadBtn">Download & Install</button>
|
|
2353
|
+
<button class="update-banner-btn" id="updateInstallBtn" style="display:none;">Restart & Update</button>
|
|
2316
2354
|
<button class="update-banner-dismiss" id="updateDismissBtn" title="Dismiss">×</button>
|
|
2317
2355
|
</div>
|
|
2318
2356
|
<div class="main">
|
|
@@ -2966,8 +3004,20 @@ Reranking models rescore initial search results to improve relevance ordering.</
|
|
|
2966
3004
|
<strong>⚡ Embed</strong> — Generate vector embeddings for any text<br>
|
|
2967
3005
|
<strong>⚖️ Compare</strong> — Measure similarity with cosine, dot product & euclidean distance<br>
|
|
2968
3006
|
<strong>🔍 Search</strong> — Semantic search with optional reranking<br>
|
|
3007
|
+
<strong>🔮 Multimodal</strong> — Compare images and text in the same vector space with voyage-multimodal-3.5<br>
|
|
2969
3008
|
<strong>⏱ Benchmark</strong> — Compare model latency, ranking quality, and costs<br>
|
|
2970
|
-
<strong>📚 Explore</strong> — Learn about embeddings, vector search, RAG, and more
|
|
3009
|
+
<strong>📚 Explore</strong> — Learn about embeddings, vector search, multimodal, RAG, and more
|
|
3010
|
+
</div>
|
|
3011
|
+
</div>
|
|
3012
|
+
|
|
3013
|
+
<div class="about-section">
|
|
3014
|
+
<div class="about-section-title">Supported Models</div>
|
|
3015
|
+
<div class="about-text" style="font-size:13px;">
|
|
3016
|
+
<strong style="color:var(--accent);">Text Embeddings</strong> — voyage-4-large, voyage-4, voyage-4-lite, voyage-code-3, voyage-finance-2, voyage-law-2<br>
|
|
3017
|
+
<strong style="color:var(--accent);">Multimodal</strong> — voyage-multimodal-3.5 (text + images + video in one vector space)<br>
|
|
3018
|
+
<strong style="color:var(--accent);">Reranking</strong> — rerank-2.5, rerank-2.5-lite<br><br>
|
|
3019
|
+
All models are accessible via the <a href="https://docs.voyageai.com/" target="_blank" style="color:var(--blue);">Voyage AI API</a>
|
|
3020
|
+
or through <a href="https://www.mongodb.com/docs/atlas/atlas-vector-search/" target="_blank" style="color:var(--blue);">MongoDB Atlas Vector Search</a>.
|
|
2971
3021
|
</div>
|
|
2972
3022
|
</div>
|
|
2973
3023
|
|
|
@@ -2983,8 +3033,20 @@ Reranking models rescore initial search results to improve relevance ordering.</
|
|
|
2983
3033
|
</div>
|
|
2984
3034
|
</div>
|
|
2985
3035
|
|
|
3036
|
+
<div class="card" style="margin-top:16px;">
|
|
3037
|
+
<div class="about-section" style="padding-bottom:0;">
|
|
3038
|
+
<div class="about-section-title">What's New</div>
|
|
3039
|
+
<div class="about-text" style="font-size:13px;">
|
|
3040
|
+
<strong>v1.2</strong> — Multimodal tab (image ↔ text similarity, cross-modal gallery search),
|
|
3041
|
+
4 new Explore concepts (multimodal embeddings, cross-modal search, modality gap, multimodal RAG),
|
|
3042
|
+
auto-update with in-app download & restart, and a hidden easter egg 🕹️
|
|
3043
|
+
</div>
|
|
3044
|
+
</div>
|
|
3045
|
+
</div>
|
|
3046
|
+
|
|
2986
3047
|
<div style="text-align:center;margin-top:16px;font-size:12px;color:var(--text-muted);">
|
|
2987
3048
|
Made with ☕ and curiosity · <a href="https://github.com/mrlynn/voyageai-cli" target="_blank" style="color:var(--text-dim);">Source on GitHub</a>
|
|
3049
|
+
· <a href="https://github.com/mrlynn/voyageai-cli/releases" target="_blank" style="color:var(--text-dim);">Releases</a>
|
|
2988
3050
|
</div>
|
|
2989
3051
|
</div>
|
|
2990
3052
|
</div>
|
|
@@ -3191,6 +3253,15 @@ Reranking models rescore initial search results to improve relevance ordering.</
|
|
|
3191
3253
|
<button class="settings-reset-btn" id="settingsClearCache">Clear All Data</button>
|
|
3192
3254
|
</div>
|
|
3193
3255
|
</div>
|
|
3256
|
+
<div class="settings-row">
|
|
3257
|
+
<div class="settings-label">
|
|
3258
|
+
<span class="settings-label-text">Anonymous Usage Analytics</span>
|
|
3259
|
+
<span class="settings-label-hint">Help improve Vai by sharing anonymous usage data (version, platform, features used). No API keys or personal data.</span>
|
|
3260
|
+
</div>
|
|
3261
|
+
<div class="settings-control">
|
|
3262
|
+
<button class="settings-toggle active" id="settingsTelemetry" type="button"></button>
|
|
3263
|
+
</div>
|
|
3264
|
+
</div>
|
|
3194
3265
|
</div>
|
|
3195
3266
|
|
|
3196
3267
|
<!-- Version Info (visible in Electron only) -->
|
|
@@ -3313,24 +3384,85 @@ let rerankModels = [];
|
|
|
3313
3384
|
let lastEmbedding = null;
|
|
3314
3385
|
|
|
3315
3386
|
// ── Init ──
|
|
3387
|
+
// ── Anonymous Telemetry ──
|
|
3388
|
+
const TELEMETRY_URL = 'https://vai.mlynn.org/api/telemetry';
|
|
3389
|
+
const TELEMETRY_KEY = 'vai-telemetry-enabled';
|
|
3390
|
+
|
|
3391
|
+
function isTelemetryEnabled() {
|
|
3392
|
+
const stored = localStorage.getItem(TELEMETRY_KEY);
|
|
3393
|
+
return stored === null ? true : stored === 'true'; // default on
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
function sendTelemetry(event, extra = {}) {
|
|
3397
|
+
if (!isTelemetryEnabled()) return;
|
|
3398
|
+
try {
|
|
3399
|
+
const isElectron = !!(window.vai && window.vai.isElectron);
|
|
3400
|
+
const payload = {
|
|
3401
|
+
event,
|
|
3402
|
+
version: document.getElementById('appVersionLabel')?.textContent?.replace('v','') || 'web',
|
|
3403
|
+
context: isElectron ? 'electron' : 'web',
|
|
3404
|
+
platform: navigator.platform || undefined,
|
|
3405
|
+
locale: navigator.language || undefined,
|
|
3406
|
+
...extra,
|
|
3407
|
+
};
|
|
3408
|
+
// Use sendBeacon for fire-and-forget (survives page unload)
|
|
3409
|
+
if (navigator.sendBeacon) {
|
|
3410
|
+
navigator.sendBeacon(TELEMETRY_URL, JSON.stringify(payload));
|
|
3411
|
+
} else {
|
|
3412
|
+
fetch(TELEMETRY_URL, {
|
|
3413
|
+
method: 'POST',
|
|
3414
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3415
|
+
body: JSON.stringify(payload),
|
|
3416
|
+
keepalive: true,
|
|
3417
|
+
}).catch(() => {});
|
|
3418
|
+
}
|
|
3419
|
+
} catch { /* telemetry should never break the app */ }
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
function initTelemetryToggle() {
|
|
3423
|
+
const toggle = document.getElementById('settingsTelemetry');
|
|
3424
|
+
if (!toggle) return;
|
|
3425
|
+
const enabled = isTelemetryEnabled();
|
|
3426
|
+
toggle.classList.toggle('active', enabled);
|
|
3427
|
+
toggle.addEventListener('click', () => {
|
|
3428
|
+
const nowEnabled = !toggle.classList.contains('active');
|
|
3429
|
+
toggle.classList.toggle('active', nowEnabled);
|
|
3430
|
+
localStorage.setItem(TELEMETRY_KEY, String(nowEnabled));
|
|
3431
|
+
showSettingsSaved();
|
|
3432
|
+
});
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3316
3435
|
async function init() {
|
|
3436
|
+
// Mark body for Electron vs Web styling
|
|
3437
|
+
if (window.vai && window.vai.isElectron) {
|
|
3438
|
+
document.body.classList.add('is-electron');
|
|
3439
|
+
}
|
|
3317
3440
|
setupTabs();
|
|
3318
3441
|
await loadConfig();
|
|
3319
3442
|
await Promise.all([loadModels(), loadConcepts()]);
|
|
3320
3443
|
populateModelSelects();
|
|
3321
3444
|
buildExploreCards();
|
|
3445
|
+
|
|
3446
|
+
// Telemetry
|
|
3447
|
+
initTelemetryToggle();
|
|
3448
|
+
sendTelemetry('app_launch');
|
|
3322
3449
|
}
|
|
3323
3450
|
|
|
3324
3451
|
// ── Tabs ──
|
|
3325
3452
|
function setupTabs() {
|
|
3326
3453
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
3327
3454
|
btn.addEventListener('click', () => {
|
|
3328
|
-
|
|
3329
|
-
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
3330
|
-
btn.classList.add('active');
|
|
3331
|
-
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
|
3455
|
+
switchTab(btn.dataset.tab);
|
|
3332
3456
|
});
|
|
3333
3457
|
});
|
|
3458
|
+
|
|
3459
|
+
// Settings cog in header
|
|
3460
|
+
const settingsCog = document.querySelector('.sidebar-settings-btn');
|
|
3461
|
+
if (settingsCog) {
|
|
3462
|
+
settingsCog.addEventListener('click', () => {
|
|
3463
|
+
switchTab(settingsCog.dataset.tab);
|
|
3464
|
+
});
|
|
3465
|
+
}
|
|
3334
3466
|
}
|
|
3335
3467
|
|
|
3336
3468
|
function switchTab(tab) {
|
|
@@ -3340,6 +3472,11 @@ function switchTab(tab) {
|
|
|
3340
3472
|
document.querySelectorAll('.tab-panel').forEach(p => {
|
|
3341
3473
|
p.classList.toggle('active', p.id === 'tab-' + tab);
|
|
3342
3474
|
});
|
|
3475
|
+
// Sync settings cog highlight
|
|
3476
|
+
const settingsBtn = document.querySelector('.sidebar-settings-btn');
|
|
3477
|
+
if (settingsBtn) settingsBtn.classList.toggle('active', tab === 'settings');
|
|
3478
|
+
// Track tab views
|
|
3479
|
+
sendTelemetry('tab_view', { tab });
|
|
3343
3480
|
}
|
|
3344
3481
|
// Expose globally so Electron main process can call it
|
|
3345
3482
|
window.switchTab = switchTab;
|
|
@@ -3419,13 +3556,23 @@ function formatBytesUI(bytes) {
|
|
|
3419
3556
|
}
|
|
3420
3557
|
|
|
3421
3558
|
async function apiPost(url, body) {
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3559
|
+
let res;
|
|
3560
|
+
try {
|
|
3561
|
+
res = await fetch(url, {
|
|
3562
|
+
method: 'POST',
|
|
3563
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3564
|
+
body: JSON.stringify(body),
|
|
3565
|
+
});
|
|
3566
|
+
} catch (fetchErr) {
|
|
3567
|
+
throw new Error('Failed to connect to server. Is the playground still running?');
|
|
3568
|
+
}
|
|
3427
3569
|
const data = await res.json();
|
|
3428
|
-
if (!res.ok)
|
|
3570
|
+
if (!res.ok) {
|
|
3571
|
+
if (data.code === 'NO_API_KEY') {
|
|
3572
|
+
throw new Error('🔑 No API key configured. Open Settings (⚙) to add your Voyage AI API key, or run: vai config set api-key <your-key>');
|
|
3573
|
+
}
|
|
3574
|
+
throw new Error(data.error || data.detail || 'API error');
|
|
3575
|
+
}
|
|
3429
3576
|
return data;
|
|
3430
3577
|
}
|
|
3431
3578
|
|
|
@@ -3456,6 +3603,7 @@ window.doEmbed = async function() {
|
|
|
3456
3603
|
hideError('embedError');
|
|
3457
3604
|
const text = document.getElementById('embedInput').value.trim();
|
|
3458
3605
|
if (!text) { showError('embedError', 'Enter some text to embed'); return; }
|
|
3606
|
+
sendTelemetry('api_call', { endpoint: 'embed', model: document.getElementById('embedModel').value });
|
|
3459
3607
|
|
|
3460
3608
|
setLoading('embedBtn', true);
|
|
3461
3609
|
try {
|
|
@@ -3538,6 +3686,7 @@ function buildHeatmap(vec, container) {
|
|
|
3538
3686
|
// ── Compare ──
|
|
3539
3687
|
window.doCompare = async function() {
|
|
3540
3688
|
hideError('compareError');
|
|
3689
|
+
sendTelemetry('api_call', { endpoint: 'compare', model: document.getElementById('compareModel').value });
|
|
3541
3690
|
const a = document.getElementById('compareA').value.trim();
|
|
3542
3691
|
const b = document.getElementById('compareB').value.trim();
|
|
3543
3692
|
if (!a || !b) { showError('compareError', 'Enter text in both fields'); return; }
|
|
@@ -3630,6 +3779,7 @@ window.doCompare = async function() {
|
|
|
3630
3779
|
// ── Search ──
|
|
3631
3780
|
window.doSearch = async function(withRerank) {
|
|
3632
3781
|
hideError('searchError');
|
|
3782
|
+
sendTelemetry('api_call', { endpoint: withRerank ? 'rerank' : 'search' });
|
|
3633
3783
|
const query = document.getElementById('searchQuery').value.trim();
|
|
3634
3784
|
const docsText = document.getElementById('searchDocs').value.trim();
|
|
3635
3785
|
if (!query || !docsText) { showError('searchError', 'Enter a query and documents'); return; }
|
|
@@ -5095,15 +5245,12 @@ function checkForAppUpdate() {
|
|
|
5095
5245
|
// Show version in sidebar + settings page
|
|
5096
5246
|
window.vai.getVersion().then(v => {
|
|
5097
5247
|
if (!v) return;
|
|
5098
|
-
// v can be string (old) or { app, cli } (new)
|
|
5099
5248
|
const appVer = typeof v === 'object' ? v.app : v;
|
|
5100
5249
|
const cliVer = typeof v === 'object' ? v.cli : null;
|
|
5101
5250
|
|
|
5102
|
-
// Sidebar label
|
|
5103
5251
|
const label = document.getElementById('appVersionLabel');
|
|
5104
5252
|
if (label && appVer) label.textContent = 'v' + appVer;
|
|
5105
5253
|
|
|
5106
|
-
// Settings version section
|
|
5107
5254
|
const section = document.getElementById('settingsVersionSection');
|
|
5108
5255
|
const appBadge = document.getElementById('settingsAppVersion');
|
|
5109
5256
|
const cliBadge = document.getElementById('settingsCliVersion');
|
|
@@ -5117,7 +5264,43 @@ function checkForAppUpdate() {
|
|
|
5117
5264
|
const dismissed = localStorage.getItem(DISMISS_KEY);
|
|
5118
5265
|
if (dismissed) {
|
|
5119
5266
|
const dismissedAt = parseInt(dismissed, 10);
|
|
5120
|
-
if (Date.now() - dismissedAt < 3600000) return;
|
|
5267
|
+
if (Date.now() - dismissedAt < 3600000) return;
|
|
5268
|
+
}
|
|
5269
|
+
|
|
5270
|
+
// Listen for real-time update events from electron-updater
|
|
5271
|
+
if (window.vai.updates.onEvent) {
|
|
5272
|
+
window.vai.updates.onEvent((data) => {
|
|
5273
|
+
const banner = document.getElementById('updateBanner');
|
|
5274
|
+
const progressWrap = document.getElementById('updateProgress');
|
|
5275
|
+
const progressFill = document.getElementById('updateProgressFill');
|
|
5276
|
+
const progressLabel = document.getElementById('updateProgressLabel');
|
|
5277
|
+
const bannerText = document.getElementById('updateBannerText');
|
|
5278
|
+
const downloadBtn = document.getElementById('updateDownloadBtn');
|
|
5279
|
+
const installBtn = document.getElementById('updateInstallBtn');
|
|
5280
|
+
|
|
5281
|
+
if (data.event === 'download-progress') {
|
|
5282
|
+
const pct = Math.round(data.percent || 0);
|
|
5283
|
+
progressFill.style.width = pct + '%';
|
|
5284
|
+
const mbTransferred = (data.transferred / 1048576).toFixed(1);
|
|
5285
|
+
const mbTotal = (data.total / 1048576).toFixed(1);
|
|
5286
|
+
progressLabel.textContent = `${pct}% (${mbTransferred}/${mbTotal} MB)`;
|
|
5287
|
+
}
|
|
5288
|
+
|
|
5289
|
+
if (data.event === 'update-downloaded') {
|
|
5290
|
+
progressWrap.style.display = 'none';
|
|
5291
|
+
bannerText.innerHTML = `<strong>Vai v${data.latestVersion}</strong> is ready to install.`;
|
|
5292
|
+
bannerText.style.display = '';
|
|
5293
|
+
downloadBtn.style.display = 'none';
|
|
5294
|
+
installBtn.style.display = '';
|
|
5295
|
+
}
|
|
5296
|
+
|
|
5297
|
+
if (data.event === 'update-error') {
|
|
5298
|
+
progressWrap.style.display = 'none';
|
|
5299
|
+
bannerText.innerHTML = `Update failed: ${data.message}. <a href="#" onclick="window.vai.updates.openRelease('https://github.com/mrlynn/voyageai-cli/releases');return false;" style="color:var(--accent);">Download manually</a>`;
|
|
5300
|
+
bannerText.style.display = '';
|
|
5301
|
+
downloadBtn.style.display = 'none';
|
|
5302
|
+
}
|
|
5303
|
+
});
|
|
5121
5304
|
}
|
|
5122
5305
|
|
|
5123
5306
|
window.vai.updates.check().then(result => {
|
|
@@ -5127,7 +5310,10 @@ function checkForAppUpdate() {
|
|
|
5127
5310
|
const versionEl = document.getElementById('updateVersion');
|
|
5128
5311
|
const currentEl = document.getElementById('currentVersion');
|
|
5129
5312
|
const downloadBtn = document.getElementById('updateDownloadBtn');
|
|
5313
|
+
const installBtn = document.getElementById('updateInstallBtn');
|
|
5130
5314
|
const dismissBtn = document.getElementById('updateDismissBtn');
|
|
5315
|
+
const bannerText = document.getElementById('updateBannerText');
|
|
5316
|
+
const progressWrap = document.getElementById('updateProgress');
|
|
5131
5317
|
|
|
5132
5318
|
if (!banner) return;
|
|
5133
5319
|
|
|
@@ -5136,7 +5322,24 @@ function checkForAppUpdate() {
|
|
|
5136
5322
|
banner.classList.add('show');
|
|
5137
5323
|
|
|
5138
5324
|
downloadBtn.addEventListener('click', () => {
|
|
5139
|
-
|
|
5325
|
+
// Start auto-download
|
|
5326
|
+
if (window.vai.updates.download) {
|
|
5327
|
+
downloadBtn.style.display = 'none';
|
|
5328
|
+
bannerText.style.display = 'none';
|
|
5329
|
+
progressWrap.style.display = 'flex';
|
|
5330
|
+
window.vai.updates.download().catch(() => {
|
|
5331
|
+
// Fallback to manual download
|
|
5332
|
+
window.vai.updates.openRelease(result.releaseUrl);
|
|
5333
|
+
});
|
|
5334
|
+
} else {
|
|
5335
|
+
window.vai.updates.openRelease(result.releaseUrl);
|
|
5336
|
+
}
|
|
5337
|
+
});
|
|
5338
|
+
|
|
5339
|
+
installBtn.addEventListener('click', () => {
|
|
5340
|
+
installBtn.textContent = 'Restarting...';
|
|
5341
|
+
installBtn.disabled = true;
|
|
5342
|
+
window.vai.updates.install();
|
|
5140
5343
|
});
|
|
5141
5344
|
|
|
5142
5345
|
dismissBtn.addEventListener('click', () => {
|
|
@@ -5466,6 +5669,7 @@ window.clearMultimodalImage = function() {
|
|
|
5466
5669
|
|
|
5467
5670
|
window.doMultimodalCompare = async function() {
|
|
5468
5671
|
hideError('mmError');
|
|
5672
|
+
sendTelemetry('api_call', { endpoint: 'multimodal-compare', model: document.getElementById('mmModel').value });
|
|
5469
5673
|
const text = document.getElementById('mmText').value.trim();
|
|
5470
5674
|
if (!mmImageData) { showError('mmError', 'Upload an image first'); return; }
|
|
5471
5675
|
if (!text) { showError('mmError', 'Enter text to compare against the image'); return; }
|
|
@@ -5617,6 +5821,7 @@ window.setMmSearchMode = function(mode) {
|
|
|
5617
5821
|
|
|
5618
5822
|
window.doMultimodalSearch = async function() {
|
|
5619
5823
|
hideError('mmSearchError');
|
|
5824
|
+
sendTelemetry('api_call', { endpoint: 'multimodal-search', model: document.getElementById('mmModel').value });
|
|
5620
5825
|
|
|
5621
5826
|
const corpusText = document.getElementById('mmCorpusText').value.trim();
|
|
5622
5827
|
const textItems = corpusText ? corpusText.split('\n').map(t => t.trim()).filter(Boolean) : [];
|
|
@@ -5732,6 +5937,365 @@ init = async function() {
|
|
|
5732
5937
|
initOnboarding();
|
|
5733
5938
|
};
|
|
5734
5939
|
|
|
5940
|
+
// ── Start ──
|
|
5941
|
+
init();
|
|
5942
|
+
|
|
5943
|
+
// ── 🕹️ Vector Space Invaders (Easter Egg) ──
|
|
5944
|
+
// Trigger: Konami code (↑↑↓↓←→←→BA) or 7 rapid clicks on sidebar logo
|
|
5945
|
+
(function initEasterEgg() {
|
|
5946
|
+
const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
|
|
5947
|
+
let konamiIdx = 0;
|
|
5948
|
+
let logoClicks = [];
|
|
5949
|
+
|
|
5950
|
+
document.addEventListener('keydown', (e) => {
|
|
5951
|
+
if (document.getElementById('vsiOverlay')?.style.display === 'flex') return;
|
|
5952
|
+
if (e.key === KONAMI[konamiIdx]) { konamiIdx++; if (konamiIdx === KONAMI.length) { konamiIdx = 0; launchVSI(); } }
|
|
5953
|
+
else { konamiIdx = 0; }
|
|
5954
|
+
});
|
|
5955
|
+
|
|
5956
|
+
const logo = document.getElementById('sidebarLogo');
|
|
5957
|
+
if (logo) {
|
|
5958
|
+
logo.addEventListener('click', () => {
|
|
5959
|
+
const now = Date.now();
|
|
5960
|
+
logoClicks.push(now);
|
|
5961
|
+
logoClicks = logoClicks.filter(t => now - t < 2000);
|
|
5962
|
+
if (logoClicks.length >= 7) { logoClicks = []; launchVSI(); }
|
|
5963
|
+
});
|
|
5964
|
+
}
|
|
5965
|
+
|
|
5966
|
+
function launchVSI() {
|
|
5967
|
+
const overlay = document.getElementById('vsiOverlay');
|
|
5968
|
+
const canvas = document.getElementById('vsiCanvas');
|
|
5969
|
+
if (!overlay || !canvas) return;
|
|
5970
|
+
overlay.style.display = 'flex';
|
|
5971
|
+
startVSI(canvas, () => { overlay.style.display = 'none'; });
|
|
5972
|
+
}
|
|
5973
|
+
})();
|
|
5974
|
+
|
|
5975
|
+
function startVSI(canvas, onExit) {
|
|
5976
|
+
const ctx = canvas.getContext('2d');
|
|
5977
|
+
const W = canvas.width = 600;
|
|
5978
|
+
const H = canvas.height = 500;
|
|
5979
|
+
|
|
5980
|
+
// Ship
|
|
5981
|
+
const ship = { x: W / 2, w: 40, h: 16, speed: 5 };
|
|
5982
|
+
const bullets = [];
|
|
5983
|
+
const particles = [];
|
|
5984
|
+
const explosionTexts = [];
|
|
5985
|
+
|
|
5986
|
+
// Enemies — vectors descending
|
|
5987
|
+
const VECTORS = [
|
|
5988
|
+
'[.23,-.41]', '[.95, .12]', '[-.56,.78]',
|
|
5989
|
+
'[.44,-.71]', '[.01,-.99]', '[-.88,.35]',
|
|
5990
|
+
'[.67, .91]', '[-.12,.84]', '[.33, .33]',
|
|
5991
|
+
'[.77,-.64]', '[-.45,.78]', '[.91,-.42]',
|
|
5992
|
+
];
|
|
5993
|
+
const BOSS_VEC = 'CLIP-ViT-L/14-336';
|
|
5994
|
+
|
|
5995
|
+
let enemies = [];
|
|
5996
|
+
let wave = 1;
|
|
5997
|
+
let score = 0;
|
|
5998
|
+
let lives = 3;
|
|
5999
|
+
let gameOver = false;
|
|
6000
|
+
let bossMode = false;
|
|
6001
|
+
let boss = null;
|
|
6002
|
+
let frame = 0;
|
|
6003
|
+
let lastShot = 0;
|
|
6004
|
+
let waveQueued = false;
|
|
6005
|
+
|
|
6006
|
+
const keys = {};
|
|
6007
|
+
const keyDown = (e) => { keys[e.key] = true; if (['ArrowLeft','ArrowRight',' ','Escape'].includes(e.key)) e.preventDefault(); if (e.key === 'Escape') { cleanup(); onExit(); } };
|
|
6008
|
+
const keyUp = (e) => { keys[e.key] = false; };
|
|
6009
|
+
document.addEventListener('keydown', keyDown);
|
|
6010
|
+
document.addEventListener('keyup', keyUp);
|
|
6011
|
+
|
|
6012
|
+
function cleanup() { document.removeEventListener('keydown', keyDown); document.removeEventListener('keyup', keyUp); gameOver = true; }
|
|
6013
|
+
|
|
6014
|
+
function spawnWave() {
|
|
6015
|
+
enemies = [];
|
|
6016
|
+
if (wave % 5 === 0) {
|
|
6017
|
+
// Boss wave every 5 waves
|
|
6018
|
+
bossMode = true;
|
|
6019
|
+
boss = { x: W / 2, y: 50, w: 180, h: 30, hp: 10 + wave, maxHp: 10 + wave, dx: 2, text: BOSS_VEC };
|
|
6020
|
+
} else {
|
|
6021
|
+
bossMode = false;
|
|
6022
|
+
boss = null;
|
|
6023
|
+
const rows = Math.min(3, 1 + Math.floor(wave / 2));
|
|
6024
|
+
const cols = Math.min(6, 3 + Math.floor(wave / 3));
|
|
6025
|
+
for (let r = 0; r < rows; r++) {
|
|
6026
|
+
for (let c = 0; c < cols; c++) {
|
|
6027
|
+
enemies.push({
|
|
6028
|
+
x: 60 + c * 90,
|
|
6029
|
+
y: 40 + r * 40,
|
|
6030
|
+
w: 78, h: 22,
|
|
6031
|
+
hp: 1 + Math.floor(wave / 4),
|
|
6032
|
+
text: VECTORS[(r * cols + c) % VECTORS.length],
|
|
6033
|
+
dx: 1 + wave * 0.3,
|
|
6034
|
+
dir: 1,
|
|
6035
|
+
});
|
|
6036
|
+
}
|
|
6037
|
+
}
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
|
|
6041
|
+
function shoot() {
|
|
6042
|
+
if (frame - lastShot < 10) return;
|
|
6043
|
+
lastShot = frame;
|
|
6044
|
+
bullets.push({ x: ship.x, y: H - 40, dy: -7 });
|
|
6045
|
+
}
|
|
6046
|
+
|
|
6047
|
+
function addExplosion(x, y, sim) {
|
|
6048
|
+
const color = sim > 0.7 ? '#00ED64' : sim > 0.4 ? '#FFC010' : '#FF6960';
|
|
6049
|
+
for (let i = 0; i < 8; i++) {
|
|
6050
|
+
const angle = (Math.PI * 2 / 8) * i + Math.random() * 0.5;
|
|
6051
|
+
const speed = 1.5 + Math.random() * 2;
|
|
6052
|
+
particles.push({ x, y, dx: Math.cos(angle) * speed, dy: Math.sin(angle) * speed, life: 30, color });
|
|
6053
|
+
}
|
|
6054
|
+
explosionTexts.push({ x, y, text: sim.toFixed(2), life: 40, color });
|
|
6055
|
+
}
|
|
6056
|
+
|
|
6057
|
+
function update() {
|
|
6058
|
+
// Move ship
|
|
6059
|
+
if (keys['ArrowLeft'] || keys['a']) ship.x = Math.max(ship.w / 2, ship.x - ship.speed);
|
|
6060
|
+
if (keys['ArrowRight'] || keys['d']) ship.x = Math.min(W - ship.w / 2, ship.x + ship.speed);
|
|
6061
|
+
if (keys[' ']) shoot();
|
|
6062
|
+
|
|
6063
|
+
// Move bullets
|
|
6064
|
+
for (let i = bullets.length - 1; i >= 0; i--) {
|
|
6065
|
+
bullets[i].y += bullets[i].dy;
|
|
6066
|
+
if (bullets[i].y < -10) bullets.splice(i, 1);
|
|
6067
|
+
}
|
|
6068
|
+
|
|
6069
|
+
// Boss logic
|
|
6070
|
+
if (bossMode && boss) {
|
|
6071
|
+
boss.x += boss.dx;
|
|
6072
|
+
if (boss.x < boss.w / 2 || boss.x > W - boss.w / 2) boss.dx *= -1;
|
|
6073
|
+
// Boss drops enemy vectors periodically
|
|
6074
|
+
if (frame % 60 === 0) {
|
|
6075
|
+
enemies.push({ x: boss.x, y: boss.y + 30, w: 78, h: 22, hp: 1, text: VECTORS[frame % VECTORS.length], dx: 0, dir: 0, dropping: true, dy: 2 });
|
|
6076
|
+
}
|
|
6077
|
+
// Bullet-boss collision
|
|
6078
|
+
for (let i = bullets.length - 1; i >= 0; i--) {
|
|
6079
|
+
const b = bullets[i];
|
|
6080
|
+
if (b.x > boss.x - boss.w / 2 && b.x < boss.x + boss.w / 2 && b.y > boss.y - boss.h / 2 && b.y < boss.y + boss.h / 2) {
|
|
6081
|
+
boss.hp--;
|
|
6082
|
+
bullets.splice(i, 1);
|
|
6083
|
+
const sim = 0.1 + Math.random() * 0.3;
|
|
6084
|
+
addExplosion(b.x, b.y, sim);
|
|
6085
|
+
score += 50;
|
|
6086
|
+
if (boss.hp <= 0) {
|
|
6087
|
+
addExplosion(boss.x, boss.y, 0.99);
|
|
6088
|
+
score += 500;
|
|
6089
|
+
boss = null;
|
|
6090
|
+
bossMode = false;
|
|
6091
|
+
wave++;
|
|
6092
|
+
waveQueued = true;
|
|
6093
|
+
setTimeout(() => { waveQueued = false; spawnWave(); }, 1000);
|
|
6094
|
+
}
|
|
6095
|
+
}
|
|
6096
|
+
}
|
|
6097
|
+
}
|
|
6098
|
+
|
|
6099
|
+
// Move enemies
|
|
6100
|
+
let edgeHit = false;
|
|
6101
|
+
for (const e of enemies) {
|
|
6102
|
+
if (e.dropping) {
|
|
6103
|
+
e.y += e.dy || 2;
|
|
6104
|
+
} else {
|
|
6105
|
+
e.x += e.dx * e.dir;
|
|
6106
|
+
if (e.x < 30 || e.x > W - 30) edgeHit = true;
|
|
6107
|
+
}
|
|
6108
|
+
}
|
|
6109
|
+
if (edgeHit) {
|
|
6110
|
+
for (const e of enemies) {
|
|
6111
|
+
if (!e.dropping) { e.dir *= -1; e.y += 15; }
|
|
6112
|
+
}
|
|
6113
|
+
}
|
|
6114
|
+
|
|
6115
|
+
// Bullet-enemy collision
|
|
6116
|
+
for (let i = bullets.length - 1; i >= 0; i--) {
|
|
6117
|
+
for (let j = enemies.length - 1; j >= 0; j--) {
|
|
6118
|
+
const b = bullets[i], e = enemies[j];
|
|
6119
|
+
if (!b) break;
|
|
6120
|
+
if (b.x > e.x - e.w / 2 && b.x < e.x + e.w / 2 && b.y > e.y - e.h / 2 && b.y < e.y + e.h / 2) {
|
|
6121
|
+
e.hp--;
|
|
6122
|
+
bullets.splice(i, 1);
|
|
6123
|
+
const sim = 0.5 + Math.random() * 0.5;
|
|
6124
|
+
addExplosion(b.x, b.y, sim);
|
|
6125
|
+
if (e.hp <= 0) {
|
|
6126
|
+
enemies.splice(j, 1);
|
|
6127
|
+
score += 100;
|
|
6128
|
+
}
|
|
6129
|
+
break;
|
|
6130
|
+
}
|
|
6131
|
+
}
|
|
6132
|
+
}
|
|
6133
|
+
|
|
6134
|
+
// Enemy reaching bottom = lose life
|
|
6135
|
+
for (let i = enemies.length - 1; i >= 0; i--) {
|
|
6136
|
+
if (enemies[i].y > H - 50) {
|
|
6137
|
+
enemies.splice(i, 1);
|
|
6138
|
+
lives--;
|
|
6139
|
+
if (lives <= 0) gameOver = true;
|
|
6140
|
+
}
|
|
6141
|
+
}
|
|
6142
|
+
|
|
6143
|
+
// Next wave
|
|
6144
|
+
if (!bossMode && enemies.length === 0 && !gameOver && !waveQueued) {
|
|
6145
|
+
wave++;
|
|
6146
|
+
waveQueued = true;
|
|
6147
|
+
setTimeout(() => { waveQueued = false; spawnWave(); }, 800);
|
|
6148
|
+
}
|
|
6149
|
+
|
|
6150
|
+
// Particles
|
|
6151
|
+
for (let i = particles.length - 1; i >= 0; i--) {
|
|
6152
|
+
const p = particles[i];
|
|
6153
|
+
p.x += p.dx; p.y += p.dy; p.life--;
|
|
6154
|
+
if (p.life <= 0) particles.splice(i, 1);
|
|
6155
|
+
}
|
|
6156
|
+
for (let i = explosionTexts.length - 1; i >= 0; i--) {
|
|
6157
|
+
explosionTexts[i].y -= 0.5;
|
|
6158
|
+
explosionTexts[i].life--;
|
|
6159
|
+
if (explosionTexts[i].life <= 0) explosionTexts.splice(i, 1);
|
|
6160
|
+
}
|
|
6161
|
+
}
|
|
6162
|
+
|
|
6163
|
+
function draw() {
|
|
6164
|
+
ctx.fillStyle = '#001E2B';
|
|
6165
|
+
ctx.fillRect(0, 0, W, H);
|
|
6166
|
+
|
|
6167
|
+
// Stars
|
|
6168
|
+
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
|
6169
|
+
for (let i = 0; i < 40; i++) {
|
|
6170
|
+
const sx = (i * 137 + frame * 0.1) % W;
|
|
6171
|
+
const sy = (i * 97 + frame * 0.05) % H;
|
|
6172
|
+
ctx.fillRect(sx, sy, 1, 1);
|
|
6173
|
+
}
|
|
6174
|
+
|
|
6175
|
+
// Ship (triangle)
|
|
6176
|
+
ctx.fillStyle = '#00ED64';
|
|
6177
|
+
ctx.beginPath();
|
|
6178
|
+
ctx.moveTo(ship.x, H - 40);
|
|
6179
|
+
ctx.lineTo(ship.x - ship.w / 2, H - 24);
|
|
6180
|
+
ctx.lineTo(ship.x + ship.w / 2, H - 24);
|
|
6181
|
+
ctx.closePath();
|
|
6182
|
+
ctx.fill();
|
|
6183
|
+
// Ship label
|
|
6184
|
+
ctx.fillStyle = '#00ED64';
|
|
6185
|
+
ctx.font = '9px monospace';
|
|
6186
|
+
ctx.textAlign = 'center';
|
|
6187
|
+
ctx.fillText('query', ship.x, H - 14);
|
|
6188
|
+
|
|
6189
|
+
// Bullets
|
|
6190
|
+
ctx.fillStyle = '#00ED64';
|
|
6191
|
+
for (const b of bullets) {
|
|
6192
|
+
ctx.fillRect(b.x - 1, b.y, 2, 8);
|
|
6193
|
+
}
|
|
6194
|
+
|
|
6195
|
+
// Boss
|
|
6196
|
+
if (boss) {
|
|
6197
|
+
const hpPct = boss.hp / boss.maxHp;
|
|
6198
|
+
ctx.fillStyle = hpPct > 0.5 ? '#FF6960' : '#FFC010';
|
|
6199
|
+
ctx.strokeStyle = '#FF6960';
|
|
6200
|
+
ctx.lineWidth = 2;
|
|
6201
|
+
ctx.strokeRect(boss.x - boss.w / 2, boss.y - boss.h / 2, boss.w, boss.h);
|
|
6202
|
+
ctx.fillStyle = '#FF6960';
|
|
6203
|
+
ctx.font = 'bold 13px monospace';
|
|
6204
|
+
ctx.textAlign = 'center';
|
|
6205
|
+
ctx.fillText(boss.text, boss.x, boss.y + 5);
|
|
6206
|
+
// HP bar
|
|
6207
|
+
ctx.fillStyle = '#3D4F58';
|
|
6208
|
+
ctx.fillRect(boss.x - boss.w / 2, boss.y - boss.h / 2 - 8, boss.w, 4);
|
|
6209
|
+
ctx.fillStyle = '#FF6960';
|
|
6210
|
+
ctx.fillRect(boss.x - boss.w / 2, boss.y - boss.h / 2 - 8, boss.w * hpPct, 4);
|
|
6211
|
+
}
|
|
6212
|
+
|
|
6213
|
+
// Enemies
|
|
6214
|
+
for (const e of enemies) {
|
|
6215
|
+
ctx.fillStyle = 'rgba(61,79,88,0.6)';
|
|
6216
|
+
ctx.fillRect(e.x - e.w / 2, e.y - e.h / 2, e.w, e.h);
|
|
6217
|
+
ctx.strokeStyle = '#0498EC';
|
|
6218
|
+
ctx.lineWidth = 1;
|
|
6219
|
+
ctx.strokeRect(e.x - e.w / 2, e.y - e.h / 2, e.w, e.h);
|
|
6220
|
+
ctx.fillStyle = '#E8EDEB';
|
|
6221
|
+
ctx.font = '9px monospace';
|
|
6222
|
+
ctx.textAlign = 'center';
|
|
6223
|
+
ctx.fillText(e.text, e.x, e.y + 3);
|
|
6224
|
+
}
|
|
6225
|
+
|
|
6226
|
+
// Particles
|
|
6227
|
+
for (const p of particles) {
|
|
6228
|
+
ctx.globalAlpha = p.life / 30;
|
|
6229
|
+
ctx.fillStyle = p.color;
|
|
6230
|
+
ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
|
|
6231
|
+
}
|
|
6232
|
+
ctx.globalAlpha = 1;
|
|
6233
|
+
|
|
6234
|
+
// Explosion texts (similarity scores)
|
|
6235
|
+
for (const et of explosionTexts) {
|
|
6236
|
+
ctx.globalAlpha = et.life / 40;
|
|
6237
|
+
ctx.fillStyle = et.color;
|
|
6238
|
+
ctx.font = 'bold 14px monospace';
|
|
6239
|
+
ctx.textAlign = 'center';
|
|
6240
|
+
ctx.fillText(et.text, et.x, et.y);
|
|
6241
|
+
}
|
|
6242
|
+
ctx.globalAlpha = 1;
|
|
6243
|
+
|
|
6244
|
+
// HUD
|
|
6245
|
+
ctx.fillStyle = '#E8EDEB';
|
|
6246
|
+
ctx.font = '13px monospace';
|
|
6247
|
+
ctx.textAlign = 'left';
|
|
6248
|
+
ctx.fillText(`SCORE: ${score}`, 12, 20);
|
|
6249
|
+
ctx.fillText(`WAVE: ${wave}`, 12, 38);
|
|
6250
|
+
ctx.textAlign = 'right';
|
|
6251
|
+
ctx.fillText('❤️'.repeat(lives), W - 12, 20);
|
|
6252
|
+
|
|
6253
|
+
// Title
|
|
6254
|
+
ctx.fillStyle = '#00ED64';
|
|
6255
|
+
ctx.font = 'bold 10px monospace';
|
|
6256
|
+
ctx.textAlign = 'center';
|
|
6257
|
+
ctx.fillText('VECTOR SPACE INVADERS', W / 2, H - 4);
|
|
6258
|
+
|
|
6259
|
+
// Game over
|
|
6260
|
+
if (gameOver) {
|
|
6261
|
+
ctx.fillStyle = 'rgba(0,30,43,0.8)';
|
|
6262
|
+
ctx.fillRect(0, 0, W, H);
|
|
6263
|
+
ctx.fillStyle = '#00ED64';
|
|
6264
|
+
ctx.font = 'bold 28px monospace';
|
|
6265
|
+
ctx.textAlign = 'center';
|
|
6266
|
+
ctx.fillText('GAME OVER', W / 2, H / 2 - 30);
|
|
6267
|
+
ctx.fillStyle = '#E8EDEB';
|
|
6268
|
+
ctx.font = '16px monospace';
|
|
6269
|
+
ctx.fillText(`Final Score: ${score} | Wave: ${wave}`, W / 2, H / 2 + 10);
|
|
6270
|
+
ctx.fillStyle = '#889397';
|
|
6271
|
+
ctx.font = '12px monospace';
|
|
6272
|
+
ctx.fillText('Press SPACE to play again | ESC to exit', W / 2, H / 2 + 40);
|
|
6273
|
+
|
|
6274
|
+
if (keys[' ']) {
|
|
6275
|
+
// Restart
|
|
6276
|
+
score = 0; wave = 1; lives = 3; gameOver = false; waveQueued = false;
|
|
6277
|
+
enemies = []; bullets.length = 0; particles.length = 0; explosionTexts.length = 0;
|
|
6278
|
+
boss = null; bossMode = false; ship.x = W / 2;
|
|
6279
|
+
spawnWave();
|
|
6280
|
+
}
|
|
6281
|
+
}
|
|
6282
|
+
}
|
|
6283
|
+
|
|
6284
|
+
function loop() {
|
|
6285
|
+
if (!document.getElementById('vsiOverlay') || document.getElementById('vsiOverlay').style.display === 'none') {
|
|
6286
|
+
cleanup();
|
|
6287
|
+
return;
|
|
6288
|
+
}
|
|
6289
|
+
frame++;
|
|
6290
|
+
update();
|
|
6291
|
+
draw();
|
|
6292
|
+
requestAnimationFrame(loop);
|
|
6293
|
+
}
|
|
6294
|
+
|
|
6295
|
+
spawnWave();
|
|
6296
|
+
loop();
|
|
6297
|
+
}
|
|
6298
|
+
|
|
5735
6299
|
// ── Start ──
|
|
5736
6300
|
init();
|
|
5737
6301
|
})();
|
|
@@ -5815,5 +6379,10 @@ init();
|
|
|
5815
6379
|
</p>
|
|
5816
6380
|
</div>
|
|
5817
6381
|
</div>
|
|
6382
|
+
<!-- 🕹️ Vector Space Invaders -->
|
|
6383
|
+
<div id="vsiOverlay" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,30,43,0.92);z-index:99999;align-items:center;justify-content:center;flex-direction:column;gap:12px;">
|
|
6384
|
+
<canvas id="vsiCanvas" width="600" height="500" style="border:1px solid #3D4F58;border-radius:8px;image-rendering:pixelated;"></canvas>
|
|
6385
|
+
<div style="color:#889397;font-size:11px;font-family:monospace;text-align:center;">← → move | SPACE shoot | ESC exit</div>
|
|
6386
|
+
</div>
|
|
5818
6387
|
</body>
|
|
5819
6388
|
</html>
|