@znt/mcp 1.1.1 → 2.0.2

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.
@@ -0,0 +1,726 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { spawn } from "node:child_process";
4
+
5
+ const MAX_BODY_BYTES = 256 * 1024;
6
+
7
+ export class CredentialSetupServer {
8
+ constructor(options = {}) {
9
+ this.host = options.host ?? "127.0.0.1";
10
+ this.server = undefined;
11
+ this.expiresAt = 0;
12
+ this.path = "";
13
+ this.onCredential = undefined;
14
+ this.onSetup = undefined;
15
+ this.initialData = {};
16
+ this.expiryTimer = undefined;
17
+ this.processing = false;
18
+ }
19
+
20
+ async open({ provider, onCredential, onSetup, initialData = {}, ttlMs = 15 * 60_000, autoOpen = true }) {
21
+ await this.close();
22
+ this.path = `/setup/${randomBytes(24).toString("hex")}`;
23
+ this.expiresAt = Date.now() + ttlMs;
24
+ this.onCredential = onCredential;
25
+ this.onSetup = onSetup;
26
+ this.initialData = { provider, ...initialData };
27
+ this.server = createServer((request, response) => this.handle(request, response));
28
+ await new Promise((resolve, reject) => {
29
+ this.server.once("error", reject);
30
+ this.server.listen(0, this.host, resolve);
31
+ });
32
+ this.expiryTimer = setTimeout(() => this.close(), ttlMs);
33
+ this.expiryTimer.unref?.();
34
+ const address = this.server.address();
35
+ const url = `http://${this.host}:${address.port}${this.path}`;
36
+
37
+ if (autoOpen && process.env.NODE_ENV !== "test") {
38
+ openBrowser(url);
39
+ }
40
+ return url;
41
+ }
42
+
43
+ async close() {
44
+ if (this.expiryTimer) clearTimeout(this.expiryTimer);
45
+ this.expiryTimer = undefined;
46
+ const server = this.server;
47
+ this.server = undefined;
48
+ this.onCredential = undefined;
49
+ this.onSetup = undefined;
50
+ this.initialData = {};
51
+ if (server) await new Promise((resolve) => server.close(resolve));
52
+ }
53
+
54
+ async handle(request, response) {
55
+ response.setHeader("Cache-Control", "no-store");
56
+ response.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'");
57
+ response.setHeader("X-Content-Type-Options", "nosniff");
58
+
59
+ if (request.url !== this.path || Date.now() > this.expiresAt) {
60
+ return send(response, 404, "Setup link is invalid or expired.");
61
+ }
62
+
63
+ if (request.method === "GET") {
64
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
65
+ response.end(renderWizard(this.initialData));
66
+ return;
67
+ }
68
+
69
+ if (request.method !== "POST") {
70
+ response.setHeader("Allow", "GET, POST");
71
+ return send(response, 405, "Method not allowed.");
72
+ }
73
+
74
+ if (this.processing) {
75
+ return sendJson(response, 409, { error: "A configuration check is already in progress." });
76
+ }
77
+
78
+ const origin = request.headers.origin;
79
+ const address = this.server?.address();
80
+ const expectedOrigin = address && `http://${this.host}:${address.port}`;
81
+ if (origin && origin !== expectedOrigin) {
82
+ return sendJson(response, 403, { error: "Origin check failed." });
83
+ }
84
+
85
+ try {
86
+ const body = await readBody(request);
87
+ let payload;
88
+ const contentType = request.headers["content-type"] || "";
89
+ if (contentType.includes("application/json")) {
90
+ payload = JSON.parse(body);
91
+ } else {
92
+ const params = new URLSearchParams(body);
93
+ payload = Object.fromEntries(params.entries());
94
+ }
95
+
96
+ this.processing = true;
97
+
98
+ if (this.onSetup) {
99
+ await this.onSetup(payload);
100
+ } else if (this.onCredential) {
101
+ const credential = payload.credential ?? payload.api_key ?? "";
102
+ if (!credential) {
103
+ return sendJson(response, 400, { error: "API key is required." });
104
+ }
105
+ await this.onCredential(credential);
106
+ } else {
107
+ return sendJson(response, 410, { error: "Setup session is closed." });
108
+ }
109
+
110
+ sendJson(response, 200, { success: true, message: "ZNT is successfully configured!" });
111
+ setTimeout(() => this.close(), 1500).unref?.();
112
+ } catch (error) {
113
+ const message = error?.message || "Configuration could not be saved or validated.";
114
+ sendJson(response, 400, { error: message });
115
+ } finally {
116
+ this.processing = false;
117
+ }
118
+ }
119
+ }
120
+
121
+ export function openBrowser(url) {
122
+ try {
123
+ const platform = process.platform;
124
+ if (platform === "darwin") {
125
+ spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
126
+ } else if (platform === "win32") {
127
+ spawn("cmd.exe", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
128
+ } else {
129
+ spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
130
+ }
131
+ } catch {
132
+ // Ignore if system browser cannot be spawned in headless environment
133
+ }
134
+ }
135
+
136
+ function readBody(request) {
137
+ return new Promise((resolve, reject) => {
138
+ let body = "";
139
+ request.setEncoding("utf8");
140
+ request.on("data", (chunk) => {
141
+ body += chunk;
142
+ if (body.length > MAX_BODY_BYTES) {
143
+ reject(new Error("Request body is too large."));
144
+ request.destroy();
145
+ }
146
+ });
147
+ request.on("end", () => resolve(body));
148
+ request.on("error", reject);
149
+ });
150
+ }
151
+
152
+ function send(response, status, message) {
153
+ response.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
154
+ response.end(message);
155
+ }
156
+
157
+ function sendJson(response, status, data) {
158
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
159
+ response.end(JSON.stringify(data));
160
+ }
161
+
162
+ function escapeHtml(value) {
163
+ if (value === undefined || value === null) return "";
164
+ return String(value).replace(/[&<>"']/g, (character) => ({
165
+ "&": "&amp;",
166
+ "<": "&lt;",
167
+ ">": "&gt;",
168
+ "\"": "&quot;",
169
+ "'": "&#39;",
170
+ })[character]);
171
+ }
172
+
173
+ function renderWizard(initial = {}) {
174
+ const currentMode = initial.mode || (initial.provider === "ollama" ? "ollama" : initial.provider === "bm25" ? "bm25" : "openapi");
175
+ const currentProviderUrl = initial.provider_url || initial.url || (currentMode === "ollama" ? "http://localhost:11434" : "https://api.openai.com/v1");
176
+ const currentModel = initial.model || (currentMode === "ollama" ? "codegemma" : "");
177
+ const currentEmbedModel = initial.embed_model || "bge-m3";
178
+ const currentSemanticMode = initial.semantic_mode || "fast";
179
+ const currentLang = initial.description_language || "ru";
180
+ const currentExclude = Array.isArray(initial.exclude) ? initial.exclude.join("\n") : (initial.exclude || "");
181
+ const currentLanguages = initial.languages || {};
182
+ const hasCredential = Boolean(initial.has_credential);
183
+
184
+ const knownLanguages = [
185
+ { key: "php", label: "PHP", defaultExclude: "vendor/**\nstorage/**\ncache/**\nnode_modules/**" },
186
+ { key: "javascript", label: "JavaScript / TypeScript", defaultExclude: "node_modules/**\ndist/**\n.nuxt/**\n.next/**\nbuild/**\npublic/build/**\n**/*.min.js\n**/wwwroot/lib/**" },
187
+ { key: "go", label: "Go", defaultExclude: "vendor/**" },
188
+ { key: "python", label: "Python", defaultExclude: "**/__pycache__/**\n**/.pytest_cache/**\n**/.venv/**\n**/venv/**\n**/.git/**" },
189
+ { key: "csharp", label: "C#", defaultExclude: "bin/**\nobj/**\nTestResults/**" },
190
+ { key: "java", label: "Java", defaultExclude: "target/**\nbuild/**\nout/**\nbin/**\n.gradle/**\n.idea/**" },
191
+ { key: "resource", label: "Resource / Configs", defaultExclude: "vendor/**\nnode_modules/**" },
192
+ ];
193
+
194
+ return `<!doctype html>
195
+ <html lang="en">
196
+ <head>
197
+ <meta charset="utf-8">
198
+ <title>Znatok MCP Setup</title>
199
+ <meta name="viewport" content="width=device-width, initial-scale=1">
200
+ <style>
201
+ :root {
202
+ --bg: #0f172a;
203
+ --card-bg: #1e293b;
204
+ --card-border: #334155;
205
+ --text: #f8fafc;
206
+ --text-muted: #94a3b8;
207
+ --primary: #38bdf8;
208
+ --primary-hover: #0284c7;
209
+ --accent: #818cf8;
210
+ --danger: #f87171;
211
+ --danger-bg: rgba(239, 68, 68, 0.15);
212
+ --success: #34d399;
213
+ --input-bg: #0f172a;
214
+ --input-border: #475569;
215
+ }
216
+ @media (prefers-color-scheme: light) {
217
+ :root {
218
+ --bg: #f8fafc;
219
+ --card-bg: #ffffff;
220
+ --card-border: #e2e8f0;
221
+ --text: #0f172a;
222
+ --text-muted: #64748b;
223
+ --primary: #0284c7;
224
+ --primary-hover: #0369a1;
225
+ --accent: #6366f1;
226
+ --danger: #dc2626;
227
+ --danger-bg: rgba(220, 38, 38, 0.1);
228
+ --success: #059669;
229
+ --input-bg: #f8fafc;
230
+ --input-border: #cbd5e1;
231
+ }
232
+ }
233
+ * { box-sizing: border-box; margin: 0; padding: 0; }
234
+ body {
235
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
236
+ background: var(--bg);
237
+ color: var(--text);
238
+ line-height: 1.5;
239
+ padding: 32px 16px;
240
+ display: flex;
241
+ justify-content: center;
242
+ }
243
+ .container {
244
+ width: 100%;
245
+ max-width: 600px;
246
+ background: var(--card-bg);
247
+ border: 1px solid var(--card-border);
248
+ border-radius: 16px;
249
+ padding: 32px;
250
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.2);
251
+ }
252
+ header { margin-bottom: 24px; text-align: center; }
253
+ header h1 { font-size: 24px; font-weight: 700; color: var(--text); margin-bottom: 6px; }
254
+ header p { color: var(--text-muted); font-size: 14px; }
255
+ .form-group { margin-bottom: 18px; }
256
+ label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; color: var(--text); }
257
+ .hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
258
+ input[type="text"], input[type="password"], select, textarea {
259
+ width: 100%;
260
+ padding: 10px 12px;
261
+ background: var(--input-bg);
262
+ border: 1px solid var(--input-border);
263
+ border-radius: 8px;
264
+ color: var(--text);
265
+ font-size: 14px;
266
+ transition: border-color 0.15s, box-shadow 0.15s;
267
+ }
268
+ input:focus, select:focus, textarea:focus {
269
+ outline: none;
270
+ border-color: var(--primary);
271
+ box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2);
272
+ }
273
+ .mode-tabs {
274
+ display: grid;
275
+ grid-template-columns: 1fr 1fr 1fr;
276
+ gap: 8px;
277
+ margin-bottom: 20px;
278
+ }
279
+ .mode-tab {
280
+ background: var(--input-bg);
281
+ border: 1px solid var(--input-border);
282
+ padding: 10px 6px;
283
+ text-align: center;
284
+ border-radius: 8px;
285
+ cursor: pointer;
286
+ font-size: 13px;
287
+ font-weight: 600;
288
+ color: var(--text-muted);
289
+ transition: all 0.2s;
290
+ }
291
+ .mode-tab.active {
292
+ background: var(--primary);
293
+ color: #fff;
294
+ border-color: var(--primary);
295
+ }
296
+ .radio-group {
297
+ display: flex;
298
+ gap: 16px;
299
+ margin-top: 4px;
300
+ }
301
+ .radio-card {
302
+ flex: 1;
303
+ display: flex;
304
+ align-items: center;
305
+ gap: 8px;
306
+ padding: 10px 12px;
307
+ border: 1px solid var(--input-border);
308
+ border-radius: 8px;
309
+ cursor: pointer;
310
+ background: var(--input-bg);
311
+ }
312
+ .radio-card:hover { border-color: var(--primary); }
313
+ details {
314
+ margin-top: 16px;
315
+ border: 1px solid var(--card-border);
316
+ border-radius: 8px;
317
+ padding: 12px 14px;
318
+ background: rgba(0, 0, 0, 0.05);
319
+ }
320
+ details[open] { padding-bottom: 16px; }
321
+ summary {
322
+ cursor: pointer;
323
+ font-size: 13px;
324
+ font-weight: 600;
325
+ color: var(--primary);
326
+ user-select: none;
327
+ }
328
+ details[open] summary { margin-bottom: 14px; }
329
+ .nested-details {
330
+ margin-top: 10px;
331
+ border: 1px solid var(--input-border);
332
+ border-radius: 6px;
333
+ background: var(--input-bg);
334
+ padding: 10px 12px;
335
+ }
336
+ .nested-details summary {
337
+ color: var(--text);
338
+ font-size: 12px;
339
+ font-weight: 600;
340
+ }
341
+ button[type="submit"] {
342
+ width: 100%;
343
+ background: var(--primary);
344
+ color: #ffffff;
345
+ padding: 12px;
346
+ border: none;
347
+ border-radius: 8px;
348
+ font-size: 15px;
349
+ font-weight: 600;
350
+ cursor: pointer;
351
+ margin-top: 24px;
352
+ transition: background 0.2s, transform 0.05s;
353
+ }
354
+ button[type="submit"]:hover { background: var(--primary-hover); }
355
+ button[type="submit"]:active { transform: scale(0.99); }
356
+ button[type="submit"]:disabled { opacity: 0.6; cursor: not-allowed; }
357
+ .alert {
358
+ padding: 12px;
359
+ border-radius: 8px;
360
+ font-size: 13px;
361
+ margin-bottom: 16px;
362
+ display: none;
363
+ }
364
+ .alert-error {
365
+ background: var(--danger-bg);
366
+ border: 1px solid var(--danger);
367
+ color: var(--danger);
368
+ }
369
+ .success-view {
370
+ display: none;
371
+ text-align: center;
372
+ padding: 24px 0;
373
+ }
374
+ .success-icon {
375
+ width: 56px;
376
+ height: 56px;
377
+ background: var(--success);
378
+ color: white;
379
+ border-radius: 50%;
380
+ display: inline-flex;
381
+ align-items: center;
382
+ justify-content: center;
383
+ font-size: 28px;
384
+ margin-bottom: 16px;
385
+ }
386
+ .credential-badge {
387
+ display: flex;
388
+ align-items: center;
389
+ justify-content: space-between;
390
+ gap: 12px;
391
+ padding: 10px 14px;
392
+ background: var(--input-bg);
393
+ border: 1px solid var(--input-border);
394
+ border-radius: 8px;
395
+ }
396
+ .credential-info {
397
+ display: flex;
398
+ align-items: center;
399
+ gap: 10px;
400
+ }
401
+ .credential-icon { font-size: 18px; }
402
+ .credential-title { font-size: 13px; font-weight: 600; color: var(--success); }
403
+ .credential-input-footer {
404
+ display: flex;
405
+ justify-content: flex-end;
406
+ gap: 12px;
407
+ margin-top: 6px;
408
+ }
409
+ .btn-sm {
410
+ padding: 6px 12px;
411
+ font-size: 12px;
412
+ border-radius: 6px;
413
+ font-weight: 600;
414
+ cursor: pointer;
415
+ border: 1px solid var(--card-border);
416
+ background: transparent;
417
+ color: var(--text);
418
+ transition: all 0.15s;
419
+ }
420
+ .btn-sm:hover {
421
+ background: var(--card-border);
422
+ }
423
+ .btn-text {
424
+ background: none;
425
+ border: none;
426
+ padding: 0;
427
+ font-size: 12px;
428
+ font-weight: 500;
429
+ color: var(--primary);
430
+ cursor: pointer;
431
+ text-decoration: underline;
432
+ }
433
+ .text-danger {
434
+ color: var(--danger) !important;
435
+ }
436
+ </style>
437
+ </head>
438
+ <body>
439
+ <div class="container">
440
+ <div id="setup-view">
441
+ <header>
442
+ <h1>Znatok Intelligence Setup</h1>
443
+ <p>Configure code intelligence provider, embeddings, and project indexing</p>
444
+ </header>
445
+
446
+ <div id="error-box" class="alert alert-error"></div>
447
+
448
+ <form id="setup-form" autocomplete="off">
449
+ <label>Configuration Mode</label>
450
+ <div class="mode-tabs">
451
+ <div class="mode-tab ${currentMode === "openapi" ? "active" : ""}" data-mode="openapi">OpenAI Compatible</div>
452
+ <div class="mode-tab ${currentMode === "ollama" ? "active" : ""}" data-mode="ollama">Ollama (Local)</div>
453
+ <div class="mode-tab ${currentMode === "bm25" ? "active" : ""}" data-mode="bm25">BM25 (No LLM)</div>
454
+ </div>
455
+ <input type="hidden" name="mode" id="mode" value="${escapeHtml(currentMode)}">
456
+
457
+ <div id="section-openapi-ollama">
458
+ <div class="form-group" id="group-provider-url">
459
+ <label for="provider_url">API Base URL</label>
460
+ <input type="text" id="provider_url" name="provider_url" value="${escapeHtml(currentProviderUrl)}" required>
461
+ <div class="hint">Compatible with OpenAI endpoints (e.g. https://api.openai.com/v1, OpenRouter, vLLM, DeepSeek)</div>
462
+ </div>
463
+
464
+ <div class="form-group" id="group-api-key">
465
+ <label for="api_key">API Key</label>
466
+ ${hasCredential ? `
467
+ <div id="credential-saved-badge" class="credential-badge">
468
+ <div class="credential-info">
469
+ <span class="credential-icon">🔒</span>
470
+ <div>
471
+ <div class="credential-title">${initial.credential_source === "env" ? `Configured in env (${escapeHtml(initial.token_env || "")})` : "Configured in OS Keyring"}</div>
472
+ <div class="hint" style="margin: 0;">Active key is retained automatically</div>
473
+ </div>
474
+ </div>
475
+ <div class="credential-actions">
476
+ <button type="button" id="btn-replace-key" class="btn-sm">Replace / Delete</button>
477
+ </div>
478
+ </div>
479
+ <div id="credential-input-group" style="display: none; margin-top: 6px;">
480
+ <input type="password" id="api_key" name="api_key" placeholder="Enter new API key (sk-...)" autocomplete="off">
481
+ <div class="credential-input-footer">
482
+ <button type="button" id="btn-keep-key" class="btn-text">Keep existing</button>
483
+ <button type="button" id="btn-delete-key" class="btn-text text-danger">Delete key</button>
484
+ </div>
485
+ </div>
486
+ ` : `
487
+ <input type="password" id="api_key" name="api_key" placeholder="sk-..." autocomplete="off">
488
+ `}
489
+ <input type="hidden" id="clear_api_key" name="clear_api_key" value="false">
490
+ <div class="hint" id="api-key-hint">Stored securely in OS Keyring. Never shared with LLM or saved in plain text.</div>
491
+ </div>
492
+
493
+ <div class="form-group" id="group-embed-model">
494
+ <label for="embed_model">Embedding Model (Vector Search)</label>
495
+ <input type="text" id="embed_model" name="embed_model" value="${escapeHtml(currentEmbedModel)}" required autocomplete="off">
496
+ <div class="hint">Model used for semantic vectors (use "none" for lexical-only BM25 search)</div>
497
+ </div>
498
+ </div>
499
+
500
+ <div class="form-group">
501
+ <label>Semantic Analysis Mode</label>
502
+ <div class="radio-group">
503
+ <label class="radio-card">
504
+ <input type="radio" name="semantic_mode" value="fast" ${currentSemanticMode === "fast" ? "checked" : ""}>
505
+ <span><strong>Fast</strong> (AST heuristics, instant, free)</span>
506
+ </label>
507
+ <label class="radio-card">
508
+ <input type="radio" name="semantic_mode" value="llm" ${currentSemanticMode === "llm" ? "checked" : ""}>
509
+ <span><strong>LLM</strong> (AI summaries, requires chat model)</span>
510
+ </label>
511
+ </div>
512
+ </div>
513
+
514
+ <div class="form-group" id="group-chat-model" style="display: ${currentSemanticMode === "llm" ? "block" : "none"};">
515
+ <label for="model">Chat / Generation Model</label>
516
+ <input type="text" id="model" name="model" value="${escapeHtml(currentModel)}" placeholder="e.g. gpt-4o-mini, deepseek/deepseek-chat" autocomplete="off">
517
+ <div class="hint">Model used for code structure explanations (required when Semantic Analysis Mode is LLM)</div>
518
+ </div>
519
+
520
+ <details>
521
+ <summary>⚙️ Advanced Project Settings</summary>
522
+ <div class="form-group" style="margin-top: 14px;">
523
+ <label for="description_language">Description Language</label>
524
+ <select id="description_language" name="description_language">
525
+ <option value="ru" ${currentLang === "ru" ? "selected" : ""}>Russian (ru)</option>
526
+ <option value="en" ${currentLang === "en" ? "selected" : ""}>English (en)</option>
527
+ </select>
528
+ </div>
529
+ <div class="form-group">
530
+ <label for="exclude">Global Exclude Paths (one pattern per line)</label>
531
+ <textarea id="exclude" name="exclude" rows="3" placeholder="dist/**&#10;.git/**">${escapeHtml(currentExclude)}</textarea>
532
+ <div class="hint">Patterns excluded across all languages</div>
533
+ </div>
534
+
535
+ <label style="margin-top: 18px;">Language Specific Excludes</label>
536
+ <div class="hint" style="margin-bottom: 10px;">Everything not excluded is automatically indexed. Configure per-language exclusions below:</div>
537
+
538
+ ${knownLanguages.map((lang) => {
539
+ const existingRules = currentLanguages[lang.key];
540
+ const excludeVal = existingRules && Array.isArray(existingRules.exclude)
541
+ ? existingRules.exclude.join("\n")
542
+ : (existingRules && existingRules.exclude ? existingRules.exclude : lang.defaultExclude);
543
+ return `
544
+ <details class="nested-details">
545
+ <summary>${escapeHtml(lang.label)} Excludes (${escapeHtml(lang.key)})</summary>
546
+ <div class="form-group" style="margin-top: 8px;">
547
+ <textarea name="lang_exclude_${escapeHtml(lang.key)}" rows="3" placeholder="vendor/**">${escapeHtml(excludeVal)}</textarea>
548
+ </div>
549
+ </details>
550
+ `;
551
+ }).join("")}
552
+ </details>
553
+
554
+ <button type="submit" id="btn-submit">Save &amp; Connect</button>
555
+ </form>
556
+ </div>
557
+
558
+ <div id="success-view" class="success-view">
559
+ <div class="success-icon">✓</div>
560
+ <h2>Znatok is Configured!</h2>
561
+ <p style="color: var(--text-muted); margin-top: 8px;">Configuration validated and saved securely.<br>You can now close this tab and return to your IDE or agent.</p>
562
+ </div>
563
+ </div>
564
+
565
+ <script>
566
+ const modeInput = document.getElementById("mode");
567
+ const modeTabs = document.querySelectorAll(".mode-tab");
568
+ const groupApiKey = document.getElementById("group-api-key");
569
+ const groupProviderUrl = document.getElementById("group-provider-url");
570
+ const groupEmbedModel = document.getElementById("group-embed-model");
571
+ const groupChatModel = document.getElementById("group-chat-model");
572
+ const sectionOpenapiOllama = document.getElementById("section-openapi-ollama");
573
+ const providerUrlInput = document.getElementById("provider_url");
574
+ const embedModelInput = document.getElementById("embed_model");
575
+ const modelInput = document.getElementById("model");
576
+ const apiKeyInput = document.getElementById("api_key");
577
+ const btnReplaceKey = document.getElementById("btn-replace-key");
578
+ const btnKeepKey = document.getElementById("btn-keep-key");
579
+ const btnDeleteKey = document.getElementById("btn-delete-key");
580
+ const savedBadge = document.getElementById("credential-saved-badge");
581
+ const inputGroup = document.getElementById("credential-input-group");
582
+ const clearApiKeyInput = document.getElementById("clear_api_key");
583
+ const apiKeyHint = document.getElementById("api-key-hint");
584
+ const form = document.getElementById("setup-form");
585
+ const errorBox = document.getElementById("error-box");
586
+ const btnSubmit = document.getElementById("btn-submit");
587
+ const setupView = document.getElementById("setup-view");
588
+ const successView = document.getElementById("success-view");
589
+
590
+ if (btnReplaceKey) {
591
+ btnReplaceKey.addEventListener("click", () => {
592
+ if (savedBadge) savedBadge.style.display = "none";
593
+ if (inputGroup) inputGroup.style.display = "block";
594
+ if (apiKeyInput) {
595
+ apiKeyInput.value = "";
596
+ apiKeyInput.focus();
597
+ }
598
+ if (clearApiKeyInput) clearApiKeyInput.value = "false";
599
+ });
600
+ }
601
+
602
+ if (btnKeepKey) {
603
+ btnKeepKey.addEventListener("click", () => {
604
+ if (inputGroup) inputGroup.style.display = "none";
605
+ if (savedBadge) savedBadge.style.display = "flex";
606
+ if (apiKeyInput) apiKeyInput.value = "";
607
+ if (clearApiKeyInput) clearApiKeyInput.value = "false";
608
+ if (apiKeyHint) apiKeyHint.textContent = "Stored securely in OS Keyring. Never shared with LLM or saved in plain text.";
609
+ });
610
+ }
611
+
612
+ if (btnDeleteKey) {
613
+ btnDeleteKey.addEventListener("click", () => {
614
+ if (inputGroup) inputGroup.style.display = "none";
615
+ if (savedBadge) savedBadge.style.display = "none";
616
+ if (apiKeyInput) apiKeyInput.value = "";
617
+ if (clearApiKeyInput) clearApiKeyInput.value = "true";
618
+ if (apiKeyHint) apiKeyHint.textContent = "⚠️ Key will be removed from OS Keyring upon saving.";
619
+ });
620
+ }
621
+
622
+ function updateView() {
623
+ const mode = modeInput.value;
624
+ const semanticMode = document.querySelector('input[name="semantic_mode"]:checked')?.value || "fast";
625
+
626
+ modeTabs.forEach(t => t.classList.toggle("active", t.dataset.mode === mode));
627
+
628
+ if (mode === "bm25") {
629
+ sectionOpenapiOllama.style.display = "none";
630
+ providerUrlInput.required = false;
631
+ embedModelInput.required = false;
632
+ if (apiKeyInput) apiKeyInput.required = false;
633
+ } else if (mode === "ollama") {
634
+ sectionOpenapiOllama.style.display = "block";
635
+ groupApiKey.style.display = "none";
636
+ if (apiKeyInput) apiKeyInput.required = false;
637
+ if (providerUrlInput.value === "https://api.openai.com/v1" || providerUrlInput.value === "https://openrouter.ai/api/v1") {
638
+ providerUrlInput.value = "http://localhost:11434";
639
+ }
640
+ providerUrlInput.required = true;
641
+ embedModelInput.required = true;
642
+ } else { // openapi (OpenAI compatible)
643
+ sectionOpenapiOllama.style.display = "block";
644
+ groupApiKey.style.display = "block";
645
+ if (providerUrlInput.value === "http://localhost:11434") {
646
+ providerUrlInput.value = "https://api.openai.com/v1";
647
+ }
648
+ providerUrlInput.required = true;
649
+ embedModelInput.required = true;
650
+ }
651
+
652
+ if (semanticMode === "llm") {
653
+ groupChatModel.style.display = "block";
654
+ modelInput.required = (mode !== "bm25");
655
+ } else {
656
+ groupChatModel.style.display = "none";
657
+ modelInput.required = false;
658
+ }
659
+ }
660
+
661
+ modeTabs.forEach(tab => {
662
+ tab.addEventListener("click", () => {
663
+ modeInput.value = tab.dataset.mode;
664
+ updateView();
665
+ });
666
+ });
667
+
668
+ document.querySelectorAll('input[name="semantic_mode"]').forEach(radio => {
669
+ radio.addEventListener("change", updateView);
670
+ });
671
+
672
+ updateView();
673
+
674
+ form.addEventListener("submit", async (e) => {
675
+ e.preventDefault();
676
+ errorBox.style.display = "none";
677
+ btnSubmit.disabled = true;
678
+ btnSubmit.textContent = "Validating & Saving...";
679
+
680
+ const formData = new FormData(form);
681
+ const raw = Object.fromEntries(formData.entries());
682
+
683
+ const data = {
684
+ mode: raw.mode,
685
+ provider_url: raw.provider_url,
686
+ ...(raw.api_key?.trim() ? { api_key: raw.api_key.trim() } : {}),
687
+ ...(raw.clear_api_key === "true" ? { clear_api_key: true } : {}),
688
+ embed_model: raw.embed_model,
689
+ semantic_mode: raw.semantic_mode,
690
+ model: raw.semantic_mode === "llm" ? (raw.model?.trim() || "") : "",
691
+ description_language: raw.description_language,
692
+ exclude: raw.exclude ? raw.exclude.split("\\n").map(s => s.trim()).filter(Boolean) : [],
693
+ languages: {}
694
+ };
695
+
696
+ for (const [key, value] of Object.entries(raw)) {
697
+ if (key.startsWith("lang_exclude_")) {
698
+ const lang = key.replace("lang_exclude_", "");
699
+ const patterns = value ? value.split("\\n").map(s => s.trim()).filter(Boolean) : [];
700
+ data.languages[lang] = { exclude: patterns };
701
+ }
702
+ }
703
+
704
+ try {
705
+ const res = await fetch(window.location.href, {
706
+ method: "POST",
707
+ headers: { "Content-Type": "application/json" },
708
+ body: JSON.stringify(data)
709
+ });
710
+ const result = await res.json();
711
+ if (!res.ok) {
712
+ throw new Error(result.error || "Configuration failed.");
713
+ }
714
+ setupView.style.display = "none";
715
+ successView.style.display = "block";
716
+ } catch (err) {
717
+ errorBox.textContent = err.message;
718
+ errorBox.style.display = "block";
719
+ btnSubmit.disabled = false;
720
+ btnSubmit.textContent = "Save & Connect";
721
+ }
722
+ });
723
+ </script>
724
+ </body>
725
+ </html>`;
726
+ }