@240xu/dsh-websearch 2.0.0

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,107 @@
1
+ /**
2
+ * SearXNG backend for dsh-websearch.
3
+ * REST API: {baseURL}/search?q={query}&format=json&categories=general&language=en&safesearch=1
4
+ * Auth: None (public instances) or API key for private instances
5
+ * Free tier: Unlimited (public instances)
6
+ * Default instance: https://searx.be
7
+ */
8
+
9
+ import { WebError } from "@deepseek-ai/dsh-web";
10
+ import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
11
+
12
+ const DEFAULT_SEARXNG_URL = "https://searx.be";
13
+
14
+ /** @type {import("../provider.js").Backend} */
15
+ export const searxngBackend = {
16
+ id: "searxng",
17
+ requiresCredential: false,
18
+
19
+ available(options) {
20
+ // Keyless by default, but can use API key for private instances
21
+ return true;
22
+ },
23
+
24
+ async search(request, signal, options, hooks) {
25
+ throwIfSearchAborted(signal);
26
+ const backendOpts = options ?? {};
27
+
28
+ const baseURL = backendOpts.baseURL ?? DEFAULT_SEARXNG_URL;
29
+ const apiKey = await backendOpts.resolveApiKey?.(); // Optional for private instances
30
+
31
+ const url = new URL(baseURL);
32
+ url.pathname = url.pathname.replace(/\/+$/,"") + "/search";
33
+ url.searchParams.set("q", request.query);
34
+ url.searchParams.set("format", "json");
35
+ url.searchParams.set("categories", "general");
36
+ url.searchParams.set("language", "en");
37
+ url.searchParams.set("safesearch", "1");
38
+ url.searchParams.set("pageno", "1");
39
+
40
+ const headers = {
41
+ "Accept": "application/json",
42
+ "User-Agent": "dsh-websearch/2.0",
43
+ };
44
+
45
+ if (apiKey) {
46
+ headers["Authorization"] = "Bearer " + apiKey;
47
+ }
48
+
49
+ hooks?.recordRequest?.({
50
+ endpoint: baseURL,
51
+ toolName: "searxng_search",
52
+ body: { q: request.query },
53
+ });
54
+
55
+ let response;
56
+ try {
57
+ response = await fetch(url, {
58
+ method: "GET",
59
+ headers,
60
+ signal,
61
+ });
62
+ } catch (error) {
63
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
64
+ throw new WebError("SearXNG request failed: " + error, "WEB_PROVIDER_ERROR", { cause: error });
65
+ }
66
+
67
+ if (!response.ok) {
68
+ const text = await response.text().catch(() => "");
69
+ hooks?.recordOutcome?.({ status: "error", message: "HTTP " + response.status + ": " + text });
70
+ throw new WebError("SearXNG API error (HTTP " + response.status + "): " + text, "WEB_PROVIDER_ERROR");
71
+ }
72
+
73
+ let data;
74
+ try {
75
+ data = await response.json();
76
+ } catch (error) {
77
+ throw new WebError("SearXNG: invalid JSON response", "WEB_PROVIDER_ERROR", { cause: error });
78
+ }
79
+
80
+ const sources = parseSearxngResult(data, request.maxResults ?? 8);
81
+ hooks?.recordOutcome?.({ status: "success", count: sources.length });
82
+ return { sources };
83
+ },
84
+ };
85
+
86
+ /**
87
+ * Parse SearXNG API response.
88
+ * @param {object} data
89
+ * @param {number} maxResults
90
+ */
91
+ export function parseSearxngResult(data, maxResults) {
92
+ const sources = [];
93
+ const results = data?.results ?? [];
94
+
95
+ for (const item of results) {
96
+ if (sources.length >= maxResults) break;
97
+ if (!item?.url) continue;
98
+ sources.push({
99
+ url: item.url,
100
+ ...(item.title ? { title: item.title } : {}),
101
+ ...(item.content ? { snippet: item.content.slice(0, 500) } : {}),
102
+ ...(item.engine ? { sourceEngine: item.engine } : {}),
103
+ });
104
+ }
105
+
106
+ return sources;
107
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Serper.dev (Google Scraper) backend for dsh-websearch.
3
+ * REST API: https://google.serper.dev/search
4
+ * Auth: X-API-KEY header
5
+ * Free tier: 2,500 requests/month
6
+ */
7
+
8
+ import { WebError } from "@deepseek-ai/dsh-web";
9
+ import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
10
+
11
+ const SERPER_ENDPOINT = "https://google.serper.dev/search";
12
+
13
+ /** @type {import("../provider.js").Backend} */
14
+ export const serperBackend = {
15
+ id: "serper",
16
+ requiresCredential: true,
17
+
18
+ available(options) {
19
+ return typeof options?.resolveApiKey === "function";
20
+ },
21
+
22
+ async search(request, signal, options, hooks) {
23
+ throwIfSearchAborted(signal);
24
+ const backendOpts = options ?? {};
25
+
26
+ const apiKey = await backendOpts.resolveApiKey?.();
27
+ if (!apiKey) {
28
+ throw new WebError("Serper: API key not configured", "WEB_PROVIDER_UNAVAILABLE");
29
+ }
30
+
31
+ const baseURL = backendOpts.baseURL ?? SERPER_ENDPOINT;
32
+ const count = Math.min(request.maxResults ?? 8, 20);
33
+
34
+ const body = {
35
+ q: request.query,
36
+ num: count,
37
+ };
38
+
39
+ hooks?.recordRequest?.({
40
+ endpoint: baseURL,
41
+ toolName: "serper_search",
42
+ body,
43
+ });
44
+
45
+ let response;
46
+ try {
47
+ response = await fetch(baseURL, {
48
+ method: "POST",
49
+ headers: {
50
+ "X-API-KEY": apiKey,
51
+ "Content-Type": "application/json",
52
+ "Accept": "application/json",
53
+ "User-Agent": "dsh-websearch/2.0",
54
+ },
55
+ body: JSON.stringify(body),
56
+ signal,
57
+ });
58
+ } catch (error) {
59
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
60
+ throw new WebError("Serper request failed: " + error, "WEB_PROVIDER_ERROR", { cause: error });
61
+ }
62
+
63
+ if (!response.ok) {
64
+ const text = await response.text().catch(() => "");
65
+ hooks?.recordOutcome?.({ status: "error", message: "HTTP " + response.status + ": " + text });
66
+ throw new WebError("Serper API error (HTTP " + response.status + "): " + text, "WEB_PROVIDER_ERROR");
67
+ }
68
+
69
+ let data;
70
+ try {
71
+ data = await response.json();
72
+ } catch (error) {
73
+ throw new WebError("Serper: invalid JSON response", "WEB_PROVIDER_ERROR", { cause: error });
74
+ }
75
+
76
+ const sources = parseSerperResult(data, request.maxResults ?? 8);
77
+ hooks?.recordOutcome?.({ status: "success", count: sources.length });
78
+ return { sources };
79
+ },
80
+ };
81
+
82
+ /**
83
+ * Parse Serper API response.
84
+ * @param {object} data
85
+ * @param {number} maxResults
86
+ */
87
+ export function parseSerperResult(data, maxResults) {
88
+ const sources = [];
89
+ const organic = data?.organic ?? [];
90
+
91
+ for (const item of organic) {
92
+ if (sources.length >= maxResults) break;
93
+ if (!item?.link) continue;
94
+ sources.push({
95
+ url: item.link,
96
+ ...(item.title ? { title: item.title } : {}),
97
+ ...(item.snippet ? { snippet: item.snippet.slice(0, 500) } : {}),
98
+ });
99
+ }
100
+
101
+ // Also include news results if present
102
+ const news = data?.news ?? [];
103
+ for (const item of news) {
104
+ if (sources.length >= maxResults) break;
105
+ if (!item?.link) continue;
106
+ sources.push({
107
+ url: item.link,
108
+ ...(item.title ? { title: item.title } : {}),
109
+ ...(item.snippet ? { snippet: item.snippet.slice(0, 500) } : {}),
110
+ ...(item.date ? { publishedAt: item.date } : {}),
111
+ });
112
+ }
113
+
114
+ return sources;
115
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Tavily Search backend for dsh-websearch.
3
+ * REST API: https://api.tavily.com/search
4
+ * Auth: Bearer token or api_key in body
5
+ * Free tier: 1,000 requests/month
6
+ */
7
+
8
+ import { WebError } from "@deepseek-ai/dsh-web";
9
+ import { throwIfSearchAborted, searchAborted, isAbortError } from "../util/abort.js";
10
+
11
+ const TAVILY_ENDPOINT = "https://api.tavily.com/search";
12
+
13
+ /** @type {import("../provider.js").Backend} */
14
+ export const tavilyBackend = {
15
+ id: "tavily",
16
+ requiresCredential: true,
17
+
18
+ available(options) {
19
+ return typeof options?.resolveApiKey === "function";
20
+ },
21
+
22
+ async search(request, signal, options, hooks) {
23
+ throwIfSearchAborted(signal);
24
+ const backendOpts = options ?? {};
25
+
26
+ const apiKey = await backendOpts.resolveApiKey?.();
27
+ if (!apiKey) {
28
+ throw new WebError("Tavily: API key not configured", "WEB_PROVIDER_UNAVAILABLE");
29
+ }
30
+
31
+ const baseURL = backendOpts.baseURL ?? TAVILY_ENDPOINT;
32
+ const maxResults = Math.min(request.maxResults ?? 8, 20);
33
+ const searchDepth = backendOpts.searchDepth ?? "basic";
34
+
35
+ const body = {
36
+ query: request.query,
37
+ max_results: maxResults,
38
+ search_depth: searchDepth,
39
+ include_answer: true,
40
+ include_raw_content: false,
41
+ };
42
+
43
+ hooks?.recordRequest?.({
44
+ endpoint: baseURL,
45
+ toolName: "tavily_search",
46
+ body,
47
+ });
48
+
49
+ let response;
50
+ try {
51
+ response = await fetch(baseURL, {
52
+ method: "POST",
53
+ headers: {
54
+ "Authorization": `Bearer ${apiKey}`,
55
+ "Content-Type": "application/json",
56
+ "Accept": "application/json",
57
+ "User-Agent": "dsh-websearch/2.0",
58
+ },
59
+ body: JSON.stringify(body),
60
+ signal,
61
+ });
62
+ } catch (error) {
63
+ if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
64
+ throw new WebError(`Tavily request failed: ${error}`, "WEB_PROVIDER_ERROR", { cause: error });
65
+ }
66
+
67
+ if (!response.ok) {
68
+ const text = await response.text().catch(() => "");
69
+ hooks?.recordOutcome?.({ status: "error", message: `HTTP ${response.status}: ${text}` });
70
+ throw new WebError(`Tavily API error (HTTP ${response.status}): ${text}`, "WEB_PROVIDER_ERROR");
71
+ }
72
+
73
+ let data;
74
+ try {
75
+ data = await response.json();
76
+ } catch (error) {
77
+ throw new WebError("Tavily: invalid JSON response", "WEB_PROVIDER_ERROR", { cause: error });
78
+ }
79
+
80
+ const parsed = parseTavilyResult(data, request.maxResults ?? 8);
81
+ const sources = parsed.sources;
82
+ hooks?.recordOutcome?.({ status: "success", count: sources.length });
83
+ return { sources, ...(parsed.content ? { content: parsed.content } : {}) };
84
+ },
85
+ };
86
+
87
+ /**
88
+ * Parse Tavily API response.
89
+ * @param {object} data
90
+ * @param {number} maxResults
91
+ */
92
+ export function parseTavilyResult(data, maxResults) {
93
+ // Per the seam contract, a provider-generated answer belongs in result.content,
94
+ // NOT as a fake source entry (sources must be citeable URLs).
95
+ const answer = data?.answer && typeof data.answer === "string" && data.answer.length > 0
96
+ ? data.answer.slice(0, 2000)
97
+ : undefined;
98
+ const sources = [];
99
+
100
+ const results = data?.results ?? [];
101
+ for (const item of results) {
102
+ if (sources.length >= maxResults) break;
103
+ if (!item?.url) continue;
104
+ sources.push({
105
+ url: item.url,
106
+ ...(item.title ? { title: item.title } : {}),
107
+ ...(item.content ? { snippet: item.content.slice(0, 500) } : {}),
108
+ ...(item.publishedAt ? { publishedAt: item.publishedAt } : {}),
109
+ });
110
+ }
111
+ return { sources, ...(answer ? { content: answer } : {}) };
112
+ }
package/lib/client.js ADDED
@@ -0,0 +1,317 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@240xu/dsh-websearch",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react_jsx_runtime = require("react/jsx-runtime");
8
+ let react = require("react");
9
+
10
+ const NS = "websearch.settings";
11
+ const PLUGIN_NS = "unified-search";
12
+
13
+ /** Backend metadata: id, default credential env ref, label. */
14
+ const BACKENDS = [
15
+ { id: "exa", keyEnv: null, label: "Exa (MCP, 免费)" },
16
+ { id: "parallel", keyEnv: null, label: "Parallel (MCP, 免费)" },
17
+ { id: "ddg", keyEnv: null, label: "DuckDuckGo (HTML)" },
18
+ { id: "searxng", keyEnv: "SEARXNG_API_KEY", label: "SearXNG (元搜索)" },
19
+ { id: "brave", keyEnv: "BRAVE_API_KEY", label: "Brave Search" },
20
+ { id: "tavily", keyEnv: "TAVILY_API_KEY", label: "Tavily (AI)" },
21
+ { id: "serper", keyEnv: "SERPER_API_KEY", label: "Serper.dev" },
22
+ { id: "mojeek", keyEnv: "MOJEEK_API_KEY", label: "Mojeek" },
23
+ { id: "deepseek", keyEnv: "DEEPSEEK_API_KEY", label: "DeepSeek" },
24
+ { id: "anthropic", keyEnv: "ANTHROPIC_API_KEY", label: "Anthropic" },
25
+ { id: "openai", keyEnv: "OPENAI_API_KEY", label: "OpenAI" },
26
+ ];
27
+ const GLOBAL_FIELDS = ["numResults", "concurrency", "backendTimeoutMs"];
28
+
29
+ const zh = {
30
+ title: "统一搜索 (Unified Search)",
31
+ description: "聚合网络搜索:11 个后端并发 fan-out、URL 去重合并。开关后端、填 API Key、调参数,保存即生效。",
32
+ save: "保存",
33
+ discard: "放弃修改",
34
+ dirty: "有未保存修改",
35
+ saved: "已保存",
36
+ saving: "保存中…",
37
+ failed: "保存失败",
38
+ reset: "重置",
39
+ overridden: "已自定义",
40
+ keyLabel: "{id} API Key",
41
+ keyHint: "填入后保存到凭证库(对应环境变量引用);留空表示不修改",
42
+ keySet: "已配置",
43
+ keyUnset: "未配置",
44
+ togglesTitle: "启用后端",
45
+ globalsTitle: "全局参数",
46
+ };
47
+ const en = {
48
+ title: "Unified Search",
49
+ description: "Aggregated web search: 11 backends fan out concurrently, URL-deduped. Toggle backends, set API keys, tune globals — saves apply immediately.",
50
+ save: "Save",
51
+ discard: "Discard",
52
+ dirty: "Unsaved changes",
53
+ saved: "Saved",
54
+ saving: "Saving…",
55
+ failed: "Save failed",
56
+ reset: "Reset",
57
+ overridden: "customized",
58
+ keyLabel: "{id} API Key",
59
+ keyHint: "Stored into the credential store on save; leave blank to keep current",
60
+ keySet: "configured",
61
+ keyUnset: "not set",
62
+ togglesTitle: "Enabled backends",
63
+ globalsTitle: "Global parameters",
64
+ };
65
+
66
+ //#region styles
67
+ const css = ".wsc-card{display:flex;flex-direction:column;gap:14px}.wsc-sectionTitle{font-size:13px;font-weight:600;opacity:.75;margin:4px 0 0}.wsc-toggles{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:6px}.wsc-toggle{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:10px;border:1px solid var(--dsw-alias-border-subtle);cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);background:0 0}.wsc-toggle input{accent-color:var(--dsw-alias-accent-primary)}.wsc-input,.wsc-keyInput{box-sizing:border-box;width:100%;padding:8px 10px;border-radius:8px;border:1px solid var(--dsw-alias-border-subtle);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px;font-family:inherit}.wsc-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px}.wsc-fieldHead{display:flex;justify-content:space-between;align-items:center;margin-bottom:3px}.wsc-label{font-size:12px;opacity:.8}.wsc-badge{font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-subtle)}.wsc-badgeOn{color:var(--dsw-alias-accent-primary);border-color:var(--dsw-alias-accent-primary)}.wsc-hint{font-size:11px;opacity:.55;margin:3px 0 0}.wsc-actions{display:flex;gap:8px;align-items:center}.wsc-btn{cursor:pointer;border:none;border-radius:10px;padding:8px 16px;font-size:13px;font-family:inherit;background:var(--dsw-alias-accent-primary);color:#fff}.wsc-btnGhost{background:0 0;color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-subtle)}.wsc-btn:disabled{opacity:.5;cursor:default}.wsc-status{font-size:12px;opacity:.7}";
68
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"dsh-websearch/card\"]") === null) {
69
+ const tag = document.createElement("style");
70
+ tag.dataset.plugin = "@240xu/dsh-websearch";
71
+ tag.dataset.pluginCss = "dsh-websearch/card";
72
+ tag.textContent = css;
73
+ document.head.appendChild(tag);
74
+ }
75
+ //#endregion
76
+
77
+ /** Build a snapshot store (same shape as client-runtime's createSnapshotStore). */
78
+ function createStore(initial) {
79
+ let snap = initial;
80
+ const listeners = new Set();
81
+ return {
82
+ getSnapshot: () => snap,
83
+ subscribe: (l) => (listeners.add(l), () => listeners.delete(l)),
84
+ set: (next) => { snap = next; listeners.forEach((l) => l()); },
85
+ };
86
+ }
87
+
88
+ function useStore(store, selector = (s) => s) {
89
+ return react.useSyncExternalStore(store.subscribe, () => selector(store.getSnapshot()));
90
+ }
91
+
92
+ /**
93
+ * Card state machine: reads the bound settings scope snapshot, stages edits,
94
+ * writes section fields through scope.set/unset and secrets via credentials.
95
+ */
96
+ class UnifiedSearchCardController {
97
+ constructor(scope, connection, remote) {
98
+ this.scope = scope;
99
+ this.connection = connection;
100
+ this.remote = remote;
101
+ this.staged = new Map(); // field -> { text } | { clear: true }
102
+ this.stagedKeys = new Map(); // envRef -> text
103
+ this.keyState = new Map(); // envRef -> {configured}
104
+ this.saving = false;
105
+ this.failed = false;
106
+ this.store = createStore(this.projection());
107
+ this.scope.subscribe(() => this.republish());
108
+ }
109
+
110
+ sectionValue(field) {
111
+ return this.scope.getSnapshot().value?.[field];
112
+ }
113
+
114
+ fieldState(field) {
115
+ const staged = this.staged.get(field);
116
+ const current = this.sectionValue(field);
117
+ if (staged === undefined) return { text: current === undefined ? "" : String(current), overridden: false };
118
+ return { text: staged.clear ? "" : staged.text, overridden: !staged.clear };
119
+ }
120
+
121
+ projection() {
122
+ const snap = this.scope.getSnapshot();
123
+ return {
124
+ ready: snap.status === "ready",
125
+ writable: snap.writable,
126
+ value: snap.value ?? {},
127
+ keyState: Object.fromEntries(this.keyState),
128
+ stagedKeys: Object.fromEntries([...this.stagedKeys].map(([k, v]) => [k, v.text])),
129
+ stagedCount: this.staged.size + this.stagedKeys.size,
130
+ stagedText: (field) => {
131
+ const s = this.staged.get(field);
132
+ return s ? (s.clear ? "" : s.text) : undefined;
133
+ },
134
+ saving: this.saving,
135
+ failed: this.failed,
136
+ version: (this.staged.size + ":" + [...this.staged.keys()].join(",")),
137
+ };
138
+ }
139
+
140
+ republish() { this.store.set(this.projection()); }
141
+
142
+ edit(field, text) { this.staged.set(field, { text }); this.republish(); }
143
+ reset(field) { this.staged.delete(field); this.republish(); }
144
+ editKey(envRef, text) { this.stagedKeys.set(envRef, { text }); this.republish(); }
145
+
146
+ /** Toggles write immediately (no staging); globals/keys stage + save. */
147
+ async setToggle(id, checked) {
148
+ await this.scope.set("enable" + id.charAt(0).toUpperCase() + id.slice(1), checked);
149
+ this.republish();
150
+ }
151
+
152
+ async readCredential(ref) {
153
+ try {
154
+ const res = await this.connection.api.credentials.describe({ refs: [ref] });
155
+ if (!res.result.ok) return;
156
+ const view = res.result.value.credentials[ref];
157
+ this.keyState.set(ref, { configured: view?.configured ?? false });
158
+ } catch { /* ignore */ }
159
+ this.republish();
160
+ }
161
+
162
+ refreshAllCredentials() {
163
+ for (const b of BACKENDS) if (b.keyEnv) this.readCredential(b.keyEnv);
164
+ }
165
+
166
+ async save() {
167
+ if (this.saving) return;
168
+ this.saving = true; this.failed = false; this.republish();
169
+ const doneFields = [];
170
+ const doneKeys = [];
171
+ try {
172
+ for (const [field, staged] of this.staged) {
173
+ // Empty draft clears the override so the schema default applies again.
174
+ if (staged.clear || staged.text === "") { await this.scope.unset(field); doneFields.push(field); continue; }
175
+ if (GLOBAL_FIELDS.includes(field)) {
176
+ const num = Number(staged.text);
177
+ // Non-numeric input: keep the draft so the user can correct it.
178
+ if (Number.isNaN(num)) { this.failed = true; continue; }
179
+ await this.scope.set(field, num);
180
+ doneFields.push(field);
181
+ } else {
182
+ await this.scope.set(field, staged.text);
183
+ doneFields.push(field);
184
+ }
185
+ }
186
+ for (const [ref, staged] of this.stagedKeys) {
187
+ const value = staged.text.trim();
188
+ if (value !== "") {
189
+ await this.connection.api.credentials.set({ ref, value });
190
+ this.keyState.set(ref, { configured: true });
191
+ doneKeys.push(ref);
192
+ }
193
+ }
194
+ for (const f of doneFields) this.staged.delete(f);
195
+ for (const k of doneKeys) this.stagedKeys.delete(k);
196
+ } catch { this.failed = true; }
197
+ this.saving = false;
198
+ this.republish();
199
+ }
200
+
201
+ inject() {
202
+ return {
203
+ hooks: {
204
+ useCard: () => useStore(this.store),
205
+ edit: (f, t) => this.edit(f, t),
206
+ setToggle: (id, checked) => this.setToggle(id, checked),
207
+ reset: (f) => this.reset(f),
208
+ editKey: (r, t) => this.editKey(r, t),
209
+ save: () => this.save(),
210
+ discard: () => { this.staged.clear(); this.stagedKeys.clear(); this.failed = false; this.republish(); },
211
+ },
212
+ };
213
+ }
214
+ }
215
+
216
+ /** Toggle row for one backend. */
217
+ function ToggleRow(props) {
218
+ const b = props.backend;
219
+ // The Host serves schema-resolved values, so defaults arrive materialized;
220
+ // no client-side default list to drift against the server.
221
+ const checked = props.value === true;
222
+ return react_jsx_runtime.jsxs("label", { className: "wsc-toggle", children: [
223
+ react_jsx_runtime.jsx("input", {
224
+ type: "checkbox",
225
+ checked,
226
+ onChange: (e) => props.onChange(e.target.checked),
227
+ }),
228
+ react_jsx_runtime.jsx("span", { children: b.label }),
229
+ ] });
230
+ }
231
+
232
+ /** The card root. */
233
+ function UnifiedSearchCard(props) {
234
+ const card = props.card;
235
+ const state = card.useCard();
236
+ // Occupants of settings.plugin.item render inside the tab's <ul>; use <li>.
237
+ if (!state.ready) {
238
+ return react_jsx_runtime.jsx("li", { className: "wsc-card", style: { fontSize: 12, opacity: .6 }, children: "…" });
239
+ }
240
+
241
+ const toggleField = (id) => "enable" + id.charAt(0).toUpperCase() + id.slice(1);
242
+
243
+ return react_jsx_runtime.jsxs("li", { className: "wsc-card", children: [
244
+ react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 12, opacity: .65 }, children: props.t("description") }),
245
+ react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("togglesTitle") }),
246
+ react_jsx_runtime.jsx("div", { className: "wsc-toggles", children: BACKENDS.map((b) =>
247
+ react_jsx_runtime.jsx(ToggleRow, {
248
+ backend: b,
249
+ value: state.value[toggleField(b.id)],
250
+ onChange: (checked) => card.setToggle(b.id, checked),
251
+ }, b.id)
252
+ ) }),
253
+ react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: props.t("globalsTitle") }),
254
+ react_jsx_runtime.jsxs("div", { className: "wsc-grid", children: [
255
+ react_jsx_runtime.jsxs("div", { children: [
256
+ react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: "numResults" }) }),
257
+ react_jsx_runtime.jsx("input", { className: "wsc-input", value: state.stagedText("numResults") ?? String(state.value.numResults ?? ""), onChange: e => card.edit("numResults", e.target.value), inputMode: "numeric" }),
258
+ ] }),
259
+ react_jsx_runtime.jsxs("div", { children: [
260
+ react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: "concurrency" }) }),
261
+ react_jsx_runtime.jsx("input", { className: "wsc-input", value: state.stagedText("concurrency") ?? String(state.value.concurrency ?? ""), onChange: e => card.edit("concurrency", e.target.value), inputMode: "numeric" }),
262
+ ] }),
263
+ react_jsx_runtime.jsxs("div", { children: [
264
+ react_jsx_runtime.jsx("div", { className: "wsc-fieldHead", children: react_jsx_runtime.jsx("span", { className: "wsc-label", children: "backendTimeoutMs" }) }),
265
+ react_jsx_runtime.jsx("input", { className: "wsc-input", value: state.stagedText("backendTimeoutMs") ?? String(state.value.backendTimeoutMs ?? ""), onChange: e => card.edit("backendTimeoutMs", e.target.value), inputMode: "numeric" }),
266
+ ] }),
267
+ ] }),
268
+ react_jsx_runtime.jsx("h4", { className: "wsc-sectionTitle", children: "API Keys" }),
269
+ react_jsx_runtime.jsx("div", { className: "wsc-grid", children: BACKENDS.filter((b) => b.keyEnv).map((b) =>
270
+ react_jsx_runtime.jsxs("div", { children: [
271
+ react_jsx_runtime.jsxs("div", { className: "wsc-fieldHead", children: [
272
+ react_jsx_runtime.jsx("span", { className: "wsc-label", children: props.t("keyLabel", { id: b.id }) }),
273
+ react_jsx_runtime.jsx("span", { className: "wsc-badge" + (state.keyState[b.keyEnv]?.configured ? " wsc-badgeOn" : ""), children: state.keyState[b.keyEnv]?.configured ? props.t("keySet") : props.t("keyUnset") }),
274
+ ] }),
275
+ react_jsx_runtime.jsx("input", {
276
+ className: "wsc-keyInput", type: "password", autoComplete: "off",
277
+ placeholder: b.keyEnv,
278
+ value: state.stagedKeys[b.keyEnv] ?? "",
279
+ onChange: (e) => card.editKey(b.keyEnv, e.target.value),
280
+ }),
281
+ react_jsx_runtime.jsx("p", { className: "wsc-hint", children: props.t("keyHint") }),
282
+ ] }, b.id)
283
+ ) }),
284
+ react_jsx_runtime.jsxs("div", { className: "wsc-actions", children: [
285
+ react_jsx_runtime.jsx("button", { className: "wsc-btn", disabled: state.saving, onClick: () => card.save(), children: state.saving ? props.t("saving") : props.t("save") }),
286
+ (state.stagedCount > 0) && react_jsx_runtime.jsx("button", { className: "wsc-btn wsc-btnGhost", onClick: () => card.discard(), children: props.t("discard") }),
287
+ state.failed && react_jsx_runtime.jsx("span", { className: "wsc-status", children: props.t("failed") }),
288
+ ] }),
289
+ ] });
290
+ }
291
+
292
+ /** Plugin entry: register the card into the settings plugins page. */
293
+ const inject = ["slots", "locale", "connection", "remote", "settingsScope"];
294
+ function apply(ctx) {
295
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "websearch: copy dictionaries");
296
+ const controller = new UnifiedSearchCardController(
297
+ ctx.settingsScope.bind({ namespace: PLUGIN_NS }),
298
+ ctx.get("connection"),
299
+ ctx.get("remote"),
300
+ );
301
+ ctx.effect(() => ctx.remote.$on("credentials/updated", () => controller.refreshAllCredentials()), "websearch: credential invalidations");
302
+ ctx.slots.inject("settings.plugin.item", function* () {
303
+ yield ctx.slots.register({
304
+ name: "settings.plugin.item",
305
+ key: PLUGIN_NS,
306
+ locale: NS,
307
+ inject: () => controller.inject(),
308
+ }, UnifiedSearchCard);
309
+ });
310
+ controller.refreshAllCredentials();
311
+ }
312
+
313
+ exports.apply = apply;
314
+ exports.inject = inject;
315
+ return module.exports;
316
+ }
317
+ });