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.
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/cli.js +2 -0
- package/src/commands/playground.js +236 -0
- package/src/lib/ui.js +53 -4
- package/src/playground/index.html +1111 -0
- package/test/commands/playground.test.js +137 -0
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# voyageai-cli
|
|
2
2
|
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/mrlynn/voyageai-cli/main/voyageai-cli.png" alt="voyageai-cli" width="600" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
3
7
|
[](https://github.com/mrlynn/voyageai-cli/actions/workflows/ci.yml) [](https://www.npmjs.com/package/voyageai-cli) [](https://opensource.org/licenses/MIT) [](https://nodejs.org)
|
|
4
8
|
|
|
5
9
|
CLI for [Voyage AI](https://www.mongodb.com/docs/voyageai/) embeddings, reranking, and [MongoDB Atlas Vector Search](https://www.mongodb.com/docs/atlas/atlas-vector-search/). Pure Node.js — no Python required.
|
|
6
10
|
|
|
7
|
-
<!-- TODO: Add demo GIF -->
|
|
8
|
-
<!--  -->
|
|
9
|
-
|
|
10
11
|
Generate embeddings, rerank search results, store vectors in Atlas, and run semantic search — all from the command line.
|
|
11
12
|
|
|
12
13
|
> **⚠️ Disclaimer:** This is an independent, community-built tool. It is **not** an official product of MongoDB, Inc. or Voyage AI. It is not supported, endorsed, or maintained by either company. For official documentation, support, and products, visit:
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ const { registerExplain } = require('./commands/explain');
|
|
|
18
18
|
const { registerSimilarity } = require('./commands/similarity');
|
|
19
19
|
const { registerIngest } = require('./commands/ingest');
|
|
20
20
|
const { registerCompletions } = require('./commands/completions');
|
|
21
|
+
const { registerPlayground } = require('./commands/playground');
|
|
21
22
|
const { showBanner, showQuickStart, getVersion } = require('./lib/banner');
|
|
22
23
|
|
|
23
24
|
const version = getVersion();
|
|
@@ -40,6 +41,7 @@ registerExplain(program);
|
|
|
40
41
|
registerSimilarity(program);
|
|
41
42
|
registerIngest(program);
|
|
42
43
|
registerCompletions(program);
|
|
44
|
+
registerPlayground(program);
|
|
43
45
|
|
|
44
46
|
// Append disclaimer to all help output
|
|
45
47
|
program.addHelpText('after', `
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { exec } = require('child_process');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Register the playground command on a Commander program.
|
|
10
|
+
* @param {import('commander').Command} program
|
|
11
|
+
*/
|
|
12
|
+
function registerPlayground(program) {
|
|
13
|
+
program
|
|
14
|
+
.command('playground')
|
|
15
|
+
.description('Launch interactive web playground for Voyage AI')
|
|
16
|
+
.option('-p, --port <port>', 'Port to serve on', '3333')
|
|
17
|
+
.option('--no-open', 'Skip auto-opening browser')
|
|
18
|
+
.action(async (opts) => {
|
|
19
|
+
const port = parseInt(opts.port, 10) || 3333;
|
|
20
|
+
const server = createPlaygroundServer();
|
|
21
|
+
|
|
22
|
+
server.listen(port, () => {
|
|
23
|
+
const url = `http://localhost:${port}`;
|
|
24
|
+
console.log(`🧭 Playground running at ${url} — Press Ctrl+C to stop`);
|
|
25
|
+
|
|
26
|
+
if (opts.open !== false) {
|
|
27
|
+
openBrowser(url);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
server.on('error', (err) => {
|
|
32
|
+
if (err.code === 'EADDRINUSE') {
|
|
33
|
+
console.error(`Error: Port ${port} is already in use. Try --port <other-port>`);
|
|
34
|
+
} else {
|
|
35
|
+
console.error(`Server error: ${err.message}`);
|
|
36
|
+
}
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Graceful shutdown
|
|
41
|
+
const shutdown = () => {
|
|
42
|
+
console.log('\n🧭 Playground stopped.');
|
|
43
|
+
server.close(() => process.exit(0));
|
|
44
|
+
// Force exit after 2s if connections linger
|
|
45
|
+
setTimeout(() => process.exit(0), 2000);
|
|
46
|
+
};
|
|
47
|
+
process.on('SIGINT', shutdown);
|
|
48
|
+
process.on('SIGTERM', shutdown);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create the playground HTTP server (exported for testing).
|
|
54
|
+
* @returns {http.Server}
|
|
55
|
+
*/
|
|
56
|
+
function createPlaygroundServer() {
|
|
57
|
+
const { getApiBase, requireApiKey, generateEmbeddings } = require('../lib/api');
|
|
58
|
+
const { MODEL_CATALOG } = require('../lib/catalog');
|
|
59
|
+
const { cosineSimilarity } = require('../lib/math');
|
|
60
|
+
const { getConfigValue } = require('../lib/config');
|
|
61
|
+
|
|
62
|
+
const htmlPath = path.join(__dirname, '..', 'playground', 'index.html');
|
|
63
|
+
|
|
64
|
+
const server = http.createServer(async (req, res) => {
|
|
65
|
+
// CORS headers for local dev
|
|
66
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
67
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
68
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
69
|
+
|
|
70
|
+
if (req.method === 'OPTIONS') {
|
|
71
|
+
res.writeHead(204);
|
|
72
|
+
res.end();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Serve HTML
|
|
78
|
+
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
|
|
79
|
+
const html = fs.readFileSync(htmlPath, 'utf8');
|
|
80
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
81
|
+
res.end(html);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// API: Models
|
|
86
|
+
if (req.method === 'GET' && req.url === '/api/models') {
|
|
87
|
+
const models = MODEL_CATALOG.filter(m => !m.legacy);
|
|
88
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
89
|
+
res.end(JSON.stringify({ models }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// API: Config
|
|
94
|
+
if (req.method === 'GET' && req.url === '/api/config') {
|
|
95
|
+
const key = process.env.VOYAGE_API_KEY || getConfigValue('apiKey');
|
|
96
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
97
|
+
res.end(JSON.stringify({
|
|
98
|
+
baseUrl: getApiBase(),
|
|
99
|
+
hasKey: !!key,
|
|
100
|
+
}));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Parse JSON body for POST routes
|
|
105
|
+
if (req.method === 'POST') {
|
|
106
|
+
const body = await readBody(req);
|
|
107
|
+
let parsed;
|
|
108
|
+
try {
|
|
109
|
+
parsed = JSON.parse(body);
|
|
110
|
+
} catch {
|
|
111
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
112
|
+
res.end(JSON.stringify({ error: 'Invalid JSON body' }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// API: Embed
|
|
117
|
+
if (req.url === '/api/embed') {
|
|
118
|
+
const { texts, model, inputType, dimensions } = parsed;
|
|
119
|
+
if (!texts || !Array.isArray(texts) || texts.length === 0) {
|
|
120
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
121
|
+
res.end(JSON.stringify({ error: 'texts must be a non-empty array' }));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const result = await generateEmbeddings(texts, {
|
|
125
|
+
model: model || undefined,
|
|
126
|
+
inputType: inputType || undefined,
|
|
127
|
+
dimensions: dimensions || undefined,
|
|
128
|
+
});
|
|
129
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
130
|
+
res.end(JSON.stringify(result));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// API: Rerank
|
|
135
|
+
if (req.url === '/api/rerank') {
|
|
136
|
+
const { query, documents, model, topK } = parsed;
|
|
137
|
+
if (!query || !documents || !Array.isArray(documents)) {
|
|
138
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
139
|
+
res.end(JSON.stringify({ error: 'query and documents are required' }));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const { apiRequest } = require('../lib/api');
|
|
143
|
+
const rerankBody = {
|
|
144
|
+
query,
|
|
145
|
+
documents,
|
|
146
|
+
model: model || 'rerank-2.5',
|
|
147
|
+
};
|
|
148
|
+
if (topK) rerankBody.top_k = topK;
|
|
149
|
+
const result = await apiRequest('/rerank', rerankBody);
|
|
150
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
151
|
+
res.end(JSON.stringify(result));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// API: Similarity
|
|
156
|
+
if (req.url === '/api/similarity') {
|
|
157
|
+
const { texts, model } = parsed;
|
|
158
|
+
if (!texts || !Array.isArray(texts) || texts.length < 2) {
|
|
159
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
160
|
+
res.end(JSON.stringify({ error: 'texts must be an array with at least 2 items' }));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const result = await generateEmbeddings(texts, { model: model || undefined });
|
|
164
|
+
const embeddings = result.data.map(d => d.embedding);
|
|
165
|
+
|
|
166
|
+
// Compute pairwise similarity matrix
|
|
167
|
+
const matrix = [];
|
|
168
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
169
|
+
const row = [];
|
|
170
|
+
for (let j = 0; j < embeddings.length; j++) {
|
|
171
|
+
row.push(i === j ? 1.0 : cosineSimilarity(embeddings[i], embeddings[j]));
|
|
172
|
+
}
|
|
173
|
+
matrix.push(row);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
177
|
+
res.end(JSON.stringify({
|
|
178
|
+
matrix,
|
|
179
|
+
embeddings: result.data,
|
|
180
|
+
usage: result.usage,
|
|
181
|
+
model: result.model,
|
|
182
|
+
}));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 404
|
|
188
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
189
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
190
|
+
|
|
191
|
+
} catch (err) {
|
|
192
|
+
// Catch API errors that call process.exit — we override for playground
|
|
193
|
+
console.error(`Playground API error: ${err.message}`);
|
|
194
|
+
if (!res.headersSent) {
|
|
195
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
196
|
+
res.end(JSON.stringify({ error: err.message || 'Internal server error' }));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return server;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Read the full request body as a string.
|
|
206
|
+
* @param {http.IncomingMessage} req
|
|
207
|
+
* @returns {Promise<string>}
|
|
208
|
+
*/
|
|
209
|
+
function readBody(req) {
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
const chunks = [];
|
|
212
|
+
req.on('data', chunk => chunks.push(chunk));
|
|
213
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
|
214
|
+
req.on('error', reject);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Open a URL in the default browser (cross-platform).
|
|
220
|
+
* @param {string} url
|
|
221
|
+
*/
|
|
222
|
+
function openBrowser(url) {
|
|
223
|
+
const platform = process.platform;
|
|
224
|
+
let cmd;
|
|
225
|
+
if (platform === 'darwin') cmd = `open "${url}"`;
|
|
226
|
+
else if (platform === 'win32') cmd = `start "${url}"`;
|
|
227
|
+
else cmd = `xdg-open "${url}"`;
|
|
228
|
+
|
|
229
|
+
exec(cmd, (err) => {
|
|
230
|
+
if (err) {
|
|
231
|
+
console.log(`Could not auto-open browser. Visit: ${url}`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = { registerPlayground, createPlaygroundServer };
|
package/src/lib/ui.js
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const pc = require('picocolors');
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
|
|
5
|
+
// ora v9 is ESM-only. Use dynamic import with a sync fallback for environments
|
|
6
|
+
// that don't support top-level require() of ESM (Node 18).
|
|
7
|
+
let _ora = null;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get the ora spinner function. Lazy-loaded via dynamic import.
|
|
11
|
+
* Returns a no-op fallback if ora can't be loaded (Node 18 CJS compat).
|
|
12
|
+
* @returns {Promise<Function>}
|
|
13
|
+
*/
|
|
14
|
+
async function getOra() {
|
|
15
|
+
if (_ora) return _ora;
|
|
16
|
+
try {
|
|
17
|
+
const mod = await import('ora');
|
|
18
|
+
_ora = mod.default || mod;
|
|
19
|
+
} catch {
|
|
20
|
+
// Fallback: no-op spinner for environments where ora can't load
|
|
21
|
+
_ora = ({ text }) => ({
|
|
22
|
+
start() { if (text) process.stderr.write(text + '\n'); return this; },
|
|
23
|
+
stop() { return this; },
|
|
24
|
+
succeed() { return this; },
|
|
25
|
+
fail() { return this; },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return _ora;
|
|
29
|
+
}
|
|
6
30
|
|
|
7
31
|
// Semantic color helpers
|
|
8
32
|
const ui = {
|
|
@@ -40,8 +64,33 @@ const ui = {
|
|
|
40
64
|
return s;
|
|
41
65
|
},
|
|
42
66
|
|
|
43
|
-
|
|
44
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Create a spinner. Returns an object with start()/stop().
|
|
69
|
+
* Because ora is loaded async, this returns a proxy that buffers
|
|
70
|
+
* the start call until ora is ready.
|
|
71
|
+
* @param {string} text
|
|
72
|
+
* @returns {{ start: Function, stop: Function }}
|
|
73
|
+
*/
|
|
74
|
+
spinner: (text) => {
|
|
75
|
+
let realSpinner = null;
|
|
76
|
+
let started = false;
|
|
77
|
+
const proxy = {
|
|
78
|
+
start() {
|
|
79
|
+
started = true;
|
|
80
|
+
getOra().then(ora => {
|
|
81
|
+
realSpinner = ora({ text, color: 'cyan' });
|
|
82
|
+
if (started) realSpinner.start();
|
|
83
|
+
});
|
|
84
|
+
return proxy;
|
|
85
|
+
},
|
|
86
|
+
stop() {
|
|
87
|
+
started = false;
|
|
88
|
+
if (realSpinner) realSpinner.stop();
|
|
89
|
+
return proxy;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
return proxy;
|
|
93
|
+
},
|
|
45
94
|
};
|
|
46
95
|
|
|
47
96
|
module.exports = ui;
|