search-rotation 0.4.9

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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/dist/abort.js +14 -0
  4. package/dist/config.js +109 -0
  5. package/dist/engines/base.js +72 -0
  6. package/dist/engines/duckduckgo.js +98 -0
  7. package/dist/engines/exa.js +113 -0
  8. package/dist/engines/firecrawl.js +78 -0
  9. package/dist/engines/googlecse.js +41 -0
  10. package/dist/engines/hosted-mcp.js +24 -0
  11. package/dist/engines/index.js +13 -0
  12. package/dist/engines/jina.js +28 -0
  13. package/dist/engines/parallel.js +74 -0
  14. package/dist/engines/tavily.js +74 -0
  15. package/dist/history.js +59 -0
  16. package/dist/index.js +182 -0
  17. package/dist/mcp/engine-status.js +58 -0
  18. package/dist/mcp/http.js +35 -0
  19. package/dist/mcp/server.js +105 -0
  20. package/dist/persistence.js +80 -0
  21. package/dist/quota.js +37 -0
  22. package/dist/router.js +183 -0
  23. package/dist/search-time.js +31 -0
  24. package/dist/status.js +141 -0
  25. package/dist/types.js +1 -0
  26. package/dist/usage.js +95 -0
  27. package/dist/version.js +5 -0
  28. package/dist/web/app.js +155 -0
  29. package/dist/web/security.js +116 -0
  30. package/docs/assets/brand/FONT-LICENSE.txt +93 -0
  31. package/docs/assets/brand/README.md +18 -0
  32. package/docs/assets/brand/icon-128.png +0 -0
  33. package/docs/assets/brand/icon-32.png +0 -0
  34. package/docs/assets/brand/icon-512.png +0 -0
  35. package/docs/assets/brand/icon-dark-128.png +0 -0
  36. package/docs/assets/brand/icon-dark-512.png +0 -0
  37. package/docs/assets/brand/icon-dark.svg +1 -0
  38. package/docs/assets/brand/icon-light-128.png +0 -0
  39. package/docs/assets/brand/icon-light-512.png +0 -0
  40. package/docs/assets/brand/icon-light.svg +1 -0
  41. package/docs/assets/brand/icon-mono.svg +1 -0
  42. package/docs/assets/brand/icon.svg +1 -0
  43. package/docs/assets/brand/preview.png +0 -0
  44. package/docs/assets/brand/preview.svg +1 -0
  45. package/docs/assets/brand/wordmark-dark.svg +1 -0
  46. package/docs/assets/brand/wordmark-light.svg +1 -0
  47. package/docs/assets/dashboard-dark.png +0 -0
  48. package/docs/assets/dashboard-light.png +0 -0
  49. package/docs/assets/dashboard.png +0 -0
  50. package/docs/assets/mcp-tools-dark.png +0 -0
  51. package/docs/assets/mcp-tools-light.png +0 -0
  52. package/docs/assets/mcp-tools.png +0 -0
  53. package/docs/assets/search-rotation.svg +52 -0
  54. package/docs/clients.md +93 -0
  55. package/docs/operations.md +66 -0
  56. package/docs/provider-access.md +38 -0
  57. package/docs/release-notes.md +52 -0
  58. package/package.json +65 -0
  59. package/static/app.js +1126 -0
  60. package/static/engine-logos/README.md +11 -0
  61. package/static/engine-logos/duckduckgo.png +0 -0
  62. package/static/engine-logos/exa.png +0 -0
  63. package/static/engine-logos/firecrawl.png +0 -0
  64. package/static/engine-logos/google-cse.png +0 -0
  65. package/static/engine-logos/jina.png +0 -0
  66. package/static/engine-logos/parallel.png +0 -0
  67. package/static/engine-logos/tavily.png +0 -0
  68. package/static/i18n.js +709 -0
  69. package/static/index.html +95 -0
  70. package/static/style.css +1258 -0
