vectra-js 0.9.3 → 0.9.4
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/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/npm-publish.yml +39 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/README.md +30 -0
- package/bin/vectra.js +9 -3
- package/package.json +23 -21
- package/src/backends/chroma_store.js +13 -2
- package/src/config.js +9 -0
- package/src/core.js +203 -1
- package/src/dashboard/dashboard-script.js +260 -0
- package/src/dashboard/index.html +362 -0
- package/src/dashboard/logo.png +0 -0
- package/src/dashboard/trace-script.js +184 -0
- package/src/dashboard/trace.html +239 -0
- package/src/observability.js +226 -0
- package/src/processor.js +1 -1
- package/src/ui/index.html +278 -236
- package/src/ui/logo.png +0 -0
- package/src/ui/script.js +59 -10
- package/src/ui/style.css +2 -2
- package/src/webconfig_server.js +162 -2
package/src/ui/logo.png
ADDED
|
Binary file
|
package/src/ui/script.js
CHANGED
|
@@ -1,4 +1,50 @@
|
|
|
1
1
|
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
// --- Dark Mode Logic ---
|
|
3
|
+
const themeToggleBtn = document.getElementById('theme-toggle');
|
|
4
|
+
const darkIcon = document.getElementById('theme-toggle-dark-icon');
|
|
5
|
+
const lightIcon = document.getElementById('theme-toggle-light-icon');
|
|
6
|
+
|
|
7
|
+
// Initial State
|
|
8
|
+
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
9
|
+
document.documentElement.classList.add('dark');
|
|
10
|
+
if (lightIcon) lightIcon.classList.remove('hidden');
|
|
11
|
+
} else {
|
|
12
|
+
document.documentElement.classList.remove('dark');
|
|
13
|
+
if (darkIcon) darkIcon.classList.remove('hidden');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Toggle Event
|
|
17
|
+
if (themeToggleBtn) {
|
|
18
|
+
themeToggleBtn.addEventListener('click', () => {
|
|
19
|
+
// Toggle icons
|
|
20
|
+
if (darkIcon) darkIcon.classList.toggle('hidden');
|
|
21
|
+
if (lightIcon) lightIcon.classList.toggle('hidden');
|
|
22
|
+
|
|
23
|
+
// If is set in local storage
|
|
24
|
+
if (localStorage.getItem('color-theme')) {
|
|
25
|
+
if (localStorage.getItem('color-theme') === 'light') {
|
|
26
|
+
document.documentElement.classList.add('dark');
|
|
27
|
+
localStorage.setItem('color-theme', 'dark');
|
|
28
|
+
} else {
|
|
29
|
+
document.documentElement.classList.remove('dark');
|
|
30
|
+
localStorage.setItem('color-theme', 'light');
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
// If not in local storage
|
|
34
|
+
if (document.documentElement.classList.contains('dark')) {
|
|
35
|
+
document.documentElement.classList.remove('dark');
|
|
36
|
+
localStorage.setItem('color-theme', 'light');
|
|
37
|
+
} else {
|
|
38
|
+
document.documentElement.classList.add('dark');
|
|
39
|
+
localStorage.setItem('color-theme', 'dark');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Trigger preview update
|
|
44
|
+
if (typeof updatePreview === 'function') updatePreview();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
2
48
|
// --- Navigation Logic (Smooth Scrolling & Scroll Spy) ---
|
|
3
49
|
const links = document.querySelectorAll('.nav-item');
|
|
4
50
|
const sections = document.querySelectorAll('section');
|
|
@@ -60,13 +106,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
60
106
|
|
|
61
107
|
function updateSidebarState(currentId) {
|
|
62
108
|
links.forEach(link => {
|
|
63
|
-
link.classList.remove('bg-
|
|
109
|
+
link.classList.remove('bg-brand-50', 'text-brand-600', 'active', 'border-l-4', 'border-brand-600');
|
|
64
110
|
link.classList.add('text-gray-600', 'hover:bg-gray-50');
|
|
65
111
|
|
|
66
112
|
// We use a slight visual indicator for active state
|
|
67
113
|
if (link.getAttribute('data-target') === currentId) {
|
|
68
114
|
link.classList.remove('text-gray-600', 'hover:bg-gray-50');
|
|
69
|
-
link.classList.add('bg-
|
|
115
|
+
link.classList.add('bg-brand-50', 'text-brand-600', 'active');
|
|
70
116
|
}
|
|
71
117
|
});
|
|
72
118
|
}
|
|
@@ -138,8 +184,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
138
184
|
const row = document.createElement('div');
|
|
139
185
|
row.className = 'flex items-center space-x-2 header-row';
|
|
140
186
|
row.innerHTML = `
|
|
141
|
-
<input type="text" placeholder="Key" value="${key}" class="header-key block w-1/2 rounded-md border-gray-300 shadow-sm focus:border-
|
|
142
|
-
<input type="text" placeholder="Value" value="${value}" class="header-value block w-1/2 rounded-md border-gray-300 shadow-sm focus:border-
|
|
187
|
+
<input type="text" placeholder="Key" value="${key}" class="header-key block w-1/2 rounded-md border-gray-300 dark:border-white/10 bg-white dark:bg-dark-950 text-slate-900 dark:text-white shadow-sm focus:border-brand-500 focus:ring-brand-500 sm:text-sm py-2 px-3 border">
|
|
188
|
+
<input type="text" placeholder="Value" value="${value}" class="header-value block w-1/2 rounded-md border-gray-300 dark:border-white/10 bg-white dark:bg-dark-950 text-slate-900 dark:text-white shadow-sm focus:border-brand-500 focus:ring-brand-500 sm:text-sm py-2 px-3 border">
|
|
143
189
|
<button type="button" class="remove-header p-2 text-gray-400 hover:text-red-500">
|
|
144
190
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
|
145
191
|
</button>
|
|
@@ -308,12 +354,15 @@ function setBackend(type) {
|
|
|
308
354
|
const btnNode = document.getElementById('backend-node');
|
|
309
355
|
const btnPython = document.getElementById('backend-python');
|
|
310
356
|
|
|
357
|
+
const activeClass = 'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-200 text-brand-600 dark:text-brand-400 bg-white dark:bg-brand-900/20 shadow-sm';
|
|
358
|
+
const inactiveClass = 'px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-200 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white';
|
|
359
|
+
|
|
311
360
|
if (isPythonBackend) {
|
|
312
|
-
btnPython.className =
|
|
313
|
-
btnNode.className =
|
|
361
|
+
btnPython.className = activeClass;
|
|
362
|
+
btnNode.className = inactiveClass;
|
|
314
363
|
} else {
|
|
315
|
-
btnNode.className =
|
|
316
|
-
btnPython.className =
|
|
364
|
+
btnNode.className = activeClass;
|
|
365
|
+
btnPython.className = inactiveClass;
|
|
317
366
|
}
|
|
318
367
|
|
|
319
368
|
updatePreview();
|
|
@@ -447,8 +496,8 @@ function addHeaderRow(key = '', value = '') {
|
|
|
447
496
|
const row = document.createElement('div');
|
|
448
497
|
row.className = 'flex items-center space-x-2 header-row mb-2';
|
|
449
498
|
row.innerHTML = `
|
|
450
|
-
<input type="text" placeholder="Key" value="${key}" class="header-key block w-1/2 rounded-md border-
|
|
451
|
-
<input type="text" placeholder="Value" value="${value}" class="header-value block w-1/2 rounded-md border-
|
|
499
|
+
<input type="text" placeholder="Key" value="${key}" class="header-key block w-1/2 rounded-md border-white/10 bg-dark-950 text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm py-2 px-3 border">
|
|
500
|
+
<input type="text" placeholder="Value" value="${value}" class="header-value block w-1/2 rounded-md border-white/10 bg-dark-950 text-white shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm py-2 px-3 border">
|
|
452
501
|
<button type="button" class="remove-header p-2 text-gray-400 hover:text-red-500 transition-colors">
|
|
453
502
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
|
454
503
|
</button>
|
package/src/ui/style.css
CHANGED
|
@@ -79,7 +79,7 @@ body {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
.nav-item.active {
|
|
82
|
-
background-color: #
|
|
82
|
+
background-color: #f5f3ff;
|
|
83
83
|
color: var(--primary);
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -176,7 +176,7 @@ label {
|
|
|
176
176
|
.form-control:focus {
|
|
177
177
|
outline: none;
|
|
178
178
|
border-color: var(--primary);
|
|
179
|
-
box-shadow: 0 0 0 3px rgba(
|
|
179
|
+
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1);
|
|
180
180
|
background-color: #fff;
|
|
181
181
|
}
|
|
182
182
|
|
package/src/webconfig_server.js
CHANGED
|
@@ -2,6 +2,37 @@ const http = require('http');
|
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const { RAGConfigSchema, ProviderType, ChunkingStrategy, RetrievalStrategy } = require('./config');
|
|
5
|
+
const sqlite3 = require('sqlite3').verbose();
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
// Helper to get DB connection from config
|
|
9
|
+
const getDb = (configPath) => {
|
|
10
|
+
try {
|
|
11
|
+
if (!fs.existsSync(configPath)) return null;
|
|
12
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
13
|
+
if (!cfg.observability || !cfg.observability.enabled) return null;
|
|
14
|
+
|
|
15
|
+
let dbPath = cfg.observability.sqlitePath || 'vectra-observability.db';
|
|
16
|
+
if (!path.isAbsolute(dbPath)) {
|
|
17
|
+
dbPath = path.resolve(path.dirname(configPath), dbPath);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return new sqlite3.Database(dbPath, sqlite3.OPEN_READONLY);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.error('Failed to open observability DB:', e);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Promisify sqlite3 methods
|
|
28
|
+
const dbQuery = (db, method, sql, params = []) => {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
db[method](sql, params, (err, result) => {
|
|
31
|
+
if (err) reject(err);
|
|
32
|
+
else resolve(result);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
};
|
|
5
36
|
|
|
6
37
|
// Simple browser opener using child_process since 'open' package might not be installed
|
|
7
38
|
const { exec } = require('child_process');
|
|
@@ -57,7 +88,7 @@ function serveStatic(res, filePath, contentType) {
|
|
|
57
88
|
}
|
|
58
89
|
}
|
|
59
90
|
|
|
60
|
-
function start(configPath, port = 8766, openInBrowser = true) {
|
|
91
|
+
function start(configPath, mode = 'webconfig', port = 8766, openInBrowser = true) {
|
|
61
92
|
const absConfigPath = path.resolve(configPath);
|
|
62
93
|
|
|
63
94
|
const createServer = (currentPort) => {
|
|
@@ -67,6 +98,132 @@ function start(configPath, port = 8766, openInBrowser = true) {
|
|
|
67
98
|
res.end(JSON.stringify(obj));
|
|
68
99
|
};
|
|
69
100
|
|
|
101
|
+
// --- Dashboard Routes ---
|
|
102
|
+
|
|
103
|
+
// Redirect /dashboard to /dashboard/ to handle relative assets correctly
|
|
104
|
+
if (req.method === 'GET' && req.url === '/dashboard') {
|
|
105
|
+
res.writeHead(301, { 'Location': '/dashboard/' });
|
|
106
|
+
res.end();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Serve Dashboard HTML
|
|
111
|
+
if (req.method === 'GET' && req.url === '/dashboard/') {
|
|
112
|
+
const filePath = path.join(__dirname, 'dashboard', 'index.html');
|
|
113
|
+
serveStatic(res, filePath, 'text/html; charset=utf-8');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Serve Dashboard Assets
|
|
118
|
+
if (req.method === 'GET' && req.url.startsWith('/dashboard/')) {
|
|
119
|
+
const assetName = req.url.split('?')[0].replace('/dashboard/', '');
|
|
120
|
+
const filePath = path.join(__dirname, 'dashboard', assetName);
|
|
121
|
+
if (fs.existsSync(filePath)) {
|
|
122
|
+
const ext = path.extname(filePath);
|
|
123
|
+
let type = 'text/plain';
|
|
124
|
+
if (ext === '.css') type = 'text/css';
|
|
125
|
+
if (ext === '.js') type = 'application/javascript';
|
|
126
|
+
if (ext === '.html') type = 'text/html';
|
|
127
|
+
|
|
128
|
+
serveStatic(res, filePath, type);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Observability API ---
|
|
134
|
+
|
|
135
|
+
if (req.method === 'GET' && req.url.startsWith('/api/observability/')) {
|
|
136
|
+
const db = getDb(absConfigPath);
|
|
137
|
+
if (!db) {
|
|
138
|
+
sendJson(400, { error: 'Observability not enabled or DB not found' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const handleRequest = async () => {
|
|
143
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
144
|
+
const projectId = url.searchParams.get('projectId');
|
|
145
|
+
// Sanitize projectId to avoid injection if possible, though strict typing helps.
|
|
146
|
+
// Using parameterized queries is safer.
|
|
147
|
+
|
|
148
|
+
let projectFilter = '';
|
|
149
|
+
const params = [];
|
|
150
|
+
|
|
151
|
+
if (projectId && projectId !== 'all') {
|
|
152
|
+
projectFilter = 'WHERE project_id = ?';
|
|
153
|
+
params.push(projectId);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
if (url.pathname.endsWith('/stats')) {
|
|
158
|
+
// Aggregate stats
|
|
159
|
+
// Fix: projectFilter starts with WHERE, but here we append to existing WHERE if name='queryRAG'.
|
|
160
|
+
// So: WHERE name='queryRAG' AND project_id = ?
|
|
161
|
+
const whereClause = projectFilter ? `AND project_id = ?` : '';
|
|
162
|
+
|
|
163
|
+
const totalReq = await dbQuery(db, 'get', `SELECT COUNT(*) as count FROM traces WHERE name = 'queryRAG' ${whereClause}`, params);
|
|
164
|
+
|
|
165
|
+
const avgLat = await dbQuery(db, 'get', `SELECT AVG(value) as val FROM metrics WHERE name = 'query_latency' ${whereClause}`, params);
|
|
166
|
+
const tokensP = await dbQuery(db, 'get', `SELECT SUM(value) as val FROM metrics WHERE name = 'prompt_chars' ${whereClause}`, params);
|
|
167
|
+
const tokensC = await dbQuery(db, 'get', `SELECT SUM(value) as val FROM metrics WHERE name = 'completion_chars' ${whereClause}`, params);
|
|
168
|
+
|
|
169
|
+
// History for charts (last 50 query latencies)
|
|
170
|
+
const history = await dbQuery(db, 'all', `
|
|
171
|
+
SELECT m.timestamp, m.value as latency,
|
|
172
|
+
(SELECT value FROM metrics m2 WHERE m2.timestamp = m.timestamp AND m2.name = 'prompt_chars') +
|
|
173
|
+
(SELECT value FROM metrics m3 WHERE m3.timestamp = m.timestamp AND m3.name = 'completion_chars') as tokens
|
|
174
|
+
FROM metrics m
|
|
175
|
+
WHERE m.name = 'query_latency' ${whereClause}
|
|
176
|
+
ORDER BY m.timestamp DESC LIMIT 50
|
|
177
|
+
`, params);
|
|
178
|
+
|
|
179
|
+
sendJson(200, {
|
|
180
|
+
totalRequests: totalReq ? totalReq.count : 0,
|
|
181
|
+
avgLatency: avgLat ? avgLat.val : 0,
|
|
182
|
+
totalPromptChars: tokensP ? tokensP.val : 0,
|
|
183
|
+
totalCompletionChars: tokensC ? tokensC.val : 0,
|
|
184
|
+
history: history ? history.reverse() : []
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
else if (url.pathname.endsWith('/traces')) {
|
|
188
|
+
const whereClause = projectFilter ? `AND project_id = ?` : '';
|
|
189
|
+
const traces = await dbQuery(db, 'all', `SELECT * FROM traces WHERE name = 'queryRAG' ${whereClause} ORDER BY start_time DESC LIMIT 50`, params);
|
|
190
|
+
sendJson(200, traces);
|
|
191
|
+
}
|
|
192
|
+
else if (url.pathname.match(/\/traces\/([a-zA-Z0-9-]+)$/)) {
|
|
193
|
+
const traceId = url.pathname.split('/').pop();
|
|
194
|
+
const spans = await dbQuery(db, 'all', `SELECT * FROM traces WHERE trace_id = ?`, [traceId]);
|
|
195
|
+
sendJson(200, spans);
|
|
196
|
+
}
|
|
197
|
+
else if (url.pathname.endsWith('/sessions')) {
|
|
198
|
+
// sessions has project_id column
|
|
199
|
+
const sessions = await dbQuery(db, 'all', `SELECT * FROM sessions ${projectFilter} ORDER BY last_activity_time DESC LIMIT 50`, params);
|
|
200
|
+
// Parse metadata JSON
|
|
201
|
+
if (sessions) {
|
|
202
|
+
sessions.forEach(s => { if(s.metadata) s.metadata = JSON.parse(s.metadata); });
|
|
203
|
+
}
|
|
204
|
+
sendJson(200, sessions);
|
|
205
|
+
}
|
|
206
|
+
else if (url.pathname.endsWith('/projects')) {
|
|
207
|
+
const projects = await dbQuery(db, 'all', `SELECT DISTINCT project_id FROM traces`);
|
|
208
|
+
sendJson(200, projects.map(p => p.project_id).filter(Boolean));
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
sendJson(404, { error: 'Unknown endpoint' });
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.error(e);
|
|
215
|
+
sendJson(500, { error: e.message });
|
|
216
|
+
} finally {
|
|
217
|
+
db.close();
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
handleRequest();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- Config UI Routes (Legacy) ---
|
|
226
|
+
|
|
70
227
|
// Serve index.html
|
|
71
228
|
if (req.method === 'GET' && (req.url === '/' || req.url === '/index.html')) {
|
|
72
229
|
const filePath = path.join(__dirname, 'ui', 'index.html');
|
|
@@ -161,7 +318,10 @@ function start(configPath, port = 8766, openInBrowser = true) {
|
|
|
161
318
|
});
|
|
162
319
|
|
|
163
320
|
server.listen(currentPort, () => {
|
|
164
|
-
|
|
321
|
+
let url = `http://localhost:${currentPort}/`;
|
|
322
|
+
if (mode === 'dashboard') {
|
|
323
|
+
url = `http://localhost:${currentPort}/dashboard`;
|
|
324
|
+
}
|
|
165
325
|
console.log(`Vectra WebConfig running at ${url}`);
|
|
166
326
|
if (openInBrowser) {
|
|
167
327
|
openBrowser(url);
|