voyageai-cli
Version:
CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search
1,164 lines (1,048 loc) ⢠97.1 kB
JavaScript
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const { exec } = require('child_process');
const crypto = require('crypto');
/**
* Load announcements from the markdown file.
* Format: Each announcement is separated by `---` and has YAML-like metadata
* followed by a ## title and description paragraph.
*/
function loadAnnouncementsFromMarkdown() {
const mdPath = path.join(__dirname, '..', 'playground', 'announcements.md');
if (!fs.existsSync(mdPath)) {
console.warn('Announcements file not found:', mdPath);
return [];
}
const content = fs.readFileSync(mdPath, 'utf-8');
// Split by --- separator (skip the header section before first ---)
const sections = content.split(/\n---\n/).slice(1);
const announcements = [];
for (const section of sections) {
const lines = section.trim().split('\n');
const metadata = {};
let titleIndex = -1;
// Parse metadata lines (key: value format)
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Stop at the title (## heading)
if (line.startsWith('## ')) {
titleIndex = i;
break;
}
// Parse key: value
const match = line.match(/^([a-z_]+):\s*(.+)$/i);
if (match) {
metadata[match[1]] = match[2].trim();
}
}
if (titleIndex === -1 || !metadata.id) continue;
// Extract title (## heading)
const title = lines[titleIndex].replace(/^##\s*/, '').trim();
// Extract description (paragraphs after the title)
const descriptionLines = [];
for (let i = titleIndex + 1; i < lines.length; i++) {
const line = lines[i].trim();
if (line) descriptionLines.push(line);
}
const description = descriptionLines.join(' ');
announcements.push({
id: metadata.id,
title,
description,
badge: metadata.badge || 'Info',
published: metadata.published || new Date().toISOString().split('T')[0],
expires: metadata.expires || '2099-12-31',
bg_image: metadata.bg_image || null,
bg_color: metadata.bg_color || null,
icon: metadata.icon || null,
cta: {
label: metadata.cta_label || 'Learn More',
action: metadata.cta_action || 'link',
target: metadata.cta_target || '#'
}
});
}
return announcements;
}
/**
* Register the playground command on a Commander program.
* @param {import('commander').Command} program
*/
function registerPlayground(program) {
program
.command('playground')
.description('Launch interactive web playground for Voyage AI')
.option('-p, --port <port>', 'Port to serve on', '3333')
.option('--host <address>', 'Bind address', '127.0.0.1')
.option('--no-open', 'Skip auto-opening browser')
.action(async (opts) => {
const port = parseInt(opts.port, 10) || 3333;
const host = opts.host || '127.0.0.1';
const server = createPlaygroundServer();
server.listen(port, host, () => {
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
const url = `http://${displayHost}:${port}`;
console.log(`š§ Playground running at ${url} ā Press Ctrl+C to stop`);
if (opts.open !== false) {
openBrowser(url);
}
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(`Error: Port ${port} is already in use. Try --port <other-port>`);
} else {
console.error(`Server error: ${err.message}`);
}
process.exit(1);
});
// Graceful shutdown
const shutdown = () => {
console.log('\nš§ Playground stopped.');
server.close(() => process.exit(0));
// Force exit after 2s if connections linger
setTimeout(() => process.exit(0), 2000);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
});
}
/**
* Create the playground HTTP server (exported for testing).
* @returns {http.Server}
*/
function createPlaygroundServer() {
const { getApiBase, requireApiKey, generateEmbeddings } = require('../lib/api');
const { MODEL_CATALOG, BENCHMARK_SCORES } = require('../lib/catalog');
const { cosineSimilarity } = require('../lib/math');
const { getConfigValue } = require('../lib/config');
const { handleRAGRequest } = require('../lib/playground-rag-api');
const { handleNanoRequest } = require('../lib/playground-nano-api');
const htmlPath = path.join(__dirname, '..', 'playground', 'index.html');
const loginHtmlPath = path.join(__dirname, '..', 'playground', 'login.html');
// Optional access control for hosted/shared environments.
// If VAI_PLAYGROUND_PASSWORD is set, the UI and API require a login cookie.
const playgroundPassword = process.env.VAI_PLAYGROUND_PASSWORD || '';
const authEnabled = !!playgroundPassword;
const sessions = new Map(); // sessionId -> { createdAt }
const SESSION_COOKIE = 'vai_playground_session';
function parseCookies(req) {
const header = req.headers.cookie || '';
if (!header) return {};
const out = {};
const parts = header.split(';');
for (const p of parts) {
const i = p.indexOf('=');
if (i === -1) continue;
const key = p.slice(0, i).trim();
const val = p.slice(i + 1).trim();
if (!key) continue;
try {
out[key] = decodeURIComponent(val);
} catch {
out[key] = val;
}
}
return out;
}
function isApiRequest(reqUrl) {
return typeof reqUrl === 'string' && reqUrl.startsWith('/api/');
}
function isAuthenticated(req) {
if (!authEnabled) return true;
const cookies = parseCookies(req);
const sessionId = cookies[SESSION_COOKIE];
if (!sessionId) return false;
return sessions.has(sessionId);
}
function setSessionCookie(res, sessionId) {
const cookie = [
`${SESSION_COOKIE}=${encodeURIComponent(sessionId)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
'Max-Age=86400',
].join('; ');
res.setHeader('Set-Cookie', cookie);
}
function clearSessionCookie(res) {
const cookie = [
`${SESSION_COOKIE}=`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
'Max-Age=0',
].join('; ');
res.setHeader('Set-Cookie', cookie);
}
async function readJsonBody(req) {
const body = await readBody(req);
if (!body) return {};
try {
return JSON.parse(body);
} catch {
throw new Error('Invalid JSON body');
}
}
// Chat history ā scoped to the server lifetime (in-memory, no persistence)
let _chatHistory = null;
// MemoryManager ā tracks the active strategy for /api/chat/memory reporting
let _playgroundMemoryManager = null;
// Workflow store catalog cache (15 min TTL)
let _catalogCache = null;
let _catalogCacheTime = 0;
const server = http.createServer(async (req, res) => {
// CORS headers for local dev
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
try {
// āā Playground auth (optional) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
// Public endpoints: /login + auth endpoints; everything else requires a session cookie.
if (authEnabled) {
// Serve login page
if (req.method === 'GET' && (req.url === '/login' || req.url?.startsWith('/login?'))) {
const loginHtml = fs.existsSync(loginHtmlPath)
? fs.readFileSync(loginHtmlPath, 'utf8')
: '<h1>Missing login.html</h1>';
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
});
res.end(loginHtml);
return;
}
// Auth status (used by clients / health checks)
if (req.method === 'GET' && req.url === '/api/auth/status') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ authenticated: isAuthenticated(req) }));
return;
}
// Login
if (req.method === 'POST' && req.url === '/api/auth/login') {
let payload;
try {
payload = await readJsonBody(req);
} catch (err) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
return;
}
const pwd = String(payload.password || '');
if (!pwd || pwd !== playgroundPassword) {
// Add a tiny delay to discourage brute force.
await new Promise(r => setTimeout(r, 250));
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid password' }));
return;
}
const sessionId = crypto.randomBytes(24).toString('hex');
sessions.set(sessionId, { createdAt: Date.now() });
setSessionCookie(res, sessionId);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
return;
}
// Logout
if (req.method === 'POST' && req.url === '/api/auth/logout') {
const cookies = parseCookies(req);
const sessionId = cookies[SESSION_COOKIE];
if (sessionId) sessions.delete(sessionId);
clearSessionCookie(res);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
return;
}
// Enforce auth for everything else (except OPTIONS handled above)
if (!isAuthenticated(req)) {
if (isApiRequest(req.url)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Authentication required' }));
return;
}
const next = encodeURIComponent(req.url || '/');
res.writeHead(302, { Location: `/login?next=${next}` });
res.end();
return;
}
}
// Handle RAG API requests
if (req.url.startsWith('/api/rag/')) {
const handled = await handleRAGRequest(req, res, {
generateEmbeddings,
generateLocalEmbeddings: require('../nano/nano-local.js').generateLocalEmbeddings,
});
if (handled) return;
}
// Handle Nano API requests
if (req.url.startsWith('/api/nano/')) {
const handled = await handleNanoRequest(req, res, {
readJsonBody,
generateLocalEmbeddings: require('../nano/nano-local.js').generateLocalEmbeddings,
checkPython: require('../nano/nano-health.js').checkPython,
checkVenv: require('../nano/nano-health.js').checkVenv,
checkModel: require('../nano/nano-health.js').checkModel,
checkDevice: require('../nano/nano-health.js').checkDevice,
generateCloudEmbeddings: generateEmbeddings,
});
if (handled) return;
}
// Serve HTML
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
const { getVersion } = require('../lib/banner');
let html = fs.readFileSync(htmlPath, 'utf8');
html = html.replace('</head>', `<script>window.__VAI_VERSION__="${getVersion()}";</script></head>`);
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
});
res.end(html);
return;
}
// Serve watermark image
if (req.method === 'GET' && req.url === '/icons/watermark.png') {
const wmPath = path.join(__dirname, '..', 'playground', 'icons', 'watermark.png');
if (fs.existsSync(wmPath)) {
const data = fs.readFileSync(wmPath);
res.writeHead(200, {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
} else {
res.writeHead(404);
res.end('Watermark not found');
}
return;
}
// Serve V.png logo
if (req.method === 'GET' && req.url === '/icons/V.png') {
const logoPath = path.join(__dirname, '..', 'playground', 'icons', 'V.png');
if (fs.existsSync(logoPath)) {
const data = fs.readFileSync(logoPath);
res.writeHead(200, {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
} else {
res.writeHead(404);
res.end('Logo not found');
}
return;
}
// Serve icon assets: /icons/{dark|light}/{size}.png
const iconMatch = req.url.match(/^\/icons\/(dark|light)\/(\d+)\.png$/);
if (req.method === 'GET' && iconMatch) {
const variant = iconMatch[1];
const size = iconMatch[2];
// Try portable path first (src/playground/icons/), then electron/icons/
const portablePath = path.join(__dirname, '..', 'playground', 'icons', variant, `${size}.png`);
const electronPath = path.join(__dirname, '..', '..', 'electron', 'icons', variant,
'AppIcons', 'Assets.xcassets', 'AppIcon.appiconset', `${size}.png`);
const iconPath = fs.existsSync(portablePath) ? portablePath : electronPath;
if (fs.existsSync(iconPath)) {
const data = fs.readFileSync(iconPath);
res.writeHead(200, {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
} else {
res.writeHead(404);
res.end('Icon not found');
}
return;
}
// Serve announcement assets: /assets/announcements/{filename}
const assetMatch = req.url.match(/^\/assets\/announcements\/([a-zA-Z0-9_.-]+\.(jpg|jpeg|png|webp|gif))$/);
if (req.method === 'GET' && assetMatch) {
const assetPath = path.join(__dirname, '..', 'playground', 'assets', 'announcements', assetMatch[1]);
if (fs.existsSync(assetPath)) {
const mimeTypes = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif' };
const data = fs.readFileSync(assetPath);
res.writeHead(200, {
'Content-Type': mimeTypes[assetMatch[2]] || 'application/octet-stream',
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
} else {
res.writeHead(404);
res.end('Asset not found');
}
return;
}
// Serve home promo assets: /assets/home/{filename}
const homeAssetMatch = req.url.match(/^\/assets\/home\/([a-zA-Z0-9_.-]+\.(mp4|webm|png|jpg|jpeg|gif))$/);
if (req.method === 'GET' && homeAssetMatch) {
const assetPath = path.join(__dirname, '..', 'playground', 'assets', 'home', homeAssetMatch[1]);
if (fs.existsSync(assetPath)) {
const mimeTypes = {
mp4: 'video/mp4',
webm: 'video/webm',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
};
const data = fs.readFileSync(assetPath);
res.writeHead(200, {
'Content-Type': mimeTypes[homeAssetMatch[2]] || 'application/octet-stream',
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
} else {
res.writeHead(404);
res.end('Asset not found');
}
return;
}
// Serve vendor assets (bundled JS libraries)
const vendorMatch = req.url.match(/^\/vendor\/([a-zA-Z0-9_.-]+\.js)$/);
if (req.method === 'GET' && vendorMatch) {
const vendorPath = path.join(__dirname, '..', 'playground', 'vendor', vendorMatch[1]);
if (fs.existsSync(vendorPath)) {
const data = fs.readFileSync(vendorPath);
res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
} else {
res.writeHead(404);
res.end('Vendor asset not found');
}
return;
}
// Serve playground JS modules
const jsMatch = req.url.match(/^\/js\/([a-zA-Z0-9_.-]+\.js)$/);
if (req.method === 'GET' && jsMatch) {
const jsPath = path.join(__dirname, '..', 'playground', 'js', jsMatch[1]);
if (fs.existsSync(jsPath)) {
const data = fs.readFileSync(jsPath);
res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-cache, must-revalidate',
'Pragma': 'no-cache',
});
res.end(data);
} else {
res.writeHead(404);
res.end('JS file not found');
}
return;
}
// API: Models
if (req.method === 'GET' && req.url === '/api/models') {
const models = MODEL_CATALOG.filter(m => !m.legacy && !m.local && !m.unreleased);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ models }));
return;
}
// API: Full Model Catalog (for Models tab)
if (req.method === 'GET' && req.url === '/api/models/catalog') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ models: MODEL_CATALOG, benchmarks: BENCHMARK_SCORES }));
return;
}
// API: Optimize ā check data readiness
if (req.method === 'GET' && req.url === '/api/optimize/status') {
const { handleOptimizeStatus } = require('../lib/playground-optimize-api');
await handleOptimizeStatus(req, res);
return;
}
// API: Optimize ā prepare demo data (ingest + index)
// Use startsWith to handle query params like ?force=true
if (req.method === 'POST' && req.url.startsWith('/api/optimize/prepare')) {
const { handleOptimizePrepare } = require('../lib/playground-optimize-api');
await handleOptimizePrepare(req, res);
return;
}
// API: Optimize analysis
if (req.method === 'POST' && req.url === '/api/optimize/analyze') {
const { handleOptimizeAnalyze } = require('../lib/playground-optimize-api');
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
await handleOptimizeAnalyze(req, res, body);
});
return;
}
// API: Generate code
if (req.method === 'POST' && req.url === '/api/generate') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const { target, component, config } = JSON.parse(body);
const codegen = require('../lib/codegen');
const templateMap = {
vanilla: { client: 'client.js', connection: 'connection.js', retrieval: 'retrieval.js', ingest: 'ingest.js', 'search-api': 'search-api.js' },
nextjs: { client: 'lib-voyage.js', connection: 'lib-mongo.js', retrieval: 'route-search.js', ingest: 'route-ingest.js', 'search-page': 'page-search.jsx' },
python: { client: 'voyage_client.py', connection: 'mongo_client.py', retrieval: 'app.py', ingest: 'chunker.py' },
};
const templateName = (templateMap[target] || {})[component];
if (!templateName) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Unknown component: ${component}` }));
return;
}
const context = codegen.buildContext(config || {}, { projectName: 'my-app' });
const code = codegen.renderTemplate(target, templateName.replace(/\.(js|jsx|py)$/, ''), context);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ code, filename: templateName }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
// API: Scaffold project (returns ZIP for web mode)
if (req.method === 'POST' && req.url === '/api/scaffold') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
try {
const { projectName, target, config } = JSON.parse(body);
const codegen = require('../lib/codegen');
const { PROJECT_STRUCTURE } = require('../lib/scaffold-structure');
const { createZip } = require('../lib/zip');
const structure = PROJECT_STRUCTURE[target];
if (!structure) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Unknown target: ${target}` }));
return;
}
const context = codegen.buildContext(config || {}, { projectName: projectName || 'my-app' });
const files = [];
// Render template files
for (const file of structure.files) {
const content = codegen.renderTemplate(target, file.template.replace(/\.(js|jsx|py|json|md|txt)$/, ''), context);
files.push({
name: `${projectName}/${file.output}`,
content,
});
}
// Add extra static files
if (structure.extraFiles) {
for (const file of structure.extraFiles) {
const content = typeof file.content === 'function' ? file.content(context) : file.content;
files.push({
name: `${projectName}/${file.output}`,
content,
});
}
}
// Add binary files
if (structure.binaryFiles) {
for (const file of structure.binaryFiles) {
const srcPath = path.join(__dirname, '..', 'lib', 'templates', target, file.source);
if (fs.existsSync(srcPath)) {
files.push({
name: `${projectName}/${file.output}`,
content: fs.readFileSync(srcPath),
binary: true,
});
}
}
}
// Create ZIP
const zipBuffer = createZip(files);
res.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${projectName}.zip"`,
'Content-Length': zipBuffer.length,
});
res.end(zipBuffer);
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
// API: Concepts (from vai explain)
if (req.method === 'GET' && req.url === '/api/concepts') {
const { concepts } = require('../lib/explanations');
// Strip picocolors ANSI from content for web display
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1b\[[0-9;]*m/g;
const stripped = {};
for (const [key, concept] of Object.entries(concepts)) {
stripped[key] = {
title: concept.title,
summary: concept.summary,
content: (typeof concept.content === 'string' ? concept.content : concept.content).replace(ANSI_RE, ''),
links: concept.links || [],
tryIt: concept.tryIt || [],
keyPoints: concept.keyPoints || [],
};
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ concepts: stripped }));
return;
}
// API: Workflow node help
if (req.method === 'GET' && req.url === '/api/workflows/node-help') {
const nodeHelp = require('../playground/help/workflow-nodes');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ nodeHelp }));
return;
}
// API: Chat config (GET)
if (req.method === 'GET' && req.url === '/api/chat/config') {
const { resolveLLMConfig } = require('../lib/llm');
const { loadProject } = require('../lib/project');
const { getConfigValue } = require('../lib/config');
const llmConfig = resolveLLMConfig();
const { config: proj } = loadProject();
// Check nano availability
let nanoAvailable = false;
try {
const { checkVenv, checkModel } = require('../nano/nano-health');
const venvOk = checkVenv().ok;
const modelOk = checkModel().ok;
nanoAvailable = venvOk && modelOk;
} catch { /* nano-health not available */ }
// Check API key availability
const hasApiKey = !!(process.env.VOYAGE_API_KEY || getConfigValue('apiKey'));
// Check Ollama availability
let ollamaAvailable = false;
try {
const { listOllamaModels } = require('../lib/llm');
const ollamaModels = await listOllamaModels({ timeoutMs: 2000 });
ollamaAvailable = ollamaModels && ollamaModels.length > 0;
} catch { /* ollama not reachable */ }
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
provider: llmConfig.provider || null,
model: llmConfig.model || null,
hasLLMKey: !!llmConfig.apiKey || llmConfig.provider === 'ollama',
db: proj.db || null,
collection: proj.collection || null,
chat: proj.chat || {},
mode: proj.chat?.mode || 'pipeline',
embeddingModel: proj.chat?.embeddingModel || null,
nanoAvailable,
hasApiKey,
ollamaAvailable,
}));
return;
}
// API: Chat memory status ā returns current memory utilization
if (req.method === 'GET' && req.url === '/api/chat/memory') {
const { MemoryBudget } = require('../lib/memory-budget');
const { estimateTokens } = require('../lib/turn-state');
const budget = new MemoryBudget();
const historyBudget = budget.estimateSlotTokens({
systemPrompt: '',
contextDocs: [],
currentMessage: '',
});
const breakdown = budget.getBreakdown() || {};
// Current history utilization
const turns = _chatHistory ? _chatHistory.getMessages() : [];
const usedTokens = turns.reduce((sum, t) => sum + estimateTokens(t.content || ''), 0);
const utilization = historyBudget > 0 ? Math.min(100, Math.round((usedTokens / historyBudget) * 100)) : 0;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
strategy: _playgroundMemoryManager ? _playgroundMemoryManager._defaultStrategy : 'sliding_window',
availableStrategies: ['sliding_window', 'summarization', 'hierarchical'],
budget: {
modelLimit: breakdown.modelLimit || 128000,
reservedResponse: breakdown.reservedResponse || 4096,
historyBudget,
},
utilization: {
turnCount: Math.floor(turns.length / 2),
tokensUsed: usedTokens,
tokensBudget: historyBudget,
percent: utilization,
},
}));
return;
}
// API: Chat models ā list available models for a provider
if (req.method === 'GET' && req.url?.startsWith('/api/chat/models')) {
const url = new URL(req.url, 'http://localhost');
const provider = url.searchParams.get('provider');
if (!provider) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'provider query param required' }));
return;
}
const { listModels } = require('../lib/llm');
const models = await listModels(provider);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ provider, models }));
return;
}
// API: Config
if (req.method === 'GET' && req.url === '/api/config') {
const key = process.env.VOYAGE_API_KEY || getConfigValue('apiKey');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
baseUrl: getApiBase(),
hasKey: !!key,
}));
return;
}
// API: Version ā return CLI package version
if (req.method === 'GET' && req.url === '/api/version') {
const { getVersion } = require('../lib/banner');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ version: getVersion() }));
return;
}
// API: Doctor health checks
if (req.method === 'GET' && req.url === '/api/doctor') {
try {
const { runChecks } = require('./doctor');
const results = await runChecks();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(results));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// API: MCP status ā installation status across all tools
if (req.method === 'GET' && req.url === '/api/mcp/status') {
try {
const { statusAll } = require('../mcp/install');
const results = statusAll();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(results));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// API: MCP install ā install vai into a target tool
if (req.method === 'POST' && req.url === '/api/mcp/install') {
try {
const body = await readBody(req);
const { target, force } = JSON.parse(body);
if (!target) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'target is required' }));
return;
}
const { installTarget } = require('../mcp/install');
const result = installTarget(target, { force: force || false });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// API: MCP uninstall ā remove vai from a target tool
if (req.method === 'POST' && req.url === '/api/mcp/uninstall') {
try {
const body = await readBody(req);
const { target } = JSON.parse(body);
if (!target) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'target is required' }));
return;
}
const { uninstallTarget } = require('../mcp/install');
const result = uninstallTarget(target);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// API: Settings ā read/write ~/.vai/config.json
if (req.method === 'GET' && req.url === '/api/settings') {
const { loadConfig, KEY_MAP, SECRET_KEYS, maskSecret } = require('../lib/config');
const config = loadConfig();
// Build response: CLI key name ā masked/raw value, for every known key
const reverseMap = {};
for (const [cliKey, internalKey] of Object.entries(KEY_MAP)) {
reverseMap[internalKey] = cliKey;
}
const settings = {};
for (const [internalKey, cliKey] of Object.entries(reverseMap)) {
const value = config[internalKey];
settings[cliKey] = {
value: value != null ? (SECRET_KEYS.has(internalKey) ? maskSecret(value) : value) : null,
isSet: value != null,
isSecret: SECRET_KEYS.has(internalKey),
internalKey,
};
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(settings));
return;
}
if (req.method === 'PUT' && req.url === '/api/settings') {
const { loadConfig, saveConfig, KEY_MAP, SECRET_KEYS } = require('../lib/config');
const body = await readBody(req);
let updates;
try {
updates = JSON.parse(body);
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
return;
}
const config = loadConfig();
const applied = [];
for (const [cliKey, value] of Object.entries(updates)) {
const internalKey = KEY_MAP[cliKey];
if (!internalKey) {
continue; // Skip unknown keys
}
// Don't overwrite secrets with masked values
if (SECRET_KEYS.has(internalKey) && typeof value === 'string' && value.includes('...')) {
continue;
}
if (value === null || value === '') {
delete config[internalKey];
} else {
config[internalKey] = value;
}
applied.push(cliKey);
}
saveConfig(config);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ applied, message: `Updated ${applied.length} setting(s)` }));
return;
}
// API: Settings reveal ā return unmasked value for a specific secret key
if (req.method === 'GET' && req.url.startsWith('/api/settings/reveal/')) {
const { loadConfig, KEY_MAP, SECRET_KEYS } = require('../lib/config');
const cliKey = req.url.replace('/api/settings/reveal/', '');
const internalKey = KEY_MAP[cliKey];
if (!internalKey || !SECRET_KEYS.has(internalKey)) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found or not a secret key' }));
return;
}
const config = loadConfig();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ value: config[internalKey] || null }));
return;
}
// API: Settings origins ā where each config value comes from
if (req.method === 'GET' && req.url === '/api/settings/origins') {
const { resolveLLMConfig } = require('../lib/llm');
const { loadProject } = require('../lib/project');
const { config: proj } = loadProject();
const chatConf = proj.chat || {};
function resolveOrigin(envVar, configKey, projectValue) {
if (envVar && process.env[envVar]) return 'env';
if (configKey && getConfigValue(configKey)) return 'config';
if (projectValue) return 'project';
return 'default';
}
const origins = {
apiKey: resolveOrigin('VOYAGE_API_KEY', 'apiKey'),
apiBase: resolveOrigin('VOYAGE_API_BASE', 'baseUrl'),
provider: resolveOrigin('VAI_LLM_PROVIDER', 'llmProvider', chatConf.provider),
model: resolveOrigin('VAI_LLM_MODEL', 'llmModel', chatConf.model),
llmApiKey: resolveOrigin('VAI_LLM_API_KEY', 'llmApiKey'),
db: resolveOrigin(null, 'defaultDb', proj.db),
collection: resolveOrigin(null, 'defaultCollection', proj.collection),
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(origins));
return;
}
// API: Workflow Store catalog (cached 15 min)
if (req.method === 'GET' && req.url === '/api/workflows/catalog') {
const _catStart = Date.now();
try {
// Check cache
if (_catalogCache && (Date.now() - _catalogCacheTime < 15 * 60 * 1000)) {
console.log(`[catalog] served from cache in ${Date.now() - _catStart}ms`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(_catalogCache));
return;
}
const { getRegistry } = require('../lib/workflow-registry');
const registry = getRegistry({ force: true });
// Build set of installed package names
const installedNames = new Set();
for (const c of [...(registry.official || []), ...(registry.community || [])]) {
if (c.name) installedNames.add(c.name);
}
// Try to fetch from npm
let npmWorkflows = [];
try {
const { searchNpm } = require('../lib/npm-utils');
const results = await searchNpm('', { limit: 50 });
npmWorkflows = results || [];
} catch (e) {
// npm unreachable ā fall back to installed only
}
// Fetch registry metadata (for vai-workflow field, inputs, author)
// Only one request per package ā no unpkg or downloads API on the critical path
const metadataCache = {};
await Promise.all(npmWorkflows.map(async (r) => {
try {
const encodedName = r.name.startsWith('@')
? `@${encodeURIComponent(r.name.slice(1))}`
: encodeURIComponent(r.name);
const regRes = await fetch(`https://registry.npmjs.org/${encodedName}/latest`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (regRes.ok) {
metadataCache[r.name] = await regRes.json();
}
} catch {
// Fall back to basic data from search
}
}));
// āā Lucide icon paths for workflow branding āā
// Curated subset of Lucide icons (lucide.dev, MIT) for the store.
// Each value is an SVG path (or multiple paths separated by a convention
// that the client renders inside a 24x24 viewBox with stroke).
const STORE_ICONS = {
trophy: 'M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2z',
search: 'M21 21l-4.3-4.3M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
'dollar-sign':'M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6',
split: 'M16 3h5v5M8 3H3v5M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3M21 3l-7.828 7.828A4 4 0 0 0 12 13.7V22',
'file-search': 'M14 2v4a2 2 0 0 0 2 2h4M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7zM9.5 12.5a2.5 2.5 0 1 0 5 0 2.5 2.5 0 1 0-5 0M13.3 14.3 15 16',
database: 'M21 5c0 1.1-3.134 3-9 3S3 6.1 3 5M21 5c0-1.1-3.134-3-9-3S3 3.9 3 5M21 5v14c0 1.1-3.134 3-9 3s-9-1.9-9-3V5M21 12c0 1.1-3.134 3-9 3s-9-1.9-9-3',
activity: 'M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36-3.18-19.64A2 2 0 0 0 10.12 1h-.24a2 2 0 0 0-1.94 1.55L5.18 12H2',
globe: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z',
'shield-alert':'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 .5-.87l7-4a1 1 0 0 1 1 0l7 4A1 1 0 0 1 20 6zM12 8v4M12 16h.01',
timer: 'M10 2h4M12 14l3-3M12 22a8 8 0 1 0 0-16 8 8 0 0 0 0 16z',
'refresh-cw': 'M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16',
'flask-conical':'M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2M8.5 2h7M7 16h10',
target: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0M12 12m-6 0a6 6 0 1 0 12 0 6 6 0 1 0-12 0M12 12m-2 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0',
code: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
'clipboard-list':'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2M9 2h6a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM12 11h4M12 16h4M8 11h.01M8 16h.01',
layers: 'M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.84zM2 12l8.58 3.91a2 2 0 0 0 1.66 0L22 12M2 17l8.58 3.91a2 2 0 0 0 1.66 0L22 17',
'bar-chart-3': 'M12 20V10M18 20V4M6 20v-4',
'heart-pulse': 'M19.5 12.572l-7.5 7.428-7.5-7.428A5 5 0 0 1 7.5 5c1.8 0 3.3.9 4.5 2.7C13.2 5.9 14.7 5 16.5 5a5 5 0 0 1 3 9.572zM12 6l-1 4h4l-1 4',
brain: 'M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2zM14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2z',
'check-circle':'M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4 12 14.01l-3-3',
zap: 'M13 2 3 14h9l-1 8 10-12h-9l1-8z',
package: 'M16.5 9.4l-9-5.19M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12',
microscope: 'M6 18h8M3 22h18M14 22a7 7 0 1 0 0-14h-1M9 14h2M9 12a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2z',
sparkle: 'M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z',
scale: 'M16 3h5v5M8 3H3v5M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3M21 3l-7.828 7.828A4 4 0 0 0 12 13.7V22',
'file-text': 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7zM14 2v4a2 2 0 0 0 2 2h4M10 13h4M10 17h4M10 9h1',
filter: 'M3 6h18M7 12h10M10 18h4',
};
// Category fallback icons (used when a workflow has no branding)
const CATEGORY_ICONS = {
retrieval: 'search',
analysis: 'bar-chart-3',
'domain-specific': 'target',
ingestion: 'database',
utility: 'zap',
integration: 'package',
};
// Default branding for the 20 official workflows
const DEFAULT_BRANDING = {
'model-shootout': { icon: 'trophy', color: '#0D9488' },
'asymmetric-search': { icon: 'split', color: '#00D4AA' },
'cost-optimizer': { icon: 'dollar-sign', color: '#F59E0B' },
'question-decomposition': { icon: 'sparkle', color: '#8B5CF6' },
'contract-clause-finder': { icon: 'file-search', color: '#1E40AF' },
'knowledge-base-bootstrap': { icon: 'database', color: '#059669' },
'embedding-drift-detector': { icon: 'activity', color: '#DC2626' },
'multilingual-search': { icon: 'globe', color: '#0EA5E9' },
'financial-risk-scanner': { icon: 'shield-alert', color: '#B45309' },
'doc-freshness': { icon: 'timer', color: '#4338CA' },
'incremental-sync': { icon: 'refresh-cw', color: '#15803D' },
'rag-ab-test': { icon: 'flask-conical', color: '#BE185D' },
'hybrid-precision-search': { icon: 'target', color: '#0891B2' },
'code-migration-helper': { icon: 'code', color: '#475569' },
'meeting-action-items': { icon: 'clipboard-list',color: '#7C2D12' },
'collection-overlap-audit': { icon: 'layers', color: '#6D28D9' },
'query-quality-scorer': { icon: 'microscope', color: '#9333EA' },
'clinical-protocol-match': { icon: 'heart-pulse', color: '#0F766E' },
'batch-quality-gate': { icon: 'check-circle', color: '#166534' },
'index-health-check': { icon: 'bar-chart-3', color: '#1D4ED8' },
};
// Static gradient/featured config
const GRADIENTS = {
'model-shootout': 'linear-gradient(135deg, #0D9488, #06B6D4)',
'asymmetric-search': 'linear-gradient(135deg, #00D4AA, #40E0FF)',
'cost-optimizer': 'linear-gradient(135deg, #F59E0B, #EF4444)',
'question-decomposition': 'linear-gradient(135deg, #8B5CF6, #EC4899)',
'contract-clause-finder': 'linear-gradient(135deg, #1E40AF, #7C3AED)',
'knowledge-base-bootstrap': 'linear-gradient(135deg, #059669, #10B981)',
'embedding-drift-detector': 'linear-gradient(135deg, #DC2626, #F97316)',
'multilingual-search': 'linear-gradient(135deg, #0EA5E9, #6366F1)',
'financial-risk-scanner': 'linear-gradient(135deg, #B45309, #D97706)',
'doc-freshness': 'linear-gradient(135deg, #4338CA, #7C3AED)',
'incremental-sync': 'linear-gradient(135deg, #15803D, #4ADE80)',
'rag-ab-test': 'linear-gradient(135deg, #BE185D, #F472B6)',
'hybrid-precision-search': 'linear-gradient(135deg, #0891B2, #22D3EE)',
'code-migration-helper': 'linear-gradient(135deg, #475569, #94A3B8)',
'meeting-action-items': 'linear-gradient(135deg, #7C2D12, #EA580C)',
'collection-overlap-audit': 'linear-gradient(135deg, #6D28D9, #A78BFA)',
'query-quality-scorer': 'linear-gradient(135deg, #9333EA, #C084FC)',
'clinical-protocol-match': 'linear-gradient(135deg, #0F766E, #2DD4BF)',
'batch-quality-gate': 'linear-gradient(135deg, #166534, #86EFAC)',
'index-health-check': 'linear-gradient(135deg, #1D4ED8, #60A5FA)',
};
const FEATURED = ['model-shootout', 'asymmetric-search', 'cost-optimizer'];
const DEFAULT_GRADIENT = 'linear-gradient(135deg, #334155, #64748B)';
const workflows = npmWorkflows.map(r => {
const shortName = (r.name || '').replace(/^@vaicli\/vai-workflow-/, '').replace(/^vai-workflow-/, '');
const meta = metadataCache[r.name]; // raw registry JSON
const vai = (meta && meta['vai-workflow']) || (meta && meta.vai) || r.vai || {};
const vaiAuthor = vai.author || null;
const version = (meta && meta.version) || r.version || '1.0.0';
// Author attribution: vai.author > package.json author
let author = { name: 'unknown' };
if (vaiAuthor && vaiAuthor.name) {
author = { name: vaiAuthor.name, url: vaiAuthor.url || undefined };
if (vaiAuthor.avatar) {
author.avatar = `https://unpkg.com/${r.name}@${version}/${vaiAuthor.avatar}`;
}
} else if (meta && meta.author) {
const rawAuthor = meta.author;
author = { name: typeof rawAuthor === 'string' ? rawAuthor : (rawAuthor.name || 'unknown') };
} else if (r.author) {
author = { name: r.author };
}
// Assets: construct CDN URLs from vai.assets paths
const vaiAssets = vai.assets || {};
const assets = {};
if (vaiAssets.icon) assets.icon = `https://unpkg.com/${r.name}@${version}/${vaiAssets.icon}`;
if (vaiAssets.banner) assets.banner = `https://unpkg.com/${r.name}@${version}/${vaiAssets.banner}`;
if (vaiAssets.screenshots && Array.isArray(vaiAssets.screenshots)) {
assets.screenshots = vaiAssets.screenshots.map(s => `https://unpkg.com/${r.name}@${version}/${s}`);
}
// Branding: vai.branding from package > DEFAULT_BRANDING > category fallback
const vaiBranding = vai.branding || {};
const defaultBrand = DEFAULT_BRANDING[shortName] || {};
const category = vai.category || 'utility';
const brandingIcon = vaiBranding.icon || defaultBrand.icon || CATEGORY_ICONS[category] || 'zap';
const brandingColor = vaiBranding.color || defaultBrand.color || '#64748B';
const branding = {
icon: brandingIcon,
color: brandingColor,
// Resolve the icon name to its SVG path data for client rendering
iconPath: STORE_ICONS[brandingIcon] || STORE_ICONS.zap,
};
// Inputs: extract from vai-workflow field (has inputs map), fall back to vai.inputs
const vaiWorkflowField = meta && meta['vai-workflow'] ? meta['vai-workflow'] : {};
const rawInputs = vaiWorkflowField.inputs || vai.inputs || {};