package/static/app.js ADDED
@@ -0,0 +1,1126 @@
1
+ "use strict";
2
+
3
+ /* search-rotation Konsole — SPA mit Hash-Routing.
4
+ Views: Übersicht (#/), Engines (#/engines), Verlauf (#/history), MCP Tools (#/tools) */
5
+
6
+ const $ = (s) => document.querySelector(s);
7
+
8
+ const state = {
9
+ meta: null,
10
+ config: null,
11
+ status: [],
12
+ history: [],
13
+ route: "/",
14
+ openEngines: new Set(), // aufgeklappte Engine-Drawer
15
+ historyOpen: new Set(), // aufgeklappte Verlaufseinträge
16
+ filters: { kind: "all", engine: "", q: "" },
17
+ livePaused: false,
18
+ dragFrom: null,
19
+ unauthorized: false,
20
+ };
21
+
22
+ /* ---------- i18n ---------- */
23
+
24
+ const I18N = window.I18N;
25
+ let lang = localStorage.getItem("sr_lang") || "en";
26
+ if (!I18N[lang]) lang = "en";
27
+
28
+ function t(key, vars) {
29
+ let s = (I18N[lang] && I18N[lang][key]) || I18N.de[key] || key;
30
+ if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll("{" + k + "}", String(v));
31
+ return s;
32
+ }
33
+
34
+ function applyI18n() {
35
+ document.documentElement.lang = lang;
36
+ document.querySelectorAll("[data-i18n]").forEach((el) => (el.textContent = t(el.dataset.i18n)));
37
+ document.querySelectorAll("[data-i18n-title]").forEach((el) => (el.title = t(el.dataset.i18nTitle)));
38
+ $("#lang-code").textContent = lang.toUpperCase();
39
+ $("#theme-btn").title = t(document.documentElement.dataset.theme === "dark" ? "theme.toLight" : "theme.toDark");
40
+ document.querySelectorAll("#lang-menu [data-lang]").forEach((b) => b.classList.toggle("active", b.dataset.lang === lang));
41
+ }
42
+
43
+ /* ---------- Theme ---------- */
44
+
45
+ function initTheme() {
46
+ const saved = localStorage.getItem("sr_theme");
47
+ const theme = saved || (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
48
+ setTheme(theme, false);
49
+ }
50
+
51
+ function setTheme(theme, persist = true) {
52
+ document.documentElement.dataset.theme = theme;
53
+ $("#theme-icon").innerHTML = '<use href="#i-' + (theme === "dark" ? "sun" : "moon") + '"/>';
54
+ $("#theme-btn").title = t(theme === "dark" ? "theme.toLight" : "theme.toDark");
55
+ document.querySelector('meta[name="theme-color"]').setAttribute("content", theme === "dark" ? "#0a0c12" : "#f4f5fa");
56
+ if (persist) localStorage.setItem("sr_theme", theme);
57
+ }
58
+
59
+ /* ---------- Helfer ---------- */
60
+
61
+ function esc(s) {
62
+ const d = document.createElement("div");
63
+ d.textContent = String(s ?? "");
64
+ return d.innerHTML.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
65
+ }
66
+
67
+ const rtf = { format: (value, unit) => new Intl.RelativeTimeFormat(lang, { numeric: "auto" }).format(value, unit) };
68
+
69
+ function relTime(ts) {
70
+ const s = Math.round((Date.now() - new Date(ts).getTime()) / 1000);
71
+ if (s < 45) return rtf.format(-s, "second");
72
+ const m = Math.round(s / 60);
73
+ if (m < 45) return rtf.format(-m, "minute");
74
+ const h = Math.round(m / 60);
75
+ if (h < 22) return rtf.format(-h, "hour");
76
+ return rtf.format(-Math.round(h / 24), "day");
77
+ }
78
+
79
+ function fmtMs(ms) {
80
+ return ms >= 1000 ? (ms / 1000).toLocaleString(lang, { maximumFractionDigits: 1 }) + " s" : Math.round(ms) + " ms";
81
+ }
82
+
83
+ const ENGINE_HUES = { tavily: 174, exa: 262, firecrawl: 24, "google-cse": 217, jina: 199, parallel: 145, duckduckgo: 14 };
84
+
85
+ function engineHue(id) {
86
+ if (ENGINE_HUES[id] !== undefined) return ENGINE_HUES[id];
87
+ let h = 0;
88
+ for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) % 360;
89
+ return h;
90
+ }
91
+
92
+ function avatarStyle(id) {
93
+ const h = engineHue(id);
94
+ return "background:linear-gradient(135deg,hsl(" + h + " 62% 52%),hsl(" + ((h + 28) % 360) + " 68% 40%))";
95
+ }
96
+
97
+ /** Bundled provider favicon; the delegated error handler retains the letter fallback. */
98
+ function engineLogoHtml(e) {
99
+ const letter = esc((e.label || e.id)[0]);
100
+ if (!Object.hasOwn(ENGINE_HUES, e.id)) return '<span class="avatar" style="' + avatarStyle(e.id) + '\">' + letter + "</span>";
101
+ return '<span class="logo-wrap"><img class="logo" src="/engine-logos/' + encodeURIComponent(e.id) +
102
+ '.png" alt="" loading="lazy" data-fallback="' + letter + '" data-hue="' + engineHue(e.id) + '\"></span>';
103
+ }
104
+
105
+ function pctClass(pct) {
106
+ return pct > 40 ? "ok" : pct > 10 ? "warn" : "crit";
107
+ }
108
+
109
+ function bar(pct) {
110
+ return '<span class="bar" role="img" aria-label="' + esc(t("quota.free", { pct })) + '"><span class="fill ' + pctClass(pct) + '" style="width:' + Math.max(0, Math.min(100, pct)) + '%"></span></span>';
111
+ }
112
+
113
+ function usageSummary(st) {
114
+ const search = st.used?.search || 0, fetch = st.used?.fetch || 0, errors = st.used?.errors || 0;
115
+ return { total: search + fetch + errors, search, fetch, errors };
116
+ }
117
+
118
+ function usageHtml(st) {
119
+ const counts = usageSummary(st);
120
+ return '<span class="usage-summary" title="' + esc(t("usage.help")) + '"><strong>' +
121
+ esc(t(counts.total === 1 ? "usage.totalOne" : "usage.total", { n: counts.total })) + '</strong><span class="usage-detail">' +
122
+ esc(t("usage.detail", counts)) + '</span></span>';
123
+ }
124
+
125
+ function quotaDescription(st) {
126
+ const q = st.quota;
127
+ if (!q || !(q.limit > 0) || !Number.isFinite(q.used)) return t("quota.unknown");
128
+ const format = n => n.toLocaleString(lang, { maximumFractionDigits: 2 });
129
+ return t("quota.remaining", { remaining: format(Math.max(0, q.limit - q.used)), limit: format(q.limit), unit: t("quota.unit." + q.unit) }) +
130
+ " · " + t(q.source === "remote" ? "quota.providerBalance" : "quota.period." + q.period) +
131
+ (q.timeZone ? " · " + q.timeZone : "") + " · " + t("quota.source." + q.source) + (q.estimated ? " · " + t("quota.estimated") : "");
132
+ }
133
+
134
+ function budgetHtml(st) {
135
+ const pct = remainingPctOf(st);
136
+ const help = quotaDescription(st) + ". " + t(st.quota?.source === "remote" ? "quota.remoteHelp" : "quota.localHelp");
137
+ return '<span class="budget-summary" title="' + esc(help) + '">' +
138
+ (pct === null ? '' : bar(pct)) + '<span class="quota-label">' + esc(quotaDescription(st)) + '</span></span>' +
139
+ (st.remoteError ? '<span class="quota-err" title="' + esc(st.remoteError) + '">' + esc(t("quota.fetchError")) + '</span>' : '');
140
+ }
141
+
142
+ function quotaHtml(st) {
143
+ return st?.id ? usageHtml(st) + budgetHtml(st) : '';
144
+ }
145
+
146
+ function ringGauge(pct) {
147
+ const r = 21, c = 2 * Math.PI * r;
148
+ const label = pct === null ? t("quota.unknown") : t("quota.free", { pct });
149
+ const off = c * (1 - (pct ?? 0) / 100);
150
+ return '<span class="gauge" role="img" title="' + esc(label) + '" aria-label="' + esc(label) + '"><svg aria-hidden="true" width="52" height="52" viewBox="0 0 52 52">' +
151
+ '<circle class="track" cx="26" cy="26" r="' + r + '" fill="none" stroke-width="5"/>' +
152
+ (pct === null ? '' : '<circle class="val ' + pctClass(pct) + '" cx="26" cy="26" r="' + r + '" fill="none" stroke-width="5" stroke-dasharray="' + c.toFixed(1) + '" stroke-dashoffset="' + off.toFixed(1) + '"/>') +
153
+ '</svg><span class="gauge-val' + (pct === null ? ' inf' : '') + '">' + (pct === null ? '?' : pct + '%') + '</span></span>';
154
+ }
155
+
156
+ /* ---------- Token & API ---------- */
157
+
158
+ async function api(path, opts = {}) {
159
+ const res = await fetch(path, {
160
+ method: opts.method || "GET",
161
+ headers: { "content-type": "application/json" },
162
+ credentials: "same-origin",
163
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
164
+ });
165
+ if (res.status === 401) {
166
+ state.unauthorized = true;
167
+ $("#view").innerHTML = '<div class="auth-error"><div><p style="font-size:34px;margin:0 0 10px">🔒</p>' + t("api.unauthorized") + '<p><a href="/login">Anmelden / Sign in</a></p></div></div>';
168
+ throw new Error("unauthorized");
169
+ }
170
+ const data = await res.json().catch(() => ({}));
171
+ if (!res.ok) throw new Error(data.error || res.statusText);
172
+ return data;
173
+ }
174
+
175
+ async function refreshStatus() {
176
+ const s = await api("/api/status");
177
+ state.status = s.engines || [];
178
+ if (state.meta) state.meta.month = s.month;
179
+ }
180
+
181
+ async function loadHistory() {
182
+ const data = await api("/api/history?limit=200");
183
+ state.history = data.entries || [];
184
+ }
185
+
186
+ async function load() {
187
+ state.meta = await api("/api/meta");
188
+ state.config = await api("/api/config");
189
+ await refreshStatus();
190
+ document.body.dataset.booted = "1";
191
+ $("#version").textContent = "v" + state.meta.version;
192
+ $("#configpath").textContent = state.meta.configPath;
193
+ $("#configpath").title = state.meta.configPath;
194
+ render();
195
+ loadHistory().then(renderIfDataView).catch(() => {});
196
+ }
197
+
198
+ function renderIfDataView() {
199
+ if (state.route === "/history" || state.route === "/") render();
200
+ }
201
+
202
+ /* ---------- Toasts ---------- */
203
+
204
+ function toast(kind, msg) {
205
+ const wrap = $("#toasts");
206
+ const el = document.createElement("div");
207
+ el.className = "toast " + kind;
208
+ el.innerHTML =
209
+ '<svg width="16" height="16"><use href="#i-' +
210
+ (kind === "ok" ? "check" : kind === "error" ? "x" : "zap") +
211
+ '"/></svg><span>' + esc(msg) + "</span>";
212
+ wrap.appendChild(el);
213
+ setTimeout(() => {
214
+ el.classList.add("leaving");
215
+ setTimeout(() => el.remove(), 260);
216
+ }, 3200);
217
+ }
218
+
219
+ /* ---------- Status-Zugriff ---------- */
220
+
221
+ function statusOf(id) {
222
+ return state.status.find((e) => e.id === id) || {};
223
+ }
224
+
225
+ function sortedEngines() {
226
+ return [...state.status].sort((a, b) => a.searchPosition - b.searchPosition);
227
+ }
228
+
229
+ function anyKeySet() {
230
+ return state.status.some((e) => e.hasKey);
231
+ }
232
+
233
+ /* ---------- Konfig-PUTs (serialisiert) ---------- */
234
+
235
+ let putChain = Promise.resolve();
236
+
237
+ function queuePut(bodyBuilder) {
238
+ putChain = putChain
239
+ .then(() => api("/api/config", { method: "PUT", body: bodyBuilder(state.config) }))
240
+ .then(async (r) => {
241
+ state.config = r.config;
242
+ try {
243
+ await refreshStatus();
244
+ } catch {}
245
+ render();
246
+ toast("ok", t("toast.saved"));
247
+ })
248
+ .catch((err) => {
249
+ if (err.message !== "unauthorized") toast("error", t("toast.error", { msg: err.message }));
250
+ });
251
+ return putChain;
252
+ }
253
+
254
+ /* ---------- Router ---------- */
255
+
256
+ function parseHash() {
257
+ const h = location.hash.replace(/^#/, "");
258
+ return ["/", "/engines", "/history", "/tools"].includes(h) ? h : "/";
259
+ }
260
+
261
+ function setRoute(route) {
262
+ state.route = route;
263
+ document.querySelectorAll(".nav-tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.route === route));
264
+ positionNavInd();
265
+ render();
266
+ window.scrollTo({ top: 0 });
267
+ }
268
+
269
+ function positionNavInd() {
270
+ const active = $(".nav-tab.active");
271
+ const ind = $("#nav-ind");
272
+ if (!active || !ind) return;
273
+ ind.style.width = active.offsetWidth + "px";
274
+ ind.style.transform = "translateX(" + active.offsetLeft + "px)";
275
+ }
276
+
277
+ function render() {
278
+ if (state.unauthorized) return;
279
+ applyI18n();
280
+ positionNavInd();
281
+ if (!state.config) return renderSkeleton();
282
+ if (state.route === "/engines") renderEngines();
283
+ else if (state.route === "/history") renderHistory();
284
+ else if (state.route === "/tools") renderTools();
285
+ else renderOverview();
286
+ }
287
+
288
+ function renderSkeleton() {
289
+ $("#view").innerHTML =
290
+ '<div class="page">' +
291
+ '<div class="skeleton" style="height:52px;margin-bottom:16px"></div>' +
292
+ '<div class="overview-grid">' + '<div class="skeleton" style="height:110px"></div>'.repeat(4) + "</div>" +
293
+ '<div class="skeleton" style="height:180px"></div>' +
294
+ "</div>";
295
+ }
296
+
297
+ /* ---------- View: Übersicht ---------- */
298
+
299
+ function statValues() {
300
+ const engines = state.status;
301
+ const searches = engines.reduce((n, e) => n + (e.used?.search || 0), 0);
302
+ const fetches = engines.reduce((n, e) => n + (e.used?.fetch || 0), 0);
303
+ const h = state.history;
304
+ const fails = h.filter((e) => !e.ok).length;
305
+ const errRate = h.length ? Math.round((100 * fails) / h.length) : 0;
306
+ const okDurs = h.filter((e) => e.ok).map((e) => e.ms);
307
+ const avg = okDurs.length ? okDurs.reduce((a, b) => a + b, 0) / okDurs.length : 0;
308
+ return { searches, fetches, errRate: h.length ? errRate + "%" : "–", avg: okDurs.length ? fmtMs(avg) : "–" };
309
+ }
310
+
311
+ function rotationHtml() {
312
+ const enabled = sortedEngines().filter((e) => e.enabled && e.capabilities?.includes("search"));
313
+ if (!enabled.length) return '<div class="empty" style="padding:22px"><span class="empty-title">' + esc(t("rotation.empty")) + '</span></div>';
314
+ return enabled.map((e, i) => '<a class="rot-row" href="#/engines">' +
315
+ '<span class="rot-pos">' + (i + 1) + '</span>' + engineLogoHtml(e) +
316
+ '<span class="rot-content"><span class="rot-name">' + esc(e.label || e.id) + '</span>' + usageHtml(e) + budgetHtml(e) + '</span>' +
317
+ '<svg class="rot-arrow" width="15" height="15"><use href="#i-arrow"/></svg></a>').join('');
318
+ }
319
+
320
+ function healthHtml() {
321
+ return sortedEngines().map(e => '<a class="health-card' + (e.enabled ? '' : ' off') + '" href="#/engines">' +
322
+ ringGauge(remainingPctOf(e)) + '<span class="health-meta"><span class="health-name">' + esc(e.label || e.id) +
323
+ (!e.enabled ? ' <span class="badge">' + esc(t("health.off")) + '</span>' : '') + '</span>' +
324
+ usageHtml(e) + '<span class="health-src" title="' + esc(t(e.quota?.source === 'remote' ? 'quota.remoteHelp' : 'quota.localHelp')) + '">' + esc(quotaDescription(e)) + '</span>' +
325
+ (e.remoteError ? '<span class="quota-err" title="' + esc(e.remoteError) + '">' + esc(t("quota.fetchError")) + '</span>' : '') +
326
+ '</span></a>').join('');
327
+ }
328
+
329
+ function recentHtml() {
330
+ const h = state.history;
331
+ if (!h.length) return '<div class="empty" style="padding:20px"><span class="muted">' + esc(t("recent.empty")) + "</span></div>";
332
+ return h
333
+ .slice(0, 6)
334
+ .map(
335
+ (e) =>
336
+ '<a class="recent-row" href="#/history" title="' + esc(e.input + ' · ' + new Date(e.ts).toLocaleString(lang) + ' · ' + fmtMs(e.ms)) + '">' +
337
+ '<span class="hkind-icon ' + esc(e.kind) + '" style="width:24px;height:24px"><svg width="13" height="13"><use href="#i-' + (e.kind === "search" ? "search" : "link") + '"/></svg></span>' +
338
+ '<span class="ri">' + esc(e.input) + "</span>" +
339
+ (e.ok ? "" : '<svg width="13" height="13" style="color:var(--crit)"><use href="#i-x"/></svg>') +
340
+ '<span class="recent-engine">' + esc(e.engine ? statusOf(e.engine).label || e.engine : t('hist.failed')) + '</span>' +
341
+ '<span class="rt">' + relTime(e.ts) + "</span></a>",
342
+ )
343
+ .join("");
344
+ }
345
+
346
+ function renderOverview() {
347
+ const v = statValues();
348
+ $("#view").innerHTML =
349
+ '<div class="page">' +
350
+ pageHead(t("overview.title"), t("overview.sub"), state.meta.month) +
351
+ (anyKeySet() ? "" : '<div class="banner"><svg width="16" height="16"><use href="#i-alert"/></svg><span>' + esc(t("notice.nokeys")) + "</span></div>") +
352
+ '<div class="overview-grid">' +
353
+ statCard("searches", "search", t("stat.searches"), t("stat.searchesSub"), v.searches) +
354
+ statCard("fetches", "link", t("stat.fetches"), t("stat.fetchesSub"), v.fetches) +
355
+ statCard("errors", "alert", t("stat.errors"), t("stat.errorsSub"), v.errRate) +
356
+ statCard("duration", "zap", t("stat.duration"), t("stat.durationSub"), v.avg) +
357
+ "</div>" +
358
+ '<div class="two-col">' +
359
+ '<div class="panel"><div class="panel-head"><h2>' + esc(t("rotation.title")) + '</h2><span class="sub">' + esc(t("rotation.sub")) + '</span><span class="spacer"><a class="btn btn-ghost" href="#/engines">' + esc(t("rotation.manage")) + ' <svg width="13" height="13"><use href="#i-arrow"/></svg></a></span></div><div class="rot-list">' + rotationHtml() + "</div></div>" +
360
+ '<div class="panel"><div class="panel-head"><h2>' + esc(t("health.title")) + '</h2><span class="sub">' + esc(t("health.sub")) + "</span></div>" + '<div class="health-grid">' + healthHtml() + "</div></div>" +
361
+ "</div>" +
362
+ '<div class="panel"><div class="panel-head"><h2>' + esc(t("recent.title")) + '</h2><span class="spacer"><a class="btn btn-ghost" href="#/history">' + esc(t("recent.all")) + ' <svg width="13" height="13"><use href="#i-arrow"/></svg></a></span></div>' +
363
+ '<div class="sparkline">' + sparkline(state.history) + "</div>" +
364
+ '<div class="recent-list">' + recentHtml() + "</div></div>" +
365
+ '<details class="panel setup-panel" open><summary>' + esc(t("setup.summary")) + '<svg class="chev" width="16" height="16"><use href="#i-chevron"/></svg></summary>' +
366
+ '<div class="snippets">' +
367
+ '<div class="snippet"><h3>' + esc(t("snippet.codex")) + '</h3><pre id="snip-codex"></pre><button class="btn" data-copy="snip-codex">' + esc(t("btn.copy")) + "</button></div>" +
368
+ '<div class="snippet"><h3>' + esc(t("snippet.claude")) + '</h3><pre id="snip-claude"></pre><button class="btn" data-copy="snip-claude">' + esc(t("btn.copy")) + "</button></div>" +
369
+ '<div class="snippet"><h3>' + esc(t("snippet.remote")) + '</h3><pre id="snip-remote"></pre><button class="btn" data-copy="snip-remote">' + esc(t("btn.copy")) + "</button></div>" +
370
+ "</div></details>" +
371
+ "</div>";
372
+
373
+ renderSnippets();
374
+ }
375
+
376
+ /** Patcht nur die dynamischen Bereiche der Übersicht — kein Voll-Render, kein Flackern. */
377
+ function updateOverview() {
378
+ const root = $("#view");
379
+ if (!root.querySelector(".rot-list")) return;
380
+ const month = root.querySelector(".page-head .badge");
381
+ if (month) month.textContent = state.meta.month;
382
+ const v = statValues();
383
+ const set = (id, val) => {
384
+ const el = root.querySelector('[data-stat="' + id + '"]');
385
+ if (el && el.textContent !== String(val)) el.textContent = String(val);
386
+ };
387
+ set("searches", v.searches);
388
+ set("fetches", v.fetches);
389
+ set("errors", v.errRate);
390
+ set("duration", v.avg);
391
+ const rot = root.querySelector(".rot-list");
392
+ if (rot) rot.innerHTML = rotationHtml();
393
+ const health = root.querySelector(".health-grid");
394
+ if (health) health.innerHTML = healthHtml();
395
+ const spark = root.querySelector(".sparkline");
396
+ if (spark && !spark.contains(document.activeElement) && !spark.matches(":hover")) spark.innerHTML = sparkline(state.history);
397
+ const recent = root.querySelector(".recent-list");
398
+ if (recent) recent.innerHTML = recentHtml();
399
+ }
400
+
401
+ function pageHead(title, sub, month) {
402
+ return (
403
+ '<div class="page-head"><h1>' + esc(title) + (month ? ' <span class="badge">' + esc(month) + "</span>" : "") + "</h1>" +
404
+ '<div class="sub">' + esc(sub) + "</div></div>"
405
+ );
406
+ }
407
+
408
+ function statCard(stat, icon, label, sub, value) {
409
+ return (
410
+ '<div class="panel stat-card" tabindex="0" title="' + esc(t('stat.' + stat + 'Help')) + '"><span class="stat-icon"><svg width="16" height="16"><use href="#i-' + icon + '"/></svg></span>' +
411
+ '<span class="stat-value" data-stat="' + stat + '">' + esc(value) + '</span><span class="stat-label">' + esc(label) + '</span><span class="stat-sub">' + esc(sub) + "</span></div>"
412
+ );
413
+ }
414
+
415
+ function remainingPctOf(st) {
416
+ const q = st.quota;
417
+ return q?.limit > 0 && Number.isFinite(q.used)
418
+ ? Math.max(0, Math.min(100, Math.round(100 * (q.limit - q.used) / q.limit))) : null;
419
+ }
420
+
421
+ /** Rolling 48-hour window, based on at most 200 retained tool calls. */
422
+ function activityBuckets(entries, now = Date.now()) {
423
+ const bucketMs = 2 * 3600 * 1000;
424
+ const buckets = Array.from({ length: 24 }, (_, i) => ({
425
+ start: now - (24 - i) * bucketMs, end: now - (23 - i) * bucketMs,
426
+ total: 0, search: 0, fetch: 0, errors: 0, attempts: 0, engines: {},
427
+ }));
428
+ for (const e of entries) {
429
+ const age = now - new Date(e.ts).getTime();
430
+ if (!Number.isFinite(age) || age < 0 || age >= 24 * bucketMs) continue;
431
+ const bucket = buckets[23 - Math.floor(age / bucketMs)];
432
+ bucket.total++;
433
+ if (e.kind === 'search') bucket.search++;
434
+ if (e.kind === 'fetch') bucket.fetch++;
435
+ if (!e.ok) bucket.errors++;
436
+ const attempts = e.attempts ?? (e.engine ? [{ engine: e.engine }] : []);
437
+ for (const attempt of attempts) {
438
+ bucket.attempts++;
439
+ bucket.engines[attempt.engine] = (bucket.engines[attempt.engine] || 0) + 1;
440
+ }
441
+ }
442
+ return buckets;
443
+ }
444
+
445
+ function sparkline(entries) {
446
+ const buckets = activityBuckets(entries);
447
+ const max = Math.max(...buckets.map(b => b.total), 1);
448
+ const format = ts => new Date(ts).toLocaleString(lang, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
449
+ const bars = buckets.map(b => {
450
+ const providers = Object.entries(b.engines).map(([id, n]) => (statusOf(id).label || id) + ': ' + n).join(' · ');
451
+ const tip = format(b.start) + ' – ' + format(b.end) + '\n' + t('activity.bucket', b) +
452
+ (providers ? '\n' + t('activity.providers', { n: b.attempts }) + ': ' + providers : '');
453
+ return '<button type="button" class="activity-bucket" aria-label="' + esc(tip) + '">' +
454
+ '<span class="activity-fill" style="height:' + (b.total ? Math.max(5, b.total / max * 100) : 0) + '%"></span>' +
455
+ '<span class="activity-tooltip" aria-hidden="true">' + esc(tip) + '</span></button>';
456
+ }).join('');
457
+ return '<div class="activity-caption">' + esc(t('activity.caption')) + '</div>' +
458
+ '<div class="activity-bars" role="group" aria-label="' + esc(t('activity.caption')) + '">' + bars + '</div>' +
459
+ '<div class="activity-axis"><span>' + esc(t('activity.start')) + '</span><span>' + esc(t('activity.end')) + '</span></div>' +
460
+ '<p class="activity-note">' + esc(t('activity.sample', { n: entries.length })) + '</p>';
461
+ }
462
+
463
+ function renderSnippets() {
464
+ const remoteUrl = location.origin + "/mcp";
465
+ const packageRef = "github:localfoundry/search-rotation-mcp#v" + state.meta.version;
466
+ const args = ["-y", "--allow-git=all", packageRef];
467
+ const codex = $("#snip-codex");
468
+ if (!codex) return;
469
+ codex.textContent = '[mcp_servers.search-rotation]\ncommand = "npx"\nargs = ' + JSON.stringify(args);
470
+ $("#snip-claude").textContent = JSON.stringify(
471
+ { mcpServers: { "search-rotation": { command: "npx", args } } },
472
+ null,
473
+ 2,
474
+ );
475
+ $("#snip-remote").textContent = "URL: " + remoteUrl + "\n" + "Authorization: Bearer <token>";
476
+ }
477
+
478
+ /* ---------- View: MCP tools ---------- */
479
+
480
+ function renderTools() {
481
+ const tools = [
482
+ { name: "web_search", icon: "search", params: [["query", "query"], ["numResults", "count"], ["engine", "engine"], ["timeRange", "timeRange"], ["startDate", "startDate"], ["endDate", "endDate"]], args: { query: "latest AI research", numResults: 5, timeRange: "week" } },
483
+ { name: "fetch_url", icon: "link", params: [["url", "url"]], args: { url: "https://example.com" } },
484
+ { name: "engine_status", icon: "gauge", params: [], args: {} },
485
+ { name: "open_dashboard", icon: "stack", params: [], args: {} },
486
+ ];
487
+ $("#view").innerHTML = '<div class="page">' + pageHead(t("tools.title"), t("tools.sub")) +
488
+ '<p class="tools-intro">' + esc(t("tools.intro")) + '</p><div class="tools-grid">' + tools.map(tool =>
489
+ '<article class="panel tool-card"><div class="panel-head"><svg width="20" height="20" aria-hidden="true"><use href="#i-' + tool.icon + '"/></svg><h2><code>' + tool.name + '</code></h2></div>' +
490
+ '<div class="tool-body"><p>' + esc(t("tools." + tool.name)) + '</p><h3>' + esc(t("tools.parameters")) + '</h3>' +
491
+ (tool.params.length ? '<dl class="tool-params">' + tool.params.map(([name, key]) => '<dt><code>' + name + '</code></dt><dd>' + esc(t("tools.param." + key)) + '</dd>').join('') + '</dl>' : '<p class="muted">' + esc(t("tools.none")) + '</p>') +
492
+ '<div class="tool-example"><h3>' + esc(t("tools.example")) + '</h3><pre id="tool-example-' + tool.name + '">' + esc(JSON.stringify({ name: tool.name, arguments: tool.args }, null, 2)) + '</pre>' +
493
+ '<button class="btn" data-copy="tool-example-' + tool.name + '" aria-label="' + esc(t("tools.copy", { name: tool.name })) + '">' + esc(t("btn.copy")) + '</button></div></div></article>'
494
+ ).join('') + '</div></div>';
495
+ }
496
+
497
+ /* ---------- View: Engines ---------- */
498
+
499
+ function settingsHtml() {
500
+ const settings = state.config.settings;
501
+ return '<div class="panel settings-panel"><h2>' + esc(t("settings.title")) + '</h2>' +
502
+ '<label class="settings-check"><input id="strict-free" type="checkbox"' + (settings.strictFreeMode ? " checked" : "") + '> ' + esc(t("settings.strict")) + '</label>' +
503
+ '<p class="muted">' + esc(t("settings.strictHelp")) + '</p>' +
504
+ '<label for="request-timeout">' + esc(t("settings.timeout")) + '</label> ' +
505
+ '<input id="request-timeout" type="number" min="1000" max="300000" step="1000" value="' + (settings.requestTimeoutMs ?? 60000) + '">' +
506
+ '<div class="result-settings"><label for="result-count-mode">' + esc(t("settings.results")) + '</label> ' +
507
+ '<select id="result-count-mode"><option value="custom"' + (settings.defaultNumResults !== null ? ' selected' : '') + '>' + esc(t("settings.resultsCustom")) + '</option><option value="provider"' + (settings.defaultNumResults === null ? ' selected' : '') + '>' + esc(t("settings.resultsProvider")) + '</option></select> ' +
508
+ '<input id="result-count" aria-label="' + esc(t("settings.resultsCount")) + '" type="number" required min="1" max="20" step="1" value="' + (settings.defaultNumResults ?? 8) + '"' + (settings.defaultNumResults === null ? ' disabled hidden' : '') + '>' +
509
+ '<p class="muted">' + esc(t("settings.resultsHelp")) + '</p></div>' +
510
+ '<button class="btn" data-act="save-settings">' + esc(t("btn.save")) + '</button></div>';
511
+ }
512
+
513
+ function renderEngines() {
514
+ const wrap = $("#view");
515
+ // Ungespeicherte Eingaben über das Re-Render retten
516
+ const saved = {};
517
+ const strictDraft = wrap.querySelector("#strict-free")?.checked;
518
+ const timeoutDraft = wrap.querySelector("#request-timeout")?.value;
519
+ const resultsModeDraft = wrap.querySelector("#result-count-mode")?.value;
520
+ const resultsCountDraft = wrap.querySelector("#result-count")?.value;
521
+ wrap.querySelectorAll(".keyinput").forEach((el) => {
522
+ if (el.value) saved["key:" + el.dataset.id] = el.value;
523
+ });
524
+ wrap.querySelectorAll(".extrainput").forEach((el) => {
525
+ if (el.value) saved["extra:" + el.dataset.id + ":" + el.dataset.extra] = el.value;
526
+ });
527
+
528
+ const engines = sortedEngines();
529
+ const inRotation = engines.filter((e) => e.enabled).length;
530
+ const keys = engines.filter((e) => e.hasKey).length;
531
+
532
+ const cards = engines
533
+ .map((e) => {
534
+ const open = state.openEngines.has(e.id);
535
+ const caps = (e.capabilities || []).map((c) => '<span class="badge">' + esc(t("cap." + c)) + "</span>").join("");
536
+ const extras = (e.extraFields || [])
537
+ .map(
538
+ (f) =>
539
+ '<div class="drawer-row"><input type="text" class="extrainput" data-id="' + esc(e.id) + '" data-extra="' + esc(f.key) +
540
+ '" placeholder="' + esc(f.label) + (e.extrasSet && e.extrasSet[f.key] ? " · " + esc(t("extra.set")) : "") + '">' +
541
+ '<button class="btn" data-act="save-extra">' + esc(t("btn.save")) + "</button></div>",
542
+ )
543
+ .join("");
544
+ return (
545
+ '<div class="engine-card' + (e.enabled ? "" : " off") + (open ? " open" : "") + '" data-id="' + esc(e.id) + '">' +
546
+ '<div class="engine-head">' +
547
+ '<span class="handle" draggable="true" title="Drag & Drop"><svg width="16" height="16"><use href="#i-grip"/></svg></span>' +
548
+ '<span class="pos-badge">' + (e.enabled ? e.searchPosition + 1 : "–") + "</span>" +
549
+ engineLogoHtml(e) +
550
+ '<span class="engine-title"><span class="engine-name">' + esc(e.label || e.id) + "</span>" +
551
+ '<span class="engine-badges">' +
552
+ (!e.hasKey && e.keyless === "ip" ? '<span class="badge ip">' + esc(t("badge.keyless")) + "</span>" : "") +
553
+ (e.quotaEndpoint ? '<span class="badge quota">' + esc(t("badge.quota")) + "</span>" : "") +
554
+ "</span></span>" +
555
+ '<label class="switch"><input type="checkbox" aria-label="' + esc(e.label || e.id) + '" data-act="toggle"' + (e.enabled ? " checked" : "") + '><span class="track-el"></span></label>' +
556
+ '<span class="engine-quota" data-quota="' + esc(e.id) + '">' + quotaHtml(e) + "</span>" +
557
+ '<button class="drawer-toggle" data-act="drawer" aria-expanded="' + open + '" title="' + esc(open ? t("engines.collapse") : t("engines.config")) + '"><svg width="17" height="17"><use href="#i-chevron"/></svg></button>' +
558
+ "</div>" +
559
+ '<div class="drawer">' +
560
+ '<div class="drawer-row">' +
561
+ '<input type="password" class="keyinput" aria-label="' + esc((e.label || e.id) + ": " + t("key.set")) + '" data-id="' + esc(e.id) + '" placeholder="' +
562
+ (e.hasKey ? esc(t("key.replace", { masked: e.keyMasked })) : esc(t("key.set"))) + '">' +
563
+ '<button class="btn btn-primary" data-act="save-key">' + esc(t("btn.save")) + "</button>" +
564
+ (e.hasKey ? '<button class="btn btn-danger" data-act="clear-key">' + esc(t("key.clear")) + "</button>" : "") +
565
+ "</div>" +
566
+ extras +
567
+ '<div class="drawer-meta"><span class="caps-chips">' + caps + "</span>" +
568
+ '<a class="signup" href="' + esc(e.signupUrl || e.homepage || "#") + '" target="_blank" rel="noreferrer">' + esc(t("signup")) + ' <svg width="12" height="12"><use href="#i-external"/></svg></a>' +
569
+ (e.capabilities || []).map((kind) => '<button class="btn" data-act="test" data-kind="' + kind + '"><svg width="13" height="13"><use href="#i-zap"/></svg> Test: ' + esc(t("cap." + kind)) + "</button>").join("") + "</div>" +
570
+ '<div class="testout" data-testout="' + esc(e.id) + '"></div>' +
571
+ (e.notes ? '<div class="notes">' + esc(e.notes) + "</div>" : "") +
572
+ "</div></div>"
573
+ );
574
+ })
575
+ .join("");
576
+
577
+ wrap.innerHTML =
578
+ '<div class="page">' +
579
+ pageHead(t("engines.title"), t("engines.sub")) +
580
+ settingsHtml() +
581
+ (anyKeySet() ? "" : '<div class="banner"><svg width="16" height="16"><use href="#i-alert"/></svg><span>' + esc(t("notice.nokeys")) + "</span></div>") +
582
+ '<div style="margin-bottom:14px"><span class="engines-stat"><span class="dot"></span>' +
583
+ esc(t("engines.stat", { in: inRotation, keys, total: engines.length })) +
584
+ "</span></div>" +
585
+ '<div class="engine-list">' + cards + "</div></div>";
586
+
587
+ // Gesicherte Eingaben zurückschreiben
588
+ if (strictDraft !== undefined) wrap.querySelector("#strict-free").checked = strictDraft;
589
+ if (timeoutDraft !== undefined) wrap.querySelector("#request-timeout").value = timeoutDraft;
590
+ if (resultsModeDraft !== undefined) {
591
+ wrap.querySelector("#result-count-mode").value = resultsModeDraft;
592
+ const field = wrap.querySelector("#result-count");
593
+ field.value = resultsCountDraft;
594
+ field.disabled = field.hidden = resultsModeDraft === "provider";
595
+ }
596
+ wrap.querySelectorAll(".keyinput").forEach((el) => {
597
+ const v = saved["key:" + el.dataset.id];
598
+ if (v) el.value = v;
599
+ });
600
+ wrap.querySelectorAll(".extrainput").forEach((el) => {
601
+ const v = saved["extra:" + el.dataset.id + ":" + el.dataset.extra];
602
+ if (v) el.value = v;
603
+ });
604
+ }
605
+
606
+ /** Speichert eine Drag-Operation auf der zuletzt bestätigten Konfiguration. */
607
+ function queueReorder(from, to) {
608
+ return queuePut((cfg) => {
609
+ const engines = cfg.engines.map((e) => ({ id: e.id, enabled: e.enabled }));
610
+ const i = engines.findIndex((e) => e.id === from);
611
+ const j = engines.findIndex((e) => e.id === to);
612
+ if (i >= 0 && j >= 0) engines.splice(j, 0, engines.splice(i, 1)[0]);
613
+ return { engines, fetchOrder: cfg.fetchOrder };
614
+ });
615
+ }
616
+
617
+ /* ---------- View: Verlauf ---------- */
618
+
619
+ function filteredHistory() {
620
+ const f = state.filters;
621
+ const q = f.q.trim().toLowerCase();
622
+ return state.history.filter((e) => {
623
+ if (f.kind !== "all" && e.kind !== f.kind) return false;
624
+ if (f.engine && e.engine !== f.engine && !(e.attempts || []).some(a => a.engine === f.engine)) return false;
625
+ if (q) {
626
+ const hay = (e.input + " " + (e.engine || "") + " " + (e.attempts || []).map((a) => a.engine).join(" ")).toLowerCase();
627
+ if (!hay.includes(q)) return false;
628
+ }
629
+ return true;
630
+ });
631
+ }
632
+
633
+ function histListHtml(entries) {
634
+ return entries
635
+ .map((e) => {
636
+ const key = e.ts;
637
+ const failChips = (e.attempts || [])
638
+ .filter((a) => !a.ok)
639
+ .map((a) => '<span class="chip fail" title="' + esc(a.error || "") + '">' + esc(a.engine) + "</span>")
640
+ .join("");
641
+ const meta =
642
+ e.ok && e.kind === "search" && e.result?.count !== undefined
643
+ ? t("hist.results", { n: e.result.count })
644
+ : e.ok && e.result?.chars !== undefined
645
+ ? t("hist.chars", { n: e.result.chars })
646
+ : "";
647
+
648
+ let body = "";
649
+ if (e.error) body += '<p class="error-text">' + esc(e.error) + "</p>";
650
+ if ((e.attempts || []).length) {
651
+ body +=
652
+ "<p><strong>" + esc(t("hist.attempts")) + ":</strong></p><ul class='att'>" +
653
+ e.attempts.map((a) => "<li>" + esc(a.engine) + " — " + (a.ok ? "✓" : "✗ " + esc(a.error || "")) + " (" + fmtMs(a.ms) + ")</li>").join("") +
654
+ "</ul>";
655
+ }
656
+ if (e.result?.items?.length) {
657
+ body +=
658
+ "<p><strong>" + esc(t("hist.items")) + ":</strong></p><ul class='hitems'>" +
659
+ e.result.items
660
+ .map(
661
+ (i) =>
662
+ "<li><a href='" + esc(i.url) + "' target='_blank' rel='noreferrer'>" + esc(i.title || i.url) + "</a>" +
663
+ (i.snippet ? "<div class='snip'>" + esc(i.snippet.slice(0, 200)) + "</div>" : "") + "</li>",
664
+ )
665
+ .join("") +
666
+ "</ul>";
667
+ }
668
+ if (e.result?.markdown) {
669
+ body += "<p><strong>" + esc(t("hist.markdown")) + ":</strong></p><pre>" + esc(e.result.markdown.slice(0, 2000)) + "</pre>";
670
+ }
671
+
672
+ return (
673
+ '<details class="hist' + (e.ok ? "" : " fail") + '"' + (state.historyOpen.has(key) ? " open" : "") + ' data-key="' + esc(key) + '">' +
674
+ "<summary>" +
675
+ '<span class="hkind-icon ' + esc(e.kind) + '"><svg width="14" height="14"><use href="#i-' + (e.kind === "search" ? "search" : "link") + '"/></svg></span>' +
676
+ '<time class="htime" datetime="' + esc(e.ts) + '" title="' + esc(new Date(e.ts).toLocaleString(lang)) + '">' + new Date(e.ts).toLocaleTimeString(lang, { hour12: false }) + '</time>' +
677
+ '<span class="hinput" title="' + esc(e.input) + '">' + esc(e.input) + "</span>" +
678
+ '<span class="chips">' + (e.engine ? '<span class="chip ok">' + esc(e.engine) + "</span>" : "") + failChips + "</span>" +
679
+ '<span class="hmeta">' +
680
+ (e.ok ? esc(meta) + " · " + fmtMs(e.ms) : '<span class="fail-mark">' + esc(t("hist.failed")) + "</span> · " + fmtMs(e.ms)) +
681
+ "</span>" +
682
+ '<svg class="chev" width="15" height="15"><use href="#i-chevron"/></svg>' +
683
+ "</summary>" +
684
+ (body ? '<div class="histbody">' + body + "</div>" : "") +
685
+ "</details>"
686
+ );
687
+ })
688
+ .join("");
689
+ }
690
+
691
+ function bindHistToggles() {
692
+ document.querySelectorAll(".hist").forEach((det) => {
693
+ det.addEventListener("toggle", () => {
694
+ const k = det.dataset.key;
695
+ if (det.open) state.historyOpen.add(k);
696
+ else state.historyOpen.delete(k);
697
+ });
698
+ });
699
+ }
700
+
701
+ /** Engine-Filter als Custom-Dropdown — native <select>-Popups folgen nicht dem
702
+ * Seiten-Theme (helle System-Popups mit heller Schrift = unlesbar im Dark). */
703
+ function engineDropdownHtml(selected) {
704
+ const engines = sortedEngines();
705
+ const cur = engines.find((e) => e.id === selected);
706
+ const label = selected && cur ? cur.label || cur.id : t("hist.filter.all");
707
+ const items =
708
+ '<button role="menuitem" data-engine="" class="' + (!selected ? "active" : "") + '">' + esc(t("hist.filter.all")) +
709
+ '<svg class="mi-check" width="14" height="14"><use href="#i-check"/></svg></button>' +
710
+ engines
711
+ .map(
712
+ (e) =>
713
+ '<button role="menuitem" data-engine="' + esc(e.id) + '" class="' + (selected === e.id ? "active" : "") + '">' +
714
+ esc(e.label || e.id) + '<svg class="mi-check" width="14" height="14"><use href="#i-check"/></svg></button>',
715
+ )
716
+ .join("");
717
+ return (
718
+ '<div class="dropdown" id="engine-dd">' +
719
+ '<button class="btn" id="engine-dd-btn" aria-haspopup="menu" aria-expanded="false">' +
720
+ '<svg width="13" height="13"><use href="#i-stack"/></svg><span id="engine-dd-label">' + esc(label) + "</span>" +
721
+ '<svg class="chev" width="13" height="13"><use href="#i-chevron"/></svg></button>' +
722
+ '<div class="menu menu-left" id="engine-dd-menu" role="menu" hidden>' + items + "</div></div>"
723
+ );
724
+ }
725
+
726
+ function historyEmptyHtml() {
727
+ return '<div class="empty"><span class="empty-title">' + esc(t(state.history.length ? "history.noMatches" : "history.empty")) + "</span></div>";
728
+ }
729
+
730
+ function renderHistory() {
731
+ const f = state.filters;
732
+ const entries = filteredHistory();
733
+ const fails = entries.filter((e) => !e.ok).length;
734
+
735
+ $("#view").innerHTML =
736
+ '<div class="page">' +
737
+ pageHead(t("history.title"), t("history.sub")) +
738
+ '<div class="hist-toolbar">' +
739
+ '<div class="seg" role="tablist">' +
740
+ segBtn("all", t("hist.filter.all")) +
741
+ segBtn("search", t("hist.filter.search")) +
742
+ segBtn("fetch", t("hist.filter.fetch")) +
743
+ "</div>" +
744
+ engineDropdownHtml(f.engine) +
745
+ '<input type="text" id="hist-q" placeholder="' + esc(t("hist.search.ph")) + '" value="' + esc(f.q) + '">' +
746
+ '<span class="spacer"></span>' +
747
+ '<span class="live-dot' + (state.livePaused ? " paused" : "") + '" id="live-dot"><span class="pulse"></span></span>' +
748
+ '<button class="btn" data-act="live-toggle">' + esc(state.livePaused ? t("hist.resume") : t("hist.pause")) + "</button>" +
749
+ '<button class="btn btn-danger" data-act="hist-clear"><svg width="13" height="13"><use href="#i-trash"/></svg> ' + esc(t("history.clear")) + "</button>" +
750
+ "</div>" +
751
+ '<p class="hist-summary">' + esc(t("hist.shown", { n: entries.length, f: fails })) + "</p>" +
752
+ '<div class="hist-list">' + (entries.length ? histListHtml(entries) : historyEmptyHtml()) + "</div>" +
753
+ "</div>";
754
+
755
+ bindHistToggles();
756
+
757
+ const qInput = $("#hist-q");
758
+ qInput.addEventListener("input", () => {
759
+ state.filters.q = qInput.value;
760
+ clearTimeout(qInput.__t);
761
+ qInput.__t = setTimeout(updateHistory, 220);
762
+ });
763
+
764
+ const ddBtn = $("#engine-dd-btn");
765
+ const ddMenu = $("#engine-dd-menu");
766
+ ddBtn.addEventListener("click", () => {
767
+ const open = !ddMenu.hidden;
768
+ ddMenu.hidden = open;
769
+ ddBtn.setAttribute("aria-expanded", String(!open));
770
+ });
771
+ ddMenu.addEventListener("click", (ev) => {
772
+ const item = ev.target.closest("[data-engine]");
773
+ if (!item) return;
774
+ state.filters.engine = item.dataset.engine;
775
+ $("#engine-dd-label").textContent = item.textContent.trim();
776
+ ddMenu.hidden = true;
777
+ ddBtn.setAttribute("aria-expanded", "false");
778
+ ddMenu.querySelectorAll("[data-engine]").forEach((b) => b.classList.toggle("active", b.dataset.engine === state.filters.engine));
779
+ updateHistory();
780
+ });
781
+ }
782
+
783
+ /** Patcht nur Liste + Zusammenfassung — Toolbar und Fokus im Filterfeld bleiben unangetastet. */
784
+ function updateHistory() {
785
+ const root = $("#view");
786
+ const list = root.querySelector(".hist-list");
787
+ if (!list) {
788
+ // Übergang vom Empty-State zu ersten Einträgen → einmal voll rendern
789
+ if (filteredHistory().length && root.querySelector(".empty")) renderHistory();
790
+ return;
791
+ }
792
+ const entries = filteredHistory();
793
+ const fails = entries.filter((e) => !e.ok).length;
794
+ const summary = root.querySelector(".hist-summary");
795
+ if (summary) summary.textContent = t("hist.shown", { n: entries.length, f: fails });
796
+ list.innerHTML = entries.length ? histListHtml(entries) : historyEmptyHtml();
797
+ bindHistToggles();
798
+ }
799
+
800
+ function segBtn(kind, label) {
801
+ return '<button data-kind="' + kind + '"' + (state.filters.kind === kind ? ' class="active"' : "") + ">" + esc(label) + "</button>";
802
+ }
803
+
804
+ /* ---------- Drag & Drop (delegiert, einmalig) ---------- */
805
+
806
+ document.addEventListener("dragstart", (ev) => {
807
+ const card = ev.target.closest?.(".engine-card");
808
+ if (!card) return;
809
+ if (!ev.target.closest(".handle")) {
810
+ // Drag nur am Griff starten — Inputs/Buttons bleiben normal nutzbar
811
+ ev.preventDefault();
812
+ return;
813
+ }
814
+ state.dragFrom = card.dataset.id;
815
+ card.classList.add("dragging");
816
+ ev.dataTransfer.effectAllowed = "move";
817
+ try {
818
+ ev.dataTransfer.setData("text/plain", card.dataset.id);
819
+ } catch {}
820
+ });
821
+
822
+ document.addEventListener("dragend", (ev) => {
823
+ const card = ev.target.closest?.(".engine-card");
824
+ if (card) card.classList.remove("dragging");
825
+ document.querySelectorAll(".drag-over").forEach((x) => x.classList.remove("drag-over"));
826
+ });
827
+
828
+ document.addEventListener("dragover", (ev) => {
829
+ const card = ev.target.closest?.(".engine-card");
830
+ if (!card || !state.dragFrom) return;
831
+ ev.preventDefault();
832
+ document.querySelectorAll(".drag-over").forEach((x) => x.classList.remove("drag-over"));
833
+ card.classList.add("drag-over");
834
+ });
835
+
836
+ document.addEventListener("drop", (ev) => {
837
+ const toEl = ev.target.closest?.(".engine-card");
838
+ const from = state.dragFrom;
839
+ state.dragFrom = null;
840
+ document.querySelectorAll(".drag-over").forEach((x) => x.classList.remove("drag-over"));
841
+ ev.preventDefault();
842
+ if (!toEl || !from || from === toEl.dataset.id || state.route !== "/engines") return;
843
+ const container = $(".engine-list");
844
+ if (!container) return;
845
+ queueReorder(from, toEl.dataset.id);
846
+ });
847
+
848
+ /* ---------- Events (delegiert) ---------- */
849
+
850
+ document.addEventListener("change", (ev) => {
851
+ if (!ev.target.matches || !ev.target.matches('input[data-act="toggle"]')) return;
852
+ const id = ev.target.closest("[data-id]").dataset.id;
853
+ const enabled = ev.target.checked;
854
+ queuePut((cfg) => ({
855
+ engines: cfg.engines.map((e) => (e.id === id ? { id, enabled } : { id: e.id, enabled: e.enabled })),
856
+ fetchOrder: cfg.fetchOrder,
857
+ settings: { port: cfg.settings.port },
858
+ }));
859
+ });
860
+
861
+ document.addEventListener("click", async (ev) => {
862
+ if (ev.target.closest('[data-act="save-settings"]')) {
863
+ const timeoutInput = $("#request-timeout");
864
+ if (!timeoutInput.checkValidity()) { timeoutInput.reportValidity(); return; }
865
+ const providerDefault = $("#result-count-mode").value === "provider";
866
+ const countInput = $("#result-count");
867
+ if (!providerDefault && !countInput.checkValidity()) { countInput.reportValidity(); return; }
868
+ const defaultNumResults = providerDefault ? null : Number(countInput.value);
869
+ const strictFreeMode = $("#strict-free").checked;
870
+ const requestTimeoutMs = Number(timeoutInput.value);
871
+ await queuePut((cfg) => ({
872
+ engines: cfg.engines.map((e) => ({ id: e.id, enabled: e.enabled })),
873
+ settings: { strictFreeMode, requestTimeoutMs, defaultNumResults },
874
+ }));
875
+ return;
876
+ }
877
+
878
+ // Kopieren-Buttons
879
+ const copyBtn = ev.target.closest("button[data-copy]");
880
+ if (copyBtn) {
881
+ navigator.clipboard.writeText(document.getElementById(copyBtn.dataset.copy).textContent);
882
+ copyBtn.textContent = t("btn.copied");
883
+ toast("info", t("toast.copied"));
884
+ setTimeout(() => (copyBtn.textContent = t("btn.copy")), 1500);
885
+ return;
886
+ }
887
+
888
+ // Verlauf löschen
889
+ const clearBtn = ev.target.closest('[data-act="hist-clear"]');
890
+ if (clearBtn) {
891
+ if (confirm(t("history.confirm"))) {
892
+ try {
893
+ await api("/api/history", { method: "DELETE" });
894
+ state.historyOpen.clear();
895
+ state.history = [];
896
+ renderHistory();
897
+ toast("ok", t("toast.saved"));
898
+ } catch (err) {
899
+ if (err.message !== "unauthorized") toast("error", t("toast.error", { msg: err.message }));
900
+ }
901
+ }
902
+ return;
903
+ }
904
+
905
+ // Live pausieren/fortsetzen
906
+ if (ev.target.closest('[data-act="live-toggle"]')) {
907
+ state.livePaused = !state.livePaused;
908
+ renderHistory();
909
+ return;
910
+ }
911
+
912
+ // Segment-Filter
913
+ const seg = ev.target.closest(".seg [data-kind]");
914
+ if (seg) {
915
+ state.filters.kind = seg.dataset.kind;
916
+ renderHistory();
917
+ return;
918
+ }
919
+
920
+ // Drawer auf/zu
921
+ const drawerBtn = ev.target.closest('[data-act="drawer"]');
922
+ if (drawerBtn) {
923
+ const id = drawerBtn.closest("[data-id]").dataset.id;
924
+ if (state.openEngines.has(id)) state.openEngines.delete(id);
925
+ else state.openEngines.add(id);
926
+ renderEngines();
927
+ return;
928
+ }
929
+
930
+ // Engine-Aktionen
931
+ const btn = ev.target.closest("button[data-act]");
932
+ if (!btn) return;
933
+ const act = btn.dataset.act;
934
+ if (!["save-key", "clear-key", "save-extra", "test"].includes(act)) return;
935
+ const host = btn.closest("[data-id]");
936
+ if (!host) return;
937
+ const id = host.dataset.id;
938
+
939
+ try {
940
+ if (act === "save-key") {
941
+ const value = host.querySelector(".keyinput").value;
942
+ queuePut((cfg) => ({
943
+ engines: cfg.engines.map((e) => (e.id === id ? { id, enabled: e.enabled, apiKey: value } : { id: e.id, enabled: e.enabled })),
944
+ fetchOrder: cfg.fetchOrder,
945
+ settings: { port: cfg.settings.port },
946
+ }));
947
+ } else if (act === "clear-key") {
948
+ queuePut((cfg) => ({
949
+ engines: cfg.engines.map((e) => (e.id === id ? { id, enabled: e.enabled, apiKey: null } : { id: e.id, enabled: e.enabled })),
950
+ fetchOrder: cfg.fetchOrder,
951
+ settings: { port: cfg.settings.port },
952
+ }));
953
+ } else if (act === "save-extra") {
954
+ const input = host.querySelector('.extrainput[data-extra="' + CSS.escape(btn.closest(".drawer-row").querySelector(".extrainput").dataset.extra) + '"]');
955
+ const key = input.dataset.extra;
956
+ const value = input.value.trim();
957
+ queuePut((cfg) => ({
958
+ engines: cfg.engines.map((e) =>
959
+ e.id === id ? { id, enabled: e.enabled, extra: { [key]: value || null } } : { id: e.id, enabled: e.enabled },
960
+ ),
961
+ fetchOrder: cfg.fetchOrder,
962
+ settings: { port: cfg.settings.port },
963
+ }));
964
+ } else if (act === "test") {
965
+ const out = document.querySelector('[data-testout="' + CSS.escape(id) + '"]');
966
+ out.innerHTML = '<span class="muted">' + esc(t("test.running")) + "</span>";
967
+ const capabilities = statusOf(id).capabilities || [];
968
+ const kind = capabilities.includes(btn.dataset.kind) ? btn.dataset.kind : capabilities[0];
969
+ if (!kind) throw new Error("Keine testbare Fähigkeit");
970
+ const r = await api("/api/test", { method: "POST", body: { id, kind } });
971
+ if (r.ok) {
972
+ out.innerHTML =
973
+ '<span class="ok">✓ ' + esc(kind === "fetch" ? t("test.fetchOk", { chars: r.chars, ms: fmtMs(r.ms) }) : t("test.ok", { count: r.count, ms: fmtMs(r.ms) })) + "</span>" +
974
+ (r.preview ? "<pre>" + esc(r.preview) + "</pre>" : "");
975
+ } else {
976
+ out.innerHTML = '<span class="error">✗ ' + esc(r.error) + "</span>";
977
+ }
978
+ }
979
+ } catch (err) {
980
+ if (err.message !== "unauthorized") toast("error", t("toast.error", { msg: err.message }));
981
+ }
982
+ });
983
+
984
+ /* ---------- Sprach-Dropdown & Theme-Button ---------- */
985
+
986
+ const langBtn = $("#lang-btn");
987
+ const langMenu = $("#lang-menu");
988
+
989
+ langBtn.addEventListener("click", (ev) => {
990
+ ev.stopPropagation();
991
+ const open = !langMenu.hidden;
992
+ langMenu.hidden = open;
993
+ langBtn.setAttribute("aria-expanded", String(!open));
994
+ });
995
+
996
+ langMenu.addEventListener("click", (ev) => {
997
+ const item = ev.target.closest("[data-lang]");
998
+ if (!item) return;
999
+ lang = item.dataset.lang;
1000
+ localStorage.setItem("sr_lang", lang);
1001
+ langMenu.hidden = true;
1002
+ langBtn.setAttribute("aria-expanded", "false");
1003
+ applyI18n();
1004
+ setTheme(document.documentElement.dataset.theme, false);
1005
+ render();
1006
+ if (state.route === "/history" || state.route === "/") loadHistory().then(render).catch(() => {});
1007
+ });
1008
+
1009
+ document.addEventListener("click", (ev) => {
1010
+ if (!langMenu.hidden && !ev.target.closest("#lang")) {
1011
+ langMenu.hidden = true;
1012
+ langBtn.setAttribute("aria-expanded", "false");
1013
+ }
1014
+ const engMenu = $("#engine-dd-menu");
1015
+ if (engMenu && !engMenu.hidden && !ev.target.closest("#engine-dd")) {
1016
+ engMenu.hidden = true;
1017
+ $("#engine-dd-btn")?.setAttribute("aria-expanded", "false");
1018
+ }
1019
+ });
1020
+
1021
+ document.addEventListener("keydown", (ev) => {
1022
+ if (ev.key !== "Escape") return;
1023
+ if (!langMenu.hidden) {
1024
+ langMenu.hidden = true;
1025
+ langBtn.setAttribute("aria-expanded", "false");
1026
+ }
1027
+ const engMenu = $("#engine-dd-menu");
1028
+ if (engMenu && !engMenu.hidden) {
1029
+ engMenu.hidden = true;
1030
+ $("#engine-dd-btn")?.setAttribute("aria-expanded", "false");
1031
+ }
1032
+ });
1033
+
1034
+ $("#theme-btn").addEventListener("click", () => {
1035
+ setTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
1036
+ });
1037
+
1038
+ /* ---------- Polling ---------- */
1039
+
1040
+ /* ---------- Polling (gezielte Teil-Updates, kein Voll-Render) ---------- */
1041
+
1042
+ let lastHistorySig = "";
1043
+ function historySig() {
1044
+ const h = state.history;
1045
+ // Minuten-Bucket: relative Zeiten ("vor 1 Stunde") bleiben ohne Flackern frisch
1046
+ return h.length + ":" + (h[0] ? h[0].ts : "") + ":" + Math.floor(Date.now() / 60000);
1047
+ }
1048
+
1049
+ let lastStatusSig = "";
1050
+ function statusSig() {
1051
+ return JSON.stringify([state.meta?.month, state.status.map(s => [s.id, s.enabled, s.searchPosition, s.capabilities, s.used, s.quota, s.remoteError])]);
1052
+ }
1053
+
1054
+ setInterval(() => {
1055
+ if (document.hidden || state.unauthorized || state.livePaused) return;
1056
+ if (state.route !== "/history" && state.route !== "/") return;
1057
+ loadHistory()
1058
+ .then(() => {
1059
+ const sig = historySig();
1060
+ if (sig === lastHistorySig) return; // nichts Neues → kein DOM-Write, kein Flackern
1061
+ lastHistorySig = sig;
1062
+ if (state.route === "/history") updateHistory();
1063
+ else if (state.route === "/") updateOverview();
1064
+ })
1065
+ .catch(() => {});
1066
+ }, 5000);
1067
+
1068
+ setInterval(async () => {
1069
+ if (document.hidden || state.unauthorized) return;
1070
+ try {
1071
+ await refreshStatus();
1072
+ } catch {
1073
+ return;
1074
+ }
1075
+ if (state.route === "/engines") {
1076
+ // Quota-Zeilen gezielt aktualisieren — keine Inputs/Drawer zerstören
1077
+ for (const st of state.status) {
1078
+ document.querySelectorAll('[data-quota="' + CSS.escape(st.id) + '"]').forEach((el) => {
1079
+ el.innerHTML = quotaHtml(st);
1080
+ });
1081
+ }
1082
+ } else if (state.route === "/") {
1083
+ const sig = statusSig();
1084
+ if (sig === lastStatusSig) return;
1085
+ lastStatusSig = sig;
1086
+ updateOverview();
1087
+ }
1088
+ }, 30000);
1089
+
1090
+ window.addEventListener("resize", positionNavInd);
1091
+ window.addEventListener("hashchange", () => setRoute(parseHash()));
1092
+
1093
+ /* Favicon-Load-Fehler → farbiger Buchstaben-Avatar (Capture-Phase: "error" bubblt nicht) */
1094
+ document.addEventListener(
1095
+ "error",
1096
+ (ev) => {
1097
+ const img = ev.target;
1098
+ if (!img || !img.matches || !img.matches("img.logo")) return;
1099
+ const hue = Number(img.dataset.hue) || 210;
1100
+ const span = document.createElement("span");
1101
+ span.className = "avatar";
1102
+ span.style.cssText =
1103
+ "background:linear-gradient(135deg,hsl(" + hue + " 62% 52%),hsl(" + ((hue + 28) % 360) + " 68% 40%))";
1104
+ span.textContent = img.dataset.fallback || "?";
1105
+ const wrap = img.closest(".logo-wrap");
1106
+ if (wrap) wrap.replaceWith(span);
1107
+ else img.remove();
1108
+ },
1109
+ true,
1110
+ );
1111
+
1112
+ /* ---------- Boot ---------- */
1113
+
1114
+ initTheme();
1115
+ applyI18n();
1116
+ setRoute(parseHash());
1117
+ load().catch((e) => {
1118
+ if (e.message !== "unauthorized") toast("error", t("toast.error", { msg: e.message }));
1119
+ });
1120
+
1121
+ // Keep the numeric input out of the provider-default mode.
1122
+ document.addEventListener("change", ev => {
1123
+ if (ev.target.id !== "result-count-mode") return;
1124
+ const field = $("#result-count");
1125
+ field.disabled = field.hidden = ev.target.value === "provider";
1126
+ });