voyageai-cli 1.30.3 → 1.30.6
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 +20 -0
- package/package.json +3 -2
- package/src/commands/app.js +2 -2
- package/src/commands/bug.js +2 -1
- package/src/commands/chat.js +1 -2
- package/src/commands/eval.js +2 -2
- package/src/commands/playground.js +85 -4
- package/src/commands/workflow.js +30 -0
- package/src/lib/banner.js +13 -2
- package/src/lib/codegen.js +0 -1
- package/src/lib/config.js +3 -1
- package/src/lib/cost-display.js +1 -1
- package/src/lib/export/formats/json-export.js +2 -2
- package/src/lib/integration-test-runner.js +459 -0
- package/src/lib/workflow-registry.js +2 -1
- package/src/lib/workflow.js +17 -15
- package/src/mcp/server.js +2 -1
- package/src/mcp/tools/management.js +2 -1
- package/src/playground/index.html +139 -0
package/README.md
CHANGED
|
@@ -512,6 +512,26 @@ echo "your-key" | vai config set api-key --stdin
|
|
|
512
512
|
vai config set mongodb-uri "mongodb+srv://..."
|
|
513
513
|
```
|
|
514
514
|
|
|
515
|
+
#### All Config Keys
|
|
516
|
+
|
|
517
|
+
| CLI Key | Description | Example |
|
|
518
|
+
|---------|-------------|---------|
|
|
519
|
+
| `api-key` | Voyage AI API key | `vai config set api-key pa-...` |
|
|
520
|
+
| `mongodb-uri` | MongoDB Atlas connection string | `vai config set mongodb-uri "mongodb+srv://..."` |
|
|
521
|
+
| `base-url` | Override API endpoint (Atlas AI or Voyage) | `vai config set base-url https://ai.mongodb.com/v1` |
|
|
522
|
+
| `default-model` | Default embedding model | `vai config set default-model voyage-3` |
|
|
523
|
+
| `default-dimensions` | Default output dimensions | `vai config set default-dimensions 512` |
|
|
524
|
+
| `default-db` | Default MongoDB database for workflows/commands | `vai config set default-db my_knowledge_base` |
|
|
525
|
+
| `default-collection` | Default MongoDB collection for workflows/commands | `vai config set default-collection documents` |
|
|
526
|
+
| `llm-provider` | LLM provider for chat/generate (`anthropic`, `openai`, `ollama`) | `vai config set llm-provider anthropic` |
|
|
527
|
+
| `llm-api-key` | LLM provider API key | `vai config set llm-api-key sk-...` |
|
|
528
|
+
| `llm-model` | LLM model override | `vai config set llm-model claude-sonnet-4-5-20250929` |
|
|
529
|
+
| `llm-base-url` | LLM endpoint override (e.g. for Ollama) | `vai config set llm-base-url http://localhost:11434` |
|
|
530
|
+
| `show-cost` | Show cost estimates after operations | `vai config set show-cost true` |
|
|
531
|
+
| `telemetry` | Enable/disable anonymous usage telemetry | `vai config set telemetry false` |
|
|
532
|
+
|
|
533
|
+
Config is stored in `~/.vai/config.json`. Use `vai config get` to see all values (secrets are masked) or `vai config get <key>` for a specific value. The desktop app's Settings → Database page also reads and writes this file.
|
|
534
|
+
|
|
515
535
|
### Shell Completions
|
|
516
536
|
|
|
517
537
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voyageai-cli",
|
|
3
|
-
"version": "1.30.
|
|
3
|
+
"version": "1.30.6",
|
|
4
4
|
"description": "CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vai": "./src/cli.js"
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"url": "https://github.com/mrlynn/voyageai-cli/issues"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node --test test/**/*.test.js"
|
|
37
|
+
"test": "node --test test/**/*.test.js",
|
|
38
|
+
"release": "./scripts/release.sh"
|
|
38
39
|
},
|
|
39
40
|
"engines": {
|
|
40
41
|
"node": ">=20.0.0"
|
package/src/commands/app.js
CHANGED
|
@@ -57,8 +57,8 @@ function registerApp(program) {
|
|
|
57
57
|
.action(async (opts) => {
|
|
58
58
|
// --version: print version and exit
|
|
59
59
|
if (opts.version) {
|
|
60
|
-
const
|
|
61
|
-
console.log(`voyageai-cli v${
|
|
60
|
+
const { getVersion } = require('../lib/banner');
|
|
61
|
+
console.log(`voyageai-cli v${getVersion()}`);
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
64
|
|
package/src/commands/bug.js
CHANGED
|
@@ -8,7 +8,8 @@ const { send: sendTelemetry } = require('../lib/telemetry');
|
|
|
8
8
|
// Try to get package version safely
|
|
9
9
|
function getVersion() {
|
|
10
10
|
try {
|
|
11
|
-
|
|
11
|
+
const { getVersion: _getVersion } = require('../lib/banner');
|
|
12
|
+
return _getVersion();
|
|
12
13
|
} catch {
|
|
13
14
|
return 'unknown';
|
|
14
15
|
}
|
package/src/commands/chat.js
CHANGED
package/src/commands/eval.js
CHANGED
|
@@ -18,7 +18,7 @@ function saveResults(filePath, results) {
|
|
|
18
18
|
const output = {
|
|
19
19
|
...results,
|
|
20
20
|
savedAt: new Date().toISOString(),
|
|
21
|
-
vaiVersion: require('
|
|
21
|
+
vaiVersion: require('../lib/banner').getVersion(),
|
|
22
22
|
};
|
|
23
23
|
fs.writeFileSync(filePath, JSON.stringify(output, null, 2), 'utf8');
|
|
24
24
|
}
|
|
@@ -945,7 +945,7 @@ function registerEvalCompare(evalCmd) {
|
|
|
945
945
|
kValues,
|
|
946
946
|
configs: results,
|
|
947
947
|
comparedAt: new Date().toISOString(),
|
|
948
|
-
vaiVersion: require('
|
|
948
|
+
vaiVersion: require('../lib/banner').getVersion(),
|
|
949
949
|
};
|
|
950
950
|
fs.writeFileSync(opts.save, JSON.stringify(output, null, 2), 'utf8');
|
|
951
951
|
if (verbose) {
|
|
@@ -465,9 +465,9 @@ function createPlaygroundServer() {
|
|
|
465
465
|
|
|
466
466
|
// API: Version — return CLI package version
|
|
467
467
|
if (req.method === 'GET' && req.url === '/api/version') {
|
|
468
|
-
const
|
|
468
|
+
const { getVersion } = require('../lib/banner');
|
|
469
469
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
470
|
-
res.end(JSON.stringify({ version:
|
|
470
|
+
res.end(JSON.stringify({ version: getVersion() }));
|
|
471
471
|
return;
|
|
472
472
|
}
|
|
473
473
|
|
|
@@ -541,6 +541,87 @@ function createPlaygroundServer() {
|
|
|
541
541
|
return;
|
|
542
542
|
}
|
|
543
543
|
|
|
544
|
+
// API: Settings — read/write ~/.vai/config.json
|
|
545
|
+
if (req.method === 'GET' && req.url === '/api/settings') {
|
|
546
|
+
const { loadConfig, KEY_MAP, SECRET_KEYS, maskSecret } = require('../lib/config');
|
|
547
|
+
const config = loadConfig();
|
|
548
|
+
|
|
549
|
+
// Build response: CLI key name → masked/raw value, for every known key
|
|
550
|
+
const reverseMap = {};
|
|
551
|
+
for (const [cliKey, internalKey] of Object.entries(KEY_MAP)) {
|
|
552
|
+
reverseMap[internalKey] = cliKey;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const settings = {};
|
|
556
|
+
for (const [internalKey, cliKey] of Object.entries(reverseMap)) {
|
|
557
|
+
const value = config[internalKey];
|
|
558
|
+
settings[cliKey] = {
|
|
559
|
+
value: value != null ? (SECRET_KEYS.has(internalKey) ? maskSecret(value) : value) : null,
|
|
560
|
+
isSet: value != null,
|
|
561
|
+
isSecret: SECRET_KEYS.has(internalKey),
|
|
562
|
+
internalKey,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
567
|
+
res.end(JSON.stringify(settings));
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (req.method === 'PUT' && req.url === '/api/settings') {
|
|
572
|
+
const { loadConfig, saveConfig, KEY_MAP, SECRET_KEYS } = require('../lib/config');
|
|
573
|
+
const body = await readBody(req);
|
|
574
|
+
let updates;
|
|
575
|
+
try {
|
|
576
|
+
updates = JSON.parse(body);
|
|
577
|
+
} catch {
|
|
578
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
579
|
+
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const config = loadConfig();
|
|
584
|
+
const applied = [];
|
|
585
|
+
|
|
586
|
+
for (const [cliKey, value] of Object.entries(updates)) {
|
|
587
|
+
const internalKey = KEY_MAP[cliKey];
|
|
588
|
+
if (!internalKey) {
|
|
589
|
+
continue; // Skip unknown keys
|
|
590
|
+
}
|
|
591
|
+
// Don't overwrite secrets with masked values
|
|
592
|
+
if (SECRET_KEYS.has(internalKey) && typeof value === 'string' && value.includes('...')) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (value === null || value === '') {
|
|
596
|
+
delete config[internalKey];
|
|
597
|
+
} else {
|
|
598
|
+
config[internalKey] = value;
|
|
599
|
+
}
|
|
600
|
+
applied.push(cliKey);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
saveConfig(config);
|
|
604
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
605
|
+
res.end(JSON.stringify({ applied, message: `Updated ${applied.length} setting(s)` }));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// API: Settings reveal — return unmasked value for a specific secret key
|
|
610
|
+
if (req.method === 'GET' && req.url.startsWith('/api/settings/reveal/')) {
|
|
611
|
+
const { loadConfig, KEY_MAP, SECRET_KEYS } = require('../lib/config');
|
|
612
|
+
const cliKey = req.url.replace('/api/settings/reveal/', '');
|
|
613
|
+
const internalKey = KEY_MAP[cliKey];
|
|
614
|
+
if (!internalKey || !SECRET_KEYS.has(internalKey)) {
|
|
615
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
616
|
+
res.end(JSON.stringify({ error: 'Not found or not a secret key' }));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const config = loadConfig();
|
|
620
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
621
|
+
res.end(JSON.stringify({ value: config[internalKey] || null }));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
544
625
|
// API: Settings origins — where each config value comes from
|
|
545
626
|
if (req.method === 'GET' && req.url === '/api/settings/origins') {
|
|
546
627
|
const { resolveLLMConfig } = require('../lib/llm');
|
|
@@ -561,8 +642,8 @@ function createPlaygroundServer() {
|
|
|
561
642
|
provider: resolveOrigin('VAI_LLM_PROVIDER', 'llmProvider', chatConf.provider),
|
|
562
643
|
model: resolveOrigin('VAI_LLM_MODEL', 'llmModel', chatConf.model),
|
|
563
644
|
llmApiKey: resolveOrigin('VAI_LLM_API_KEY', 'llmApiKey'),
|
|
564
|
-
db:
|
|
565
|
-
collection:
|
|
645
|
+
db: resolveOrigin(null, 'defaultDb', proj.db),
|
|
646
|
+
collection: resolveOrigin(null, 'defaultCollection', proj.collection),
|
|
566
647
|
};
|
|
567
648
|
|
|
568
649
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
package/src/commands/workflow.js
CHANGED
|
@@ -529,6 +529,36 @@ function registerWorkflow(program) {
|
|
|
529
529
|
}
|
|
530
530
|
});
|
|
531
531
|
|
|
532
|
+
// ── workflow integration-test ──
|
|
533
|
+
wfCmd
|
|
534
|
+
.command('integration-test')
|
|
535
|
+
.description('Run live integration tests against use-case domain datasets')
|
|
536
|
+
.option('--domain <slug>', 'Test only a specific domain (devdocs, healthcare, finance, legal)')
|
|
537
|
+
.option('--workflow <name>', 'Test only a specific workflow')
|
|
538
|
+
.option('--no-seed', 'Skip seeding (assumes data already exists)')
|
|
539
|
+
.option('--teardown', 'Drop test collections after running')
|
|
540
|
+
.option('--sample-docs <path>', 'Override base path for sample documents')
|
|
541
|
+
.option('--json', 'Output machine-readable JSON', false)
|
|
542
|
+
.action(async (opts) => {
|
|
543
|
+
// Delegate to the integration test runner
|
|
544
|
+
const { execFileSync } = require('child_process');
|
|
545
|
+
const runnerPath = path.join(__dirname, '../../test/integration/run.js');
|
|
546
|
+
const args = [];
|
|
547
|
+
if (opts.domain) args.push('--domain', opts.domain);
|
|
548
|
+
if (opts.workflow) args.push('--workflow', opts.workflow);
|
|
549
|
+
if (opts.seed === false) args.push('--no-seed');
|
|
550
|
+
if (opts.teardown) args.push('--teardown');
|
|
551
|
+
if (opts.sampleDocs) args.push('--sample-docs', opts.sampleDocs);
|
|
552
|
+
if (opts.json) args.push('--json');
|
|
553
|
+
|
|
554
|
+
try {
|
|
555
|
+
execFileSync(process.execPath, [runnerPath, ...args], { stdio: 'inherit' });
|
|
556
|
+
} catch (err) {
|
|
557
|
+
if (err.status) process.exit(err.status);
|
|
558
|
+
throw err;
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
532
562
|
// ── workflow validate <file> ──
|
|
533
563
|
wfCmd
|
|
534
564
|
.command('validate <file>')
|
package/src/lib/banner.js
CHANGED
|
@@ -7,8 +7,19 @@ const pc = require('picocolors');
|
|
|
7
7
|
* @returns {string}
|
|
8
8
|
*/
|
|
9
9
|
function getVersion() {
|
|
10
|
-
const
|
|
11
|
-
|
|
10
|
+
const path = require('path');
|
|
11
|
+
// Walk up from this file to find package.json — works both in dev and packaged Electron
|
|
12
|
+
let dir = __dirname;
|
|
13
|
+
for (let i = 0; i < 5; i++) {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = require(path.join(dir, 'package.json'));
|
|
16
|
+
if (pkg.version) return pkg.version;
|
|
17
|
+
} catch (_) { /* keep looking */ }
|
|
18
|
+
const parent = path.dirname(dir);
|
|
19
|
+
if (parent === dir) break;
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
return '0.0.0';
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
/**
|
package/src/lib/codegen.js
CHANGED
package/src/lib/config.js
CHANGED
|
@@ -18,7 +18,9 @@ const KEY_MAP = {
|
|
|
18
18
|
'llm-api-key': 'llmApiKey',
|
|
19
19
|
'llm-model': 'llmModel',
|
|
20
20
|
'llm-base-url': 'llmBaseUrl',
|
|
21
|
-
'
|
|
21
|
+
'default-db': 'defaultDb',
|
|
22
|
+
'default-collection': 'defaultCollection',
|
|
23
|
+
'show-cost': 'showCost',
|
|
22
24
|
'telemetry': 'telemetry',
|
|
23
25
|
};
|
|
24
26
|
|
package/src/lib/cost-display.js
CHANGED
|
@@ -89,7 +89,7 @@ function showCombinedCostSummary(operations, opts = {}) {
|
|
|
89
89
|
* @returns {boolean}
|
|
90
90
|
*/
|
|
91
91
|
function isEnabled() {
|
|
92
|
-
const val = getConfigValue('show-cost');
|
|
92
|
+
const val = getConfigValue('showCost') || getConfigValue('show-cost');
|
|
93
93
|
return val === true || val === 'true';
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const pkg =
|
|
3
|
+
const { getVersion } = require('../../../lib/banner');
|
|
4
|
+
const pkg = { version: getVersion() };
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Render normalized data as JSON.
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { executeWorkflow } = require('./workflow');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Domain-to-workflow mapping. Each entry defines which workflows are compatible
|
|
9
|
+
* with a given use-case domain, and how to map domain data into workflow inputs.
|
|
10
|
+
*/
|
|
11
|
+
const WORKFLOW_DOMAIN_MAP = {
|
|
12
|
+
'rag-chat': {
|
|
13
|
+
inputMapper: (domain, query) => ({
|
|
14
|
+
question: query.query,
|
|
15
|
+
collection: domain.collectionName,
|
|
16
|
+
collection2: '',
|
|
17
|
+
limit: 10,
|
|
18
|
+
min_score: 0.3,
|
|
19
|
+
system_prompt: 'You are a helpful assistant. Answer based on provided context. Cite sources.',
|
|
20
|
+
chat_history: '',
|
|
21
|
+
}),
|
|
22
|
+
assertions: ['noErrors', 'stepsCompleted', 'nonEmptyOutput'],
|
|
23
|
+
},
|
|
24
|
+
'search-with-fallback': {
|
|
25
|
+
inputMapper: (domain, query) => ({
|
|
26
|
+
query: query.query,
|
|
27
|
+
primary_collection: domain.collectionName,
|
|
28
|
+
fallback_collection: domain.collectionName, // same collection for testing
|
|
29
|
+
}),
|
|
30
|
+
assertions: ['noErrors', 'stepsCompleted'],
|
|
31
|
+
},
|
|
32
|
+
'research-and-summarize': {
|
|
33
|
+
inputMapper: (domain, query) => ({
|
|
34
|
+
question: query.query,
|
|
35
|
+
limit: 5,
|
|
36
|
+
}),
|
|
37
|
+
assertions: ['noErrors', 'stepsCompleted'],
|
|
38
|
+
},
|
|
39
|
+
'multi-collection-search': {
|
|
40
|
+
inputMapper: (domain, query) => ({
|
|
41
|
+
query: query.query,
|
|
42
|
+
collection1: domain.collectionName,
|
|
43
|
+
collection2: domain.collectionName,
|
|
44
|
+
limit: 5,
|
|
45
|
+
}),
|
|
46
|
+
assertions: ['noErrors', 'stepsCompleted'],
|
|
47
|
+
},
|
|
48
|
+
'consistency-check': {
|
|
49
|
+
inputMapper: (domain, _query) => ({
|
|
50
|
+
topic: domain.title,
|
|
51
|
+
collection1: domain.collectionName,
|
|
52
|
+
collection2: domain.collectionName,
|
|
53
|
+
}),
|
|
54
|
+
assertions: ['noErrors'],
|
|
55
|
+
},
|
|
56
|
+
'cost-analysis': {
|
|
57
|
+
inputMapper: (domain, _query) => ({
|
|
58
|
+
docs: 100,
|
|
59
|
+
queries: 500,
|
|
60
|
+
months: 1,
|
|
61
|
+
}),
|
|
62
|
+
assertions: ['noErrors', 'stepsCompleted'],
|
|
63
|
+
},
|
|
64
|
+
'smart-ingest': {
|
|
65
|
+
inputMapper: (domain, _query, sampleDocPath) => {
|
|
66
|
+
// Use first sample doc content if available
|
|
67
|
+
let text = 'This is a test document for integration testing.';
|
|
68
|
+
if (sampleDocPath) {
|
|
69
|
+
const docs = fs.readdirSync(sampleDocPath).filter(f => f.endsWith('.md'));
|
|
70
|
+
if (docs.length > 0) {
|
|
71
|
+
text = fs.readFileSync(path.join(sampleDocPath, docs[0]), 'utf8').slice(0, 2000);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
text,
|
|
76
|
+
source: 'integration-test',
|
|
77
|
+
threshold: 0.95,
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
assertions: ['noErrors'],
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load a use-case domain dataset.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} domainDataPath - Path to the use-case .ts/.json data file or parsed object
|
|
88
|
+
* @returns {object} Parsed domain data with sampleDocs, exampleQueries, etc.
|
|
89
|
+
*/
|
|
90
|
+
function loadDomainData(domainData) {
|
|
91
|
+
if (typeof domainData === 'object') return domainData;
|
|
92
|
+
const raw = fs.readFileSync(domainData, 'utf8');
|
|
93
|
+
return JSON.parse(raw);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Seed a test collection by ingesting sample documents.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} options
|
|
100
|
+
* @param {string} options.sampleDocsPath - Path to folder of sample .md files
|
|
101
|
+
* @param {string} options.dbName - Target database
|
|
102
|
+
* @param {string} options.collectionName - Target collection
|
|
103
|
+
* @param {string} [options.model] - Voyage model to use
|
|
104
|
+
* @returns {Promise<{ docCount: number, collection: string }>}
|
|
105
|
+
*/
|
|
106
|
+
async function seedCollection({ sampleDocsPath, dbName, collectionName, model }) {
|
|
107
|
+
const { connectAndClose } = require('./mongo');
|
|
108
|
+
|
|
109
|
+
// Check if collection already has documents (skip re-seeding)
|
|
110
|
+
const existingCount = await connectAndClose(dbName, collectionName, async (col) => {
|
|
111
|
+
return col.countDocuments();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (existingCount > 0) {
|
|
115
|
+
return { docCount: existingCount, collection: collectionName, seeded: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Read all .md files from sample docs
|
|
119
|
+
const files = fs.readdirSync(sampleDocsPath).filter(f => f.endsWith('.md'));
|
|
120
|
+
if (files.length === 0) {
|
|
121
|
+
throw new Error(`No .md files found in ${sampleDocsPath}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const documents = files.map(f => ({
|
|
125
|
+
text: fs.readFileSync(path.join(sampleDocsPath, f), 'utf8'),
|
|
126
|
+
source: f,
|
|
127
|
+
metadata: { filename: f },
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
// Chunk and embed
|
|
131
|
+
const { chunk } = require('./chunker');
|
|
132
|
+
const { generateEmbeddings } = require('./api');
|
|
133
|
+
|
|
134
|
+
const allChunks = [];
|
|
135
|
+
for (const doc of documents) {
|
|
136
|
+
const chunks = chunk(doc.text, { strategy: 'recursive', chunkSize: 512, chunkOverlap: 50 });
|
|
137
|
+
for (const c of chunks) {
|
|
138
|
+
allChunks.push({
|
|
139
|
+
text: c.text || c,
|
|
140
|
+
source: doc.source,
|
|
141
|
+
metadata: doc.metadata,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Embed in batches
|
|
147
|
+
const batchSize = 128;
|
|
148
|
+
const allEmbeddings = [];
|
|
149
|
+
for (let i = 0; i < allChunks.length; i += batchSize) {
|
|
150
|
+
const batch = allChunks.slice(i, i + batchSize);
|
|
151
|
+
const texts = batch.map(c => c.text);
|
|
152
|
+
const resp = await generateEmbeddings(texts, {
|
|
153
|
+
model: model || 'voyage-3-lite',
|
|
154
|
+
inputType: 'document',
|
|
155
|
+
});
|
|
156
|
+
allEmbeddings.push(...resp.data.map(d => d.embedding));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Insert into MongoDB
|
|
160
|
+
const docsToInsert = allChunks.map((c, i) => ({
|
|
161
|
+
text: c.text,
|
|
162
|
+
source: c.source,
|
|
163
|
+
metadata: c.metadata,
|
|
164
|
+
embedding: allEmbeddings[i],
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
await connectAndClose(dbName, collectionName, async (col) => {
|
|
168
|
+
await col.insertMany(docsToInsert);
|
|
169
|
+
|
|
170
|
+
// Create vector search index if it doesn't exist
|
|
171
|
+
try {
|
|
172
|
+
const indexes = await col.listSearchIndexes().toArray();
|
|
173
|
+
const hasIndex = indexes.some(idx => idx.name === 'vector_index');
|
|
174
|
+
if (!hasIndex) {
|
|
175
|
+
await col.createSearchIndex({
|
|
176
|
+
name: 'vector_index',
|
|
177
|
+
type: 'vectorSearch',
|
|
178
|
+
definition: {
|
|
179
|
+
fields: [{
|
|
180
|
+
type: 'vector',
|
|
181
|
+
path: 'embedding',
|
|
182
|
+
numDimensions: allEmbeddings[0].length,
|
|
183
|
+
similarity: 'cosine',
|
|
184
|
+
}],
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// May not be available on non-Atlas deployments
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return { docCount: docsToInsert.length, collection: collectionName, seeded: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Check if a vector search index exists and is ready.
|
|
198
|
+
* Optionally waits for it to become ready.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} dbName
|
|
201
|
+
* @param {string} collectionName
|
|
202
|
+
* @param {object} [options]
|
|
203
|
+
* @param {string} [options.indexName='vector_index']
|
|
204
|
+
* @param {boolean} [options.wait=false] - Wait for index to become ready
|
|
205
|
+
* @param {number} [options.timeoutMs=120000] - Max wait time
|
|
206
|
+
* @param {function} [options.onProgress] - Progress callback
|
|
207
|
+
* @returns {Promise<boolean>}
|
|
208
|
+
*/
|
|
209
|
+
async function checkVectorIndex(dbName, collectionName, options = {}) {
|
|
210
|
+
const { indexName = 'vector_index', wait = false, timeoutMs = 120000, onProgress = () => {} } = options;
|
|
211
|
+
const { getMongoCollection } = require('./mongo');
|
|
212
|
+
|
|
213
|
+
const deadline = Date.now() + timeoutMs;
|
|
214
|
+
|
|
215
|
+
while (true) {
|
|
216
|
+
const { client, collection } = await getMongoCollection(dbName, collectionName);
|
|
217
|
+
try {
|
|
218
|
+
const indexes = await collection.listSearchIndexes().toArray();
|
|
219
|
+
const idx = indexes.find(i => i.name === indexName);
|
|
220
|
+
if (idx && idx.status === 'READY') return true;
|
|
221
|
+
if (!wait || Date.now() >= deadline) return false;
|
|
222
|
+
const status = idx ? idx.status : 'NOT_FOUND';
|
|
223
|
+
onProgress({ phase: 'index', message: `Index status: ${status}, waiting...` });
|
|
224
|
+
} catch {
|
|
225
|
+
if (!wait || Date.now() >= deadline) return false;
|
|
226
|
+
} finally {
|
|
227
|
+
await client.close();
|
|
228
|
+
}
|
|
229
|
+
// Wait 5 seconds before checking again
|
|
230
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Run integration tests for a domain against compatible workflows.
|
|
236
|
+
*
|
|
237
|
+
* @param {object} options
|
|
238
|
+
* @param {object} options.domain - Domain data (from use-case data files)
|
|
239
|
+
* @param {string} options.sampleDocsPath - Path to sample doc files
|
|
240
|
+
* @param {string} options.workflowsDir - Path to workflow JSON definitions
|
|
241
|
+
* @param {string[]} [options.workflows] - Specific workflow names to test (default: all compatible)
|
|
242
|
+
* @param {boolean} [options.seed] - Whether to seed data first (default: true)
|
|
243
|
+
* @param {boolean} [options.teardown] - Whether to drop test collections after (default: false)
|
|
244
|
+
* @param {function} [options.onProgress] - Progress callback
|
|
245
|
+
* @returns {Promise<IntegrationTestResults>}
|
|
246
|
+
*/
|
|
247
|
+
async function runIntegrationTests(options) {
|
|
248
|
+
const {
|
|
249
|
+
domain,
|
|
250
|
+
sampleDocsPath,
|
|
251
|
+
workflowsDir,
|
|
252
|
+
workflows: requestedWorkflows,
|
|
253
|
+
seed = true,
|
|
254
|
+
teardown = false,
|
|
255
|
+
onProgress = () => {},
|
|
256
|
+
} = options;
|
|
257
|
+
|
|
258
|
+
const testCollectionName = `vai_test_${domain.slug || domain.collectionName}`;
|
|
259
|
+
const testDomain = { ...domain, collectionName: testCollectionName };
|
|
260
|
+
const results = {
|
|
261
|
+
domain: domain.slug || domain.title,
|
|
262
|
+
collection: testCollectionName,
|
|
263
|
+
seed: null,
|
|
264
|
+
indexReady: false,
|
|
265
|
+
workflows: [],
|
|
266
|
+
summary: { total: 0, passed: 0, failed: 0, skipped: 0 },
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Step 1: Seed
|
|
270
|
+
if (seed && sampleDocsPath) {
|
|
271
|
+
onProgress({ phase: 'seed', message: `Seeding ${testCollectionName}...` });
|
|
272
|
+
try {
|
|
273
|
+
results.seed = await seedCollection({
|
|
274
|
+
sampleDocsPath,
|
|
275
|
+
dbName: domain.dbName || 'vai_integration_test',
|
|
276
|
+
collectionName: testCollectionName,
|
|
277
|
+
model: domain.voyageModel,
|
|
278
|
+
});
|
|
279
|
+
onProgress({ phase: 'seed', message: `Seeded ${results.seed.docCount} chunks` });
|
|
280
|
+
} catch (err) {
|
|
281
|
+
results.seed = { error: err.message };
|
|
282
|
+
onProgress({ phase: 'seed', message: `Seed failed: ${err.message}` });
|
|
283
|
+
// Can't continue without data for most workflows
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Step 2: Check vector index (wait up to 2 minutes for it to become ready)
|
|
288
|
+
onProgress({ phase: 'index', message: 'Checking vector search index...' });
|
|
289
|
+
results.indexReady = await checkVectorIndex(
|
|
290
|
+
domain.dbName || 'vai_integration_test',
|
|
291
|
+
testCollectionName,
|
|
292
|
+
{ wait: true, timeoutMs: 120000, onProgress }
|
|
293
|
+
);
|
|
294
|
+
if (!results.indexReady) {
|
|
295
|
+
onProgress({ phase: 'index', message: 'WARNING: Vector index not ready — query-based workflows may fail' });
|
|
296
|
+
} else {
|
|
297
|
+
onProgress({ phase: 'index', message: 'Vector index ready' });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Step 3: Run workflows
|
|
301
|
+
const availableWorkflows = fs.readdirSync(workflowsDir)
|
|
302
|
+
.filter(f => f.endsWith('.json'))
|
|
303
|
+
.map(f => f.replace('.json', ''));
|
|
304
|
+
|
|
305
|
+
const workflowsToTest = requestedWorkflows
|
|
306
|
+
? requestedWorkflows.filter(w => availableWorkflows.includes(w) && WORKFLOW_DOMAIN_MAP[w])
|
|
307
|
+
: availableWorkflows.filter(w => WORKFLOW_DOMAIN_MAP[w]);
|
|
308
|
+
|
|
309
|
+
const queries = domain.exampleQueries || [];
|
|
310
|
+
if (queries.length === 0) {
|
|
311
|
+
queries.push({ query: domain.title, explanation: 'Fallback query from domain title' });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const wfName of workflowsToTest) {
|
|
315
|
+
const mapping = WORKFLOW_DOMAIN_MAP[wfName];
|
|
316
|
+
if (!mapping) {
|
|
317
|
+
results.workflows.push({ name: wfName, status: 'skipped', reason: 'No domain mapping' });
|
|
318
|
+
results.summary.skipped++;
|
|
319
|
+
results.summary.total++;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const wfPath = path.join(workflowsDir, `${wfName}.json`);
|
|
324
|
+
const definition = JSON.parse(fs.readFileSync(wfPath, 'utf8'));
|
|
325
|
+
|
|
326
|
+
// Test with first example query
|
|
327
|
+
const testQuery = queries[0];
|
|
328
|
+
const inputs = mapping.inputMapper(testDomain, testQuery, sampleDocsPath);
|
|
329
|
+
|
|
330
|
+
onProgress({ phase: 'workflow', message: `Running ${wfName} with query: "${testQuery.query}"` });
|
|
331
|
+
|
|
332
|
+
const wfResult = {
|
|
333
|
+
name: wfName,
|
|
334
|
+
query: testQuery.query,
|
|
335
|
+
inputs,
|
|
336
|
+
status: 'passed',
|
|
337
|
+
steps: [],
|
|
338
|
+
assertions: [],
|
|
339
|
+
errors: [],
|
|
340
|
+
durationMs: 0,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const start = Date.now();
|
|
344
|
+
try {
|
|
345
|
+
// Inject db and embedding model into workflow defaults so query/rerank
|
|
346
|
+
// steps use the same model the documents were embedded with
|
|
347
|
+
const testDefinition = {
|
|
348
|
+
...definition,
|
|
349
|
+
defaults: {
|
|
350
|
+
...(definition.defaults || {}),
|
|
351
|
+
db: domain.dbName || 'vai_integration_test',
|
|
352
|
+
model: domain.voyageModel,
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
const result = await executeWorkflow(testDefinition, {
|
|
356
|
+
inputs,
|
|
357
|
+
db: domain.dbName || 'vai_integration_test',
|
|
358
|
+
});
|
|
359
|
+
wfResult.durationMs = Date.now() - start;
|
|
360
|
+
wfResult.steps = (result.steps || []).map(s => ({
|
|
361
|
+
id: s.id,
|
|
362
|
+
tool: s.tool,
|
|
363
|
+
skipped: s.skipped || false,
|
|
364
|
+
error: s.error || null,
|
|
365
|
+
durationMs: s.durationMs,
|
|
366
|
+
}));
|
|
367
|
+
|
|
368
|
+
// Run assertions
|
|
369
|
+
if (mapping.assertions.includes('noErrors')) {
|
|
370
|
+
const errorSteps = (result.steps || []).filter(s => s.error);
|
|
371
|
+
if (errorSteps.length > 0) {
|
|
372
|
+
wfResult.assertions.push({
|
|
373
|
+
pass: false,
|
|
374
|
+
name: 'noErrors',
|
|
375
|
+
message: `${errorSteps.length} step(s) errored: ${errorSteps.map(s => `${s.id}: ${s.error}`).join('; ')}`,
|
|
376
|
+
});
|
|
377
|
+
wfResult.status = 'failed';
|
|
378
|
+
} else {
|
|
379
|
+
wfResult.assertions.push({ pass: true, name: 'noErrors', message: 'All steps error-free' });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (mapping.assertions.includes('stepsCompleted')) {
|
|
384
|
+
const completedSteps = (result.steps || []).filter(s => !s.skipped && !s.error);
|
|
385
|
+
if (completedSteps.length === 0) {
|
|
386
|
+
wfResult.assertions.push({ pass: false, name: 'stepsCompleted', message: 'No steps completed' });
|
|
387
|
+
wfResult.status = 'failed';
|
|
388
|
+
} else {
|
|
389
|
+
wfResult.assertions.push({ pass: true, name: 'stepsCompleted', message: `${completedSteps.length} steps completed` });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (mapping.assertions.includes('nonEmptyOutput')) {
|
|
394
|
+
const output = result.output || {};
|
|
395
|
+
const hasContent = Object.values(output).some(v =>
|
|
396
|
+
v && (typeof v === 'string' ? v.length > 0 : Array.isArray(v) ? v.length > 0 : true)
|
|
397
|
+
);
|
|
398
|
+
if (!hasContent) {
|
|
399
|
+
wfResult.assertions.push({ pass: false, name: 'nonEmptyOutput', message: 'Output is empty' });
|
|
400
|
+
wfResult.status = 'failed';
|
|
401
|
+
} else {
|
|
402
|
+
wfResult.assertions.push({ pass: true, name: 'nonEmptyOutput', message: 'Output has content' });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Check expected sources if the query has sampleResults
|
|
407
|
+
if (testQuery.sampleResults && testQuery.sampleResults.length > 0 && result.output) {
|
|
408
|
+
const outputStr = JSON.stringify(result.output).toLowerCase();
|
|
409
|
+
const expectedSource = testQuery.sampleResults[0].source.toLowerCase();
|
|
410
|
+
const baseName = expectedSource.replace('.md', '');
|
|
411
|
+
const found = outputStr.includes(expectedSource) || outputStr.includes(baseName);
|
|
412
|
+
wfResult.assertions.push({
|
|
413
|
+
pass: found,
|
|
414
|
+
name: 'expectedSource',
|
|
415
|
+
message: found
|
|
416
|
+
? `Found expected source: ${testQuery.sampleResults[0].source}`
|
|
417
|
+
: `Expected source "${testQuery.sampleResults[0].source}" not found in output (sources: ${
|
|
418
|
+
(result.output.sources || []).map(s => s.source || s.filename || 'unknown').join(', ') || 'none'
|
|
419
|
+
})`,
|
|
420
|
+
});
|
|
421
|
+
// Source matching is a soft signal — don't fail the whole test, just warn
|
|
422
|
+
if (!found) wfResult.assertions[wfResult.assertions.length - 1].warn = true;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
} catch (err) {
|
|
426
|
+
wfResult.durationMs = Date.now() - start;
|
|
427
|
+
wfResult.status = 'failed';
|
|
428
|
+
wfResult.errors.push(err.message);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
results.workflows.push(wfResult);
|
|
432
|
+
results.summary.total++;
|
|
433
|
+
if (wfResult.status === 'passed') results.summary.passed++;
|
|
434
|
+
else if (wfResult.status === 'failed') results.summary.failed++;
|
|
435
|
+
else results.summary.skipped++;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Step 4: Teardown
|
|
439
|
+
if (teardown) {
|
|
440
|
+
onProgress({ phase: 'teardown', message: `Dropping ${testCollectionName}...` });
|
|
441
|
+
try {
|
|
442
|
+
const { connectAndClose } = require('./mongo');
|
|
443
|
+
await connectAndClose(domain.dbName || 'vai_integration_test', testCollectionName, async (col) => {
|
|
444
|
+
await col.drop();
|
|
445
|
+
});
|
|
446
|
+
} catch (err) {
|
|
447
|
+
onProgress({ phase: 'teardown', message: `Teardown failed: ${err.message}` });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return results;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
module.exports = {
|
|
455
|
+
WORKFLOW_DOMAIN_MAP,
|
|
456
|
+
seedCollection,
|
|
457
|
+
checkVectorIndex,
|
|
458
|
+
runIntegrationTests,
|
|
459
|
+
};
|
|
@@ -149,7 +149,8 @@ function validatePackage(packagePath, pkg) {
|
|
|
149
149
|
|
|
150
150
|
if (pkg.vai.minVaiVersion) {
|
|
151
151
|
try {
|
|
152
|
-
const {
|
|
152
|
+
const { getVersion } = require('./banner');
|
|
153
|
+
const currentVersion = getVersion();
|
|
153
154
|
if (compareVersions(pkg.vai.minVaiVersion, currentVersion) > 0) {
|
|
154
155
|
warnings.push(`Requires vai >= ${pkg.vai.minVaiVersion} (you have ${currentVersion})`);
|
|
155
156
|
}
|
package/src/lib/workflow.js
CHANGED
|
@@ -1161,6 +1161,19 @@ async function executeHttp(inputs) {
|
|
|
1161
1161
|
};
|
|
1162
1162
|
}
|
|
1163
1163
|
|
|
1164
|
+
/**
|
|
1165
|
+
* Resolve db and collection from inputs → defaults → project → vai config.
|
|
1166
|
+
*/
|
|
1167
|
+
function resolveDbAndCollection(inputs, defaults) {
|
|
1168
|
+
const { loadProject } = require('./project');
|
|
1169
|
+
const { getConfigValue } = require('./config');
|
|
1170
|
+
const { config: proj } = loadProject();
|
|
1171
|
+
return {
|
|
1172
|
+
db: inputs.db || defaults.db || proj.db || process.env.VAI_DEFAULT_DB || getConfigValue('defaultDb'),
|
|
1173
|
+
collection: inputs.collection || defaults.collection || proj.collection || process.env.VAI_DEFAULT_COLLECTION || getConfigValue('defaultCollection'),
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1164
1177
|
/**
|
|
1165
1178
|
* Execute a MongoDB aggregation pipeline step.
|
|
1166
1179
|
*
|
|
@@ -1170,11 +1183,8 @@ async function executeHttp(inputs) {
|
|
|
1170
1183
|
*/
|
|
1171
1184
|
async function executeAggregate(inputs, defaults) {
|
|
1172
1185
|
const { getMongoCollection } = require('./mongo');
|
|
1173
|
-
const { loadProject } = require('./project');
|
|
1174
|
-
const { config: proj } = loadProject();
|
|
1175
1186
|
|
|
1176
|
-
const db = inputs
|
|
1177
|
-
const collection = inputs.collection || defaults.collection || proj.collection;
|
|
1187
|
+
const { db, collection } = resolveDbAndCollection(inputs, defaults);
|
|
1178
1188
|
const pipeline = inputs.pipeline;
|
|
1179
1189
|
const allowWrites = inputs.allowWrites || false;
|
|
1180
1190
|
|
|
@@ -1217,11 +1227,8 @@ async function executeAggregate(inputs, defaults) {
|
|
|
1217
1227
|
async function executeQuery(inputs, defaults) {
|
|
1218
1228
|
const { generateEmbeddings, apiRequest } = require('./api');
|
|
1219
1229
|
const { getMongoCollection } = require('./mongo');
|
|
1220
|
-
const { loadProject } = require('./project');
|
|
1221
|
-
const { config: proj } = loadProject();
|
|
1222
1230
|
|
|
1223
|
-
const db = inputs
|
|
1224
|
-
const collection = inputs.collection || defaults.collection || proj.collection;
|
|
1231
|
+
const { db, collection } = resolveDbAndCollection(inputs, defaults);
|
|
1225
1232
|
const model = inputs.model || defaults.model;
|
|
1226
1233
|
const query = inputs.query;
|
|
1227
1234
|
const limit = inputs.limit || 10;
|
|
@@ -1381,11 +1388,8 @@ async function executeIngest(inputs, defaults) {
|
|
|
1381
1388
|
const { generateEmbeddings } = require('./api');
|
|
1382
1389
|
const { getMongoCollection } = require('./mongo');
|
|
1383
1390
|
const { chunk } = require('./chunker');
|
|
1384
|
-
const { loadProject } = require('./project');
|
|
1385
|
-
const { config: proj } = loadProject();
|
|
1386
1391
|
|
|
1387
|
-
const db = inputs
|
|
1388
|
-
const collection = inputs.collection || defaults.collection || proj.collection;
|
|
1392
|
+
const { db, collection } = resolveDbAndCollection(inputs, defaults);
|
|
1389
1393
|
const text = inputs.text;
|
|
1390
1394
|
const source = inputs.source || 'workflow-ingest';
|
|
1391
1395
|
const model = inputs.model || defaults.model;
|
|
@@ -1464,10 +1468,8 @@ async function executeIngest(inputs, defaults) {
|
|
|
1464
1468
|
*/
|
|
1465
1469
|
async function executeCollections(inputs, defaults) {
|
|
1466
1470
|
const { introspectCollections } = require('./workflow-utils');
|
|
1467
|
-
const { loadProject } = require('./project');
|
|
1468
|
-
const { config: proj } = loadProject();
|
|
1469
1471
|
|
|
1470
|
-
const db = inputs
|
|
1472
|
+
const { db } = resolveDbAndCollection(inputs, defaults);
|
|
1471
1473
|
if (!db) throw new Error('collections: database not specified');
|
|
1472
1474
|
|
|
1473
1475
|
const collections = await introspectCollections(db);
|
package/src/mcp/server.js
CHANGED
|
@@ -12,7 +12,8 @@ const { registerWorkspaceTools } = require('./tools/workspace');
|
|
|
12
12
|
const { registerCodeSearchTools } = require('./tools/code-search');
|
|
13
13
|
const { registerAuthoringTools } = require('./tools/authoring');
|
|
14
14
|
|
|
15
|
-
const
|
|
15
|
+
const { getVersion } = require('../lib/banner');
|
|
16
|
+
const VERSION = getVersion();
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Create and configure the MCP server with all tools registered.
|
|
@@ -11,7 +11,8 @@ const { introspectCollections } = require('../../lib/workflow-utils');
|
|
|
11
11
|
*/
|
|
12
12
|
async function handleVaiCollections(input) {
|
|
13
13
|
const { config: proj } = loadProject();
|
|
14
|
-
const
|
|
14
|
+
const { getConfigValue } = require('../../lib/config');
|
|
15
|
+
const dbName = input.db || proj.db || process.env.VAI_DEFAULT_DB || getConfigValue('defaultDb');
|
|
15
16
|
if (!dbName) throw new Error('No database specified. Pass db parameter or configure via vai init.');
|
|
16
17
|
|
|
17
18
|
const collections = await introspectCollections(dbName);
|
|
@@ -7915,6 +7915,10 @@ Reranking models rescore initial search results to improve relevance ordering.</
|
|
|
7915
7915
|
<svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-config"/></svg>
|
|
7916
7916
|
<span>General</span>
|
|
7917
7917
|
</button>
|
|
7918
|
+
<button class="settings-nav-item" data-settings-section="database">
|
|
7919
|
+
<svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-database"/></svg>
|
|
7920
|
+
<span>Database</span>
|
|
7921
|
+
</button>
|
|
7918
7922
|
<button class="settings-nav-item" data-settings-section="appearance">
|
|
7919
7923
|
<svg width="16" height="16" viewBox="0 0 24 24"><use href="#lg-palette"/></svg>
|
|
7920
7924
|
<span>Appearance</span>
|
|
@@ -8018,6 +8022,47 @@ Reranking models rescore initial search results to improve relevance ordering.</
|
|
|
8018
8022
|
</div>
|
|
8019
8023
|
</div>
|
|
8020
8024
|
|
|
8025
|
+
<!-- ── Database ── -->
|
|
8026
|
+
<div class="settings-panel" id="settings-database">
|
|
8027
|
+
<div class="settings-panel-header">
|
|
8028
|
+
<h3 class="settings-panel-title">Database</h3>
|
|
8029
|
+
<p class="settings-panel-subtitle">MongoDB Atlas connection and default targets</p>
|
|
8030
|
+
</div>
|
|
8031
|
+
<div class="settings-section">
|
|
8032
|
+
<div class="settings-row">
|
|
8033
|
+
<div class="settings-label">
|
|
8034
|
+
<span class="settings-label-text">MongoDB URI <span class="settings-origin" data-origin-key="mongodbUri"></span></span>
|
|
8035
|
+
<span class="settings-label-hint">Connection string for MongoDB Atlas · <a href="https://cloud.mongodb.com" target="_blank" class="settings-key-link"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:2px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>Atlas Console</a></span>
|
|
8036
|
+
</div>
|
|
8037
|
+
<div class="settings-control" style="min-width:260px;">
|
|
8038
|
+
<div class="settings-api-field">
|
|
8039
|
+
<input type="password" id="settingsMongoUri" placeholder="mongodb+srv://user:pass@cluster.mongodb.net/" autocomplete="off" spellcheck="false">
|
|
8040
|
+
<button type="button" id="settingsMongoUriToggle" title="Show/hide"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg></button>
|
|
8041
|
+
<button type="button" id="settingsMongoUriSave" class="save-btn" title="Save">Save</button>
|
|
8042
|
+
</div>
|
|
8043
|
+
</div>
|
|
8044
|
+
</div>
|
|
8045
|
+
<div class="settings-row">
|
|
8046
|
+
<div class="settings-label">
|
|
8047
|
+
<span class="settings-label-text">Default Database <span class="settings-origin" data-origin-key="db"></span></span>
|
|
8048
|
+
<span class="settings-label-hint">Used by workflows and commands when no database is specified</span>
|
|
8049
|
+
</div>
|
|
8050
|
+
<div class="settings-control">
|
|
8051
|
+
<input type="text" class="settings-input" id="settingsDefaultDb" placeholder="e.g. my_knowledge_base">
|
|
8052
|
+
</div>
|
|
8053
|
+
</div>
|
|
8054
|
+
<div class="settings-row">
|
|
8055
|
+
<div class="settings-label">
|
|
8056
|
+
<span class="settings-label-text">Default Collection <span class="settings-origin" data-origin-key="collection"></span></span>
|
|
8057
|
+
<span class="settings-label-hint">Used by workflows and commands when no collection is specified</span>
|
|
8058
|
+
</div>
|
|
8059
|
+
<div class="settings-control">
|
|
8060
|
+
<input type="text" class="settings-input" id="settingsDefaultCollection" placeholder="e.g. documents">
|
|
8061
|
+
</div>
|
|
8062
|
+
</div>
|
|
8063
|
+
</div>
|
|
8064
|
+
</div>
|
|
8065
|
+
|
|
8021
8066
|
<!-- ── Appearance ── -->
|
|
8022
8067
|
<div class="settings-panel" id="settings-appearance">
|
|
8023
8068
|
<div class="settings-panel-header">
|
|
@@ -11845,6 +11890,100 @@ function initSettings() {
|
|
|
11845
11890
|
});
|
|
11846
11891
|
}
|
|
11847
11892
|
|
|
11893
|
+
// ── Database settings (read/write ~/.vai/config.json via /api/settings) ──
|
|
11894
|
+
const dbSettingsFields = {
|
|
11895
|
+
'settingsMongoUri': 'mongodb-uri',
|
|
11896
|
+
'settingsDefaultDb': 'default-db',
|
|
11897
|
+
'settingsDefaultCollection': 'default-collection',
|
|
11898
|
+
};
|
|
11899
|
+
|
|
11900
|
+
// Load current values from ~/.vai/config.json
|
|
11901
|
+
fetch('/api/settings').then(r => r.json()).then(settings => {
|
|
11902
|
+
for (const [elemId, cliKey] of Object.entries(dbSettingsFields)) {
|
|
11903
|
+
const el = document.getElementById(elemId);
|
|
11904
|
+
if (el && settings[cliKey] && settings[cliKey].isSet) {
|
|
11905
|
+
// Don't populate masked secret values into editable fields
|
|
11906
|
+
if (settings[cliKey].isSecret) {
|
|
11907
|
+
el.value = settings[cliKey].value || '';
|
|
11908
|
+
el.dataset.isMasked = 'true';
|
|
11909
|
+
} else {
|
|
11910
|
+
el.value = settings[cliKey].value || '';
|
|
11911
|
+
}
|
|
11912
|
+
}
|
|
11913
|
+
}
|
|
11914
|
+
}).catch(() => {});
|
|
11915
|
+
|
|
11916
|
+
// Save non-secret fields on change (default-db, default-collection)
|
|
11917
|
+
['settingsDefaultDb', 'settingsDefaultCollection'].forEach(elemId => {
|
|
11918
|
+
const el = document.getElementById(elemId);
|
|
11919
|
+
if (el) {
|
|
11920
|
+
let debounce;
|
|
11921
|
+
el.addEventListener('input', () => {
|
|
11922
|
+
clearTimeout(debounce);
|
|
11923
|
+
debounce = setTimeout(() => {
|
|
11924
|
+
const cliKey = dbSettingsFields[elemId];
|
|
11925
|
+
fetch('/api/settings', {
|
|
11926
|
+
method: 'PUT',
|
|
11927
|
+
headers: { 'Content-Type': 'application/json' },
|
|
11928
|
+
body: JSON.stringify({ [cliKey]: el.value || null }),
|
|
11929
|
+
}).then(() => flashSaved()).catch(() => {});
|
|
11930
|
+
}, 600);
|
|
11931
|
+
});
|
|
11932
|
+
}
|
|
11933
|
+
});
|
|
11934
|
+
|
|
11935
|
+
// MongoDB URI — save button (secret field, same pattern as API key)
|
|
11936
|
+
const mongoUriInput = document.getElementById('settingsMongoUri');
|
|
11937
|
+
const mongoUriToggle = document.getElementById('settingsMongoUriToggle');
|
|
11938
|
+
const mongoUriSave = document.getElementById('settingsMongoUriSave');
|
|
11939
|
+
if (mongoUriInput && mongoUriSave) {
|
|
11940
|
+
let mongoUriVisible = false;
|
|
11941
|
+
if (mongoUriToggle) {
|
|
11942
|
+
mongoUriToggle.addEventListener('click', async () => {
|
|
11943
|
+
if (!mongoUriVisible) {
|
|
11944
|
+
// Fetch the real unmasked value
|
|
11945
|
+
try {
|
|
11946
|
+
const resp = await fetch('/api/settings/reveal/mongodb-uri');
|
|
11947
|
+
const data = await resp.json();
|
|
11948
|
+
if (data.value) {
|
|
11949
|
+
mongoUriInput.value = data.value;
|
|
11950
|
+
delete mongoUriInput.dataset.isMasked;
|
|
11951
|
+
}
|
|
11952
|
+
} catch {}
|
|
11953
|
+
mongoUriInput.type = 'text';
|
|
11954
|
+
mongoUriVisible = true;
|
|
11955
|
+
} else {
|
|
11956
|
+
mongoUriInput.type = 'password';
|
|
11957
|
+
mongoUriVisible = false;
|
|
11958
|
+
}
|
|
11959
|
+
});
|
|
11960
|
+
}
|
|
11961
|
+
mongoUriSave.addEventListener('click', () => {
|
|
11962
|
+
// Don't save masked values back
|
|
11963
|
+
if (mongoUriInput.dataset.isMasked === 'true') return;
|
|
11964
|
+
const val = mongoUriInput.value.trim();
|
|
11965
|
+
fetch('/api/settings', {
|
|
11966
|
+
method: 'PUT',
|
|
11967
|
+
headers: { 'Content-Type': 'application/json' },
|
|
11968
|
+
body: JSON.stringify({ 'mongodb-uri': val || null }),
|
|
11969
|
+
}).then(() => {
|
|
11970
|
+
flashSaved();
|
|
11971
|
+
if (val) {
|
|
11972
|
+
mongoUriInput.value = '';
|
|
11973
|
+
mongoUriInput.type = 'password';
|
|
11974
|
+
mongoUriVisible = false;
|
|
11975
|
+
mongoUriInput.placeholder = val.slice(0, 20) + '...';
|
|
11976
|
+
}
|
|
11977
|
+
}).catch(() => {});
|
|
11978
|
+
});
|
|
11979
|
+
mongoUriInput.addEventListener('input', () => {
|
|
11980
|
+
delete mongoUriInput.dataset.isMasked;
|
|
11981
|
+
});
|
|
11982
|
+
mongoUriInput.addEventListener('keydown', (e) => {
|
|
11983
|
+
if (e.key === 'Enter') mongoUriSave.click();
|
|
11984
|
+
});
|
|
11985
|
+
}
|
|
11986
|
+
|
|
11848
11987
|
// Set up settings sub-navigation
|
|
11849
11988
|
setupSettingsNav();
|
|
11850
11989
|
|