@klarkxy/dsh-web-search-manager 0.1.11 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,10 +1,12 @@
1
- import { WEB_SEARCH_RPC_CHANNEL, defaultSettings, migrateSearchOrder, pickActiveSearch, providerKey, resolveSearchOrder, validateBaseURL } from "./contracts.js";
1
+ import { WEB_MANAGER_OWNER, WEB_SEARCH_RPC_CHANNEL, defaultSettings, migrateSearchOrder, pickActiveSearch, providerKey, resolveSearchOrder, safeConfigurationUrl, validateBaseURL } from "./contracts.js";
2
2
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
3
3
  import { WebError } from "@deepseek-ai/dsh-web";
4
+ import { WEB_PROVIDER_AVAILABILITY_EVENT } from "@klarkxy/dsh-plugin-kit/contracts";
4
5
  import { DEEPSEEK_DEFAULT_API_VERSION, DEEPSEEK_DEFAULT_BASE_URL, DEEPSEEK_DEFAULT_MAX_TOKENS, DEEPSEEK_DEFAULT_MAX_USES, DEEPSEEK_DEFAULT_MODEL, DeepSeekSearchProvider } from "@deepseek-ai/dsh-web-search-deepseek";
5
6
  import { ExaSearchProvider } from "@deepseek-ai/dsh-web-search-exa";
6
7
  import { HttpFetchProvider } from "@deepseek-ai/dsh-web-fetch-http";
7
8
  import { isIP } from "node:net";
9
+ import { registerHostRpc } from "@klarkxy/dsh-plugin-kit/host-rpc";
8
10
  import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
9
11
  import { z } from "zod";
10
12
  //#region src/jina-fetch.ts
@@ -128,7 +130,7 @@ function providerError(error, secrets = []) {
128
130
  return new WebError(redactDescription(descriptions.join(";") || "供应商未提供错误描述。", secrets), redactDescription(code || "WEB_PROVIDER_ERROR", secrets));
129
131
  }
130
132
  const SEARCH_ENGINE_PAGE = "https://lite.duckduckgo.com/lite/";
131
- const MAX_RESPONSE_BYTES$2 = 2e6;
133
+ const MAX_RESPONSE_BYTES$1 = 2e6;
132
134
  const USER_AGENT = "Mozilla/5.0 (compatible; dsh-editor-search/1.0)";
133
135
  function searchEngineUrl(query, page = SEARCH_ENGINE_PAGE) {
134
136
  const url = new URL(page);
@@ -195,7 +197,7 @@ async function readText(response, signal) {
195
197
  const { value, done } = await reader.read();
196
198
  if (done) break;
197
199
  size += value.length;
198
- if (size > MAX_RESPONSE_BYTES$2) throw new Error("Response too large");
200
+ if (size > MAX_RESPONSE_BYTES$1) throw new Error("Response too large");
199
201
  chunks.push(value);
200
202
  }
201
203
  } finally {
@@ -251,10 +253,10 @@ var SearchEngineProvider = class {
251
253
  }
252
254
  }
253
255
  };
254
- function record$1(value) {
256
+ function record(value) {
255
257
  return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
256
258
  }
257
- async function readJSON$1(response, signal) {
259
+ async function readJSON(response, signal) {
258
260
  const reader = response.body?.getReader();
259
261
  if (!reader) throw new Error("Missing response");
260
262
  let size = 0;
@@ -286,7 +288,7 @@ function httpsEndpoint(base, path = "") {
286
288
  return `${url.href.replace(/\/+$/, "")}${path}`;
287
289
  }
288
290
  function sourceFrom(raw, urlKey, titleKey, snippetKey) {
289
- const row = record$1(raw);
291
+ const row = record(raw);
290
292
  if (typeof row?.[urlKey] !== "string") return;
291
293
  let url;
292
294
  try {
@@ -350,7 +352,7 @@ function createRestSearch(spec) {
350
352
  await response.body?.cancel().catch(() => {});
351
353
  throw new HttpStatusError(response.status);
352
354
  }
353
- const sources = spec.parse(await readJSON$1(response, signal));
355
+ const sources = spec.parse(await readJSON(response, signal));
354
356
  return {
355
357
  sources: sources.slice(0, count),
356
358
  truncated: sources.length > count
@@ -373,7 +375,7 @@ const BraveSearchProvider = createRestSearch({
373
375
  count: String(count)
374
376
  }),
375
377
  parse(payload) {
376
- const results = record$1(record$1(payload)?.web)?.results;
378
+ const results = record(record(payload)?.web)?.results;
377
379
  return Array.isArray(results) ? results.flatMap((row) => sourceFrom(row, "url", "title", "description") ?? []) : [];
378
380
  }
379
381
  });
@@ -393,7 +395,7 @@ const BochaSearchProvider = createRestSearch({
393
395
  summary: false
394
396
  }),
395
397
  parse(payload) {
396
- const pages = record$1(record$1(record$1(payload)?.data)?.webPages)?.value ?? record$1(record$1(payload)?.webPages)?.value;
398
+ const pages = record(record(record(payload)?.data)?.webPages)?.value ?? record(record(payload)?.webPages)?.value;
397
399
  return Array.isArray(pages) ? pages.flatMap((row) => sourceFrom(row, "url", "name", "snippet") ?? []) : [];
398
400
  }
399
401
  });
@@ -411,7 +413,7 @@ const SerperSearchProvider = createRestSearch({
411
413
  num: count
412
414
  }),
413
415
  parse(payload) {
414
- const organic = record$1(payload)?.organic;
416
+ const organic = record(payload)?.organic;
415
417
  return Array.isArray(organic) ? organic.flatMap((row) => sourceFrom(row, "link", "title", "snippet") ?? []) : [];
416
418
  }
417
419
  });
@@ -429,47 +431,12 @@ const FirecrawlSearchProvider = createRestSearch({
429
431
  limit: count
430
432
  }),
431
433
  parse(payload) {
432
- const data = record$1(payload)?.data;
434
+ const data = record(payload)?.data;
433
435
  return Array.isArray(data) ? data.flatMap((row) => sourceFrom(row, "url", "title", "description") ?? []) : [];
434
436
  }
435
437
  });
436
438
  //#endregion
437
439
  //#region src/tavily.ts
438
- const MAX_RESPONSE_BYTES = 2e6;
439
- function endpoint(base) {
440
- const url = new URL(base);
441
- if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Invalid endpoint");
442
- return `${url.href.replace(/\/+$/, "")}/search`;
443
- }
444
- function record(value) {
445
- return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
446
- }
447
- async function readJSON(response, signal) {
448
- const reader = response.body?.getReader();
449
- if (!reader) throw new Error("Missing response");
450
- let size = 0;
451
- const chunks = [];
452
- try {
453
- while (true) {
454
- signal?.throwIfAborted();
455
- const { value, done } = await reader.read();
456
- if (done) break;
457
- size += value.length;
458
- if (size > MAX_RESPONSE_BYTES) throw new Error("Response too large");
459
- chunks.push(value);
460
- }
461
- const bytes = new Uint8Array(size);
462
- let offset = 0;
463
- for (const chunk of chunks) {
464
- bytes.set(chunk, offset);
465
- offset += chunk.length;
466
- }
467
- return JSON.parse(new TextDecoder().decode(bytes));
468
- } finally {
469
- await reader.cancel().catch(() => {});
470
- reader.releaseLock();
471
- }
472
- }
473
440
  /** Direct REST adapter. No auxiliary LLM, retries, anonymous fallback or auto-depth. */
474
441
  var TavilySearchProvider = class {
475
442
  options;
@@ -479,7 +446,7 @@ var TavilySearchProvider = class {
479
446
  }
480
447
  available() {
481
448
  try {
482
- endpoint(this.options.baseURL ?? "https://api.tavily.com");
449
+ httpsEndpoint(this.options.baseURL ?? "https://api.tavily.com", "/search");
483
450
  return Boolean(this.options.apiKey.trim());
484
451
  } catch {
485
452
  return false;
@@ -492,7 +459,7 @@ var TavilySearchProvider = class {
492
459
  if (!Number.isInteger(count) || count < 1 || count > 20) throw new WebError("结果数须为 1 至 20。", "WEB_INVALID_REQUEST");
493
460
  try {
494
461
  signal?.throwIfAborted();
495
- const response = await (this.options.fetch ?? fetch)(endpoint(this.options.baseURL ?? "https://api.tavily.com"), {
462
+ const response = await (this.options.fetch ?? fetch)(httpsEndpoint(this.options.baseURL ?? "https://api.tavily.com", "/search"), {
496
463
  method: "POST",
497
464
  redirect: "error",
498
465
  signal,
@@ -521,18 +488,12 @@ var TavilySearchProvider = class {
521
488
  const sources = [];
522
489
  for (const raw of payload.results) {
523
490
  const row = record(raw);
524
- if (typeof row?.url !== "string") continue;
525
- let url;
526
- try {
527
- url = new URL(row.url);
528
- } catch {
529
- continue;
530
- }
531
- if (!["https:", "http:"].includes(url.protocol) || url.username || url.password) continue;
491
+ const source = sourceFrom(row, "url", "title", "content");
492
+ if (!source || !row) continue;
532
493
  sources.push({
533
- url: url.href,
534
- ...typeof row.title === "string" ? { title: row.title.slice(0, 1e3) } : {},
535
- ...typeof row.content === "string" ? { snippet: row.content.slice(0, 8e3) } : {},
494
+ ...source,
495
+ ...row.title === "" ? { title: "" } : {},
496
+ ...row.content === "" ? { snippet: "" } : {},
536
497
  ...typeof row.published_date === "string" && Number.isFinite(Date.parse(row.published_date)) ? { publishedAt: new Date(row.published_date).toISOString() } : {}
537
498
  });
538
499
  }
@@ -547,225 +508,331 @@ var TavilySearchProvider = class {
547
508
  }
548
509
  };
549
510
  //#endregion
550
- //#region src/builtins.ts
551
- function registerBuiltins(manager) {
552
- const offEngine = manager.registerSearchProvider({
553
- id: "ddg",
554
- label: "DuckDuckGo",
555
- billing: "none",
556
- description: "无需注册。默认后端。"
557
- }, () => new SearchEngineProvider());
558
- const offDeepSeek = manager.registerSearchProvider({
559
- id: "deepseek-official",
560
- label: "DeepSeek 搜索",
561
- description: "使用已配置的 DeepSeek API Key,可能产生额外搜索费用。",
562
- defaultBaseURL: DEEPSEEK_DEFAULT_BASE_URL,
563
- credentialRef: "DEEPSEEK_API_KEY",
564
- credentialShared: true,
565
- credentialHint: "与模型设置共用 Key",
566
- billing: "model-and-tools",
567
- signupUrl: "https://platform.deepseek.com/api_keys"
568
- }, (options) => new DeepSeekSearchProvider(() => ({
569
- apiKey: options.apiKey,
570
- baseURL: options.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
571
- model: DEEPSEEK_DEFAULT_MODEL,
572
- apiVersion: DEEPSEEK_DEFAULT_API_VERSION,
573
- maxTokens: DEEPSEEK_DEFAULT_MAX_TOKENS,
574
- maxUses: DEEPSEEK_DEFAULT_MAX_USES
575
- })));
576
- const offExa = manager.registerSearchProvider({
577
- id: "exa",
578
- label: "Exa",
579
- description: "独立 Search API。",
580
- defaultBaseURL: "https://api.exa.ai",
581
- credentialRef: "DSH_EDITOR_WEB_EXA_API_KEY",
582
- billing: "request",
583
- signupUrl: "https://dashboard.exa.ai/api-keys"
584
- }, (options) => new ExaSearchProvider({
585
- apiKey: options.apiKey ?? "",
586
- baseURL: options.baseURL ?? "https://api.exa.ai",
587
- searchType: "auto",
588
- highlightsPerResult: 1
589
- }));
590
- const offBrave = manager.registerSearchProvider({
591
- id: "brave",
592
- label: "Brave",
593
- description: "Brave Search API。",
594
- defaultBaseURL: "https://api.search.brave.com",
595
- credentialRef: "DSH_EDITOR_WEB_BRAVE_API_KEY",
596
- billing: "request",
597
- signupUrl: "https://api.search.brave.com"
598
- }, (options) => new BraveSearchProvider({
599
- apiKey: options.apiKey ?? "",
600
- baseURL: options.baseURL
601
- }));
602
- const offBocha = manager.registerSearchProvider({
603
- id: "bocha",
604
- label: "博查",
605
- description: "中文网页搜索 API。",
606
- defaultBaseURL: "https://api.bochaai.com",
607
- credentialRef: "DSH_EDITOR_WEB_BOCHA_API_KEY",
608
- billing: "request",
609
- signupUrl: "https://open.bochaai.com"
610
- }, (options) => new BochaSearchProvider({
611
- apiKey: options.apiKey ?? "",
612
- baseURL: options.baseURL
613
- }));
614
- const offSerper = manager.registerSearchProvider({
615
- id: "serper",
616
- label: "Serper",
617
- description: "Google 结果的 Search API。",
618
- defaultBaseURL: "https://google.serper.dev",
619
- credentialRef: "DSH_EDITOR_WEB_SERPER_API_KEY",
620
- billing: "request",
621
- signupUrl: "https://serper.dev"
622
- }, (options) => new SerperSearchProvider({
623
- apiKey: options.apiKey ?? "",
624
- baseURL: options.baseURL
625
- }));
626
- const offFirecrawl = manager.registerSearchProvider({
627
- id: "firecrawl",
628
- label: "Firecrawl",
629
- description: "网页搜索与提取 API。",
630
- defaultBaseURL: "https://api.firecrawl.dev",
631
- credentialRef: "DSH_EDITOR_WEB_FIRECRAWL_API_KEY",
632
- billing: "request",
633
- signupUrl: "https://www.firecrawl.dev"
634
- }, (options) => new FirecrawlSearchProvider({
635
- apiKey: options.apiKey ?? "",
636
- baseURL: options.baseURL
637
- }));
638
- const offTavily = manager.registerSearchProvider({
639
- id: "tavily",
640
- label: "Tavily",
641
- defaultBaseURL: "https://api.tavily.com",
642
- description: "Search API;固定 basic 检索,不自动升级搜索深度。",
643
- credentialRef: "DSH_EDITOR_WEB_TAVILY_API_KEY",
644
- billing: "request",
645
- signupUrl: "https://app.tavily.com"
646
- }, (options) => new TavilySearchProvider({
647
- apiKey: options.apiKey ?? "",
648
- baseURL: options.baseURL
649
- }));
650
- const offHttp = manager.registerFetchProvider({
651
- id: "http",
652
- label: "网页读取",
653
- billing: "none",
654
- description: "优先匿名 Jina Reader 读取,失败后本机 HTTP 直连;无需 Key。"
655
- }, (options) => {
656
- const limits = {
657
- maxResponseBytes: 5e6,
658
- maxBodyChars: options.maxFetchChars,
659
- timeoutMs: options.timeoutMs,
660
- maxRedirects: 5,
661
- userAgent: "dsh-editor/managed-web"
662
- };
663
- const readerTimeoutMs = Math.min(15e3, Math.floor(options.timeoutMs / 2));
664
- return new JinaFirstFetchProvider({
665
- reader: new HttpFetchProvider({
666
- ...limits,
667
- maxBodyChars: limits.maxResponseBytes,
668
- timeoutMs: readerTimeoutMs
669
- }),
670
- direct: new HttpFetchProvider(limits),
671
- readerTimeoutMs
511
+ //#region src/native-providers.ts
512
+ function validate(descriptor) {
513
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(descriptor.id) || !descriptor.label.trim()) throw new WebError("供应商标识或名称无效。", "WEB_INVALID_PROVIDER");
514
+ if (descriptor.credentialRef && !/^[A-Z][A-Z0-9_]{1,127}$/.test(descriptor.credentialRef)) throw new WebError("供应商凭据引用无效。", "WEB_INVALID_PROVIDER");
515
+ if (descriptor.defaultBaseURL) validateBaseURL(descriptor.defaultBaseURL);
516
+ for (const [value, label] of [[descriptor.signupUrl, "注册地址"], [descriptor.pricingUrl, "费用说明地址"]]) {
517
+ if (!value) continue;
518
+ try {
519
+ const url = new URL(value);
520
+ if (url.protocol !== "https:" || url.username || url.password) throw new Error();
521
+ } catch {
522
+ throw new WebError(`${label}须为 HTTPS。`, "WEB_INVALID_PROVIDER");
523
+ }
524
+ }
525
+ }
526
+ async function configuredCall(kind, descriptor, configuration, signal, run) {
527
+ let apiKey;
528
+ try {
529
+ signal?.throwIfAborted();
530
+ apiKey = descriptor.credentialRef ? await configuration.resolveCredential(descriptor.credentialRef) : void 0;
531
+ signal?.throwIfAborted();
532
+ if (descriptor.credentialRef && !apiKey?.trim()) throw new WebError("搜索凭据已删除或不可用,请重新配置。", "WEB_CREDENTIAL_MISSING");
533
+ const settings = configuration.settings();
534
+ return await run({
535
+ apiKey,
536
+ baseURL: settings.endpoints[providerKey(kind, descriptor.id)] ?? descriptor.defaultBaseURL,
537
+ timeoutMs: settings.timeoutMs,
538
+ maxFetchChars: settings.maxFetchChars
672
539
  });
540
+ } catch (error) {
541
+ throw providerError(error, apiKey ? [apiKey] : []);
542
+ }
543
+ }
544
+ /** Bundle-owned adapters register through the same native ctx.web entry as any plugin. */
545
+ function registerNativeSearch(web, configuration, descriptor, factory) {
546
+ validate(descriptor);
547
+ return web.registerSearchProvider({
548
+ id: descriptor.id,
549
+ dshWebManagement: Object.freeze({
550
+ ...descriptor,
551
+ configurationOwner: WEB_MANAGER_OWNER
552
+ }),
553
+ available: () => true,
554
+ search: (request, signal) => configuredCall("search", descriptor, configuration, signal, (options) => {
555
+ const provider = factory(options);
556
+ if (!provider.available()) throw new WebError("供应商配置不可用。", "WEB_PROVIDER_ERROR");
557
+ return provider.search(request, signal);
558
+ })
559
+ });
560
+ }
561
+ function registerNativeFetch(web, configuration, descriptor, factory) {
562
+ validate(descriptor);
563
+ return web.registerFetchProvider({
564
+ id: descriptor.id,
565
+ dshWebManagement: Object.freeze({
566
+ ...descriptor,
567
+ configurationOwner: WEB_MANAGER_OWNER
568
+ }),
569
+ available: () => true,
570
+ fetch: (request, signal) => configuredCall("fetch", descriptor, configuration, signal, (options) => {
571
+ const provider = factory(options);
572
+ if (!provider.available()) throw new WebError("供应商配置不可用。", "WEB_PROVIDER_ERROR");
573
+ return provider.fetch(request, signal);
574
+ })
673
575
  });
674
- return () => {
675
- offHttp();
676
- offTavily();
677
- offFirecrawl();
678
- offSerper();
679
- offBocha();
680
- offBrave();
681
- offExa();
682
- offDeepSeek();
683
- offEngine();
684
- };
685
576
  }
686
577
  //#endregion
687
- //#region src/host-rpc.ts
688
- function registerHostRpc(ctx, channel, call) {
689
- return ctx.webServer.register({
690
- kind: "prefix",
691
- path: channel,
692
- handler: async (req, res) => {
693
- if (!ctx.connection.requestRejection) {
694
- res.writeHead(503);
695
- res.end();
696
- return;
697
- }
698
- const rejection = ctx.connection.requestRejection(req);
699
- if (rejection !== void 0) {
700
- res.writeHead(rejection);
701
- res.end();
702
- return;
703
- }
704
- const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
705
- const endpoint = pathname.startsWith(`${channel}/`) ? pathname.slice(channel.length + 1) : "";
706
- if (req.method !== "POST" || !/^[a-z.]+$/.test(endpoint)) {
707
- res.writeHead(404);
708
- res.end();
709
- return;
710
- }
711
- if (String(req.headers["content-type"] ?? "").split(";")[0]?.trim() !== "application/json") {
712
- res.writeHead(415);
713
- res.end();
714
- return;
715
- }
716
- const controller = new AbortController();
717
- res.on("close", () => {
718
- if (!res.writableEnded) controller.abort();
578
+ //#region src/web-registry.ts
579
+ const owner = Symbol.for("@klarkxy/dsh-web-search-manager/registry-owner");
580
+ /** The only private-host boundary. Verified against DSH 0.2.0-rc.2.
581
+ * Keep the actual Maps: Cordis effects capture them for automatic unregistration.
582
+ * Replace this bridge when the host exposes enumeration/selection publicly.
583
+ */
584
+ function runtimeOf(web) {
585
+ const runtime = web;
586
+ if (!(runtime.searchProviders instanceof Map) || !(runtime.fetchProviders instanceof Map) || !Object.hasOwn(runtime, "searchProviderId") || !Object.hasOwn(runtime, "fetchProviderId")) throw new WebError("当前 ctx.web 不支持注册表管理,请使用兼容的 DSH 版本。", "WEB_REGISTRY_UNSUPPORTED");
587
+ return runtime;
588
+ }
589
+ function hasWebProvider(web, kind, id) {
590
+ return runtimeOf(web)[kind === "search" ? "searchProviders" : "fetchProviders"].has(id);
591
+ }
592
+ function observeWebRegistry(web, hooks) {
593
+ const runtime = runtimeOf(web);
594
+ const maps = [runtime.searchProviders, runtime.fetchProviders];
595
+ if (maps.some((map) => Object.hasOwn(map, owner))) throw new WebError("ctx.web 已由另一个搜索管理器管理。", "WEB_REGISTRY_MANAGED");
596
+ const undo = [];
597
+ try {
598
+ for (const [kind, map] of [["search", maps[0]], ["fetch", maps[1]]]) {
599
+ Object.defineProperty(map, owner, {
600
+ value: true,
601
+ configurable: true
719
602
  });
720
- try {
721
- const chunks = [];
722
- let size = 0;
723
- for await (const chunk of req) {
724
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
725
- size += buffer.length;
726
- if (size > 65536) {
727
- res.writeHead(413, { connection: "close" });
728
- res.end();
729
- req.destroy();
730
- return;
603
+ undo.push(() => {
604
+ delete map[owner];
605
+ });
606
+ const originals = /* @__PURE__ */ new Map();
607
+ const set = map.set, remove = map.delete, clear = map.clear;
608
+ const wrap = (id, provider) => {
609
+ const wrapped = hooks.add(kind, provider);
610
+ originals.set(id, {
611
+ original: provider,
612
+ wrapped
613
+ });
614
+ return wrapped;
615
+ };
616
+ undo.push(() => {
617
+ for (const [id, row] of originals) if (map.get(id) === row.wrapped) set.call(map, id, row.original);
618
+ });
619
+ for (const [id, provider] of map) set.call(map, id, wrap(id, provider));
620
+ const replacements = {
621
+ set(id, provider) {
622
+ const wrapped = wrap(id, provider);
623
+ set.call(map, id, wrapped);
624
+ return map;
625
+ },
626
+ delete(id) {
627
+ const removed = remove.call(map, id);
628
+ if (removed) {
629
+ originals.delete(id);
630
+ hooks.remove(kind, id);
731
631
  }
732
- chunks.push(buffer);
733
- }
734
- let request;
735
- try {
736
- const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
737
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
738
- request = parsed;
739
- } catch {
740
- res.writeHead(400);
741
- res.end();
742
- return;
743
- }
744
- if (request.type !== "client-request" || request.method !== endpoint || typeof request.rpcId !== "string") {
745
- res.writeHead(400);
746
- res.end();
747
- return;
748
- }
749
- const result = await call(endpoint, request.payload, controller.signal);
750
- if (!res.destroyed) {
751
- res.writeHead(200, {
752
- "content-type": "application/json",
753
- "cache-control": "no-store"
754
- });
755
- res.end(JSON.stringify({
756
- type: "server-response",
757
- rpcId: request.rpcId,
758
- result
759
- }));
760
- }
761
- } catch {
762
- if (!res.destroyed) {
763
- res.writeHead(500);
764
- res.end("network settings request failed");
632
+ return removed;
633
+ },
634
+ clear() {
635
+ const ids = [...map.keys()];
636
+ clear.call(map);
637
+ originals.clear();
638
+ for (const id of ids) hooks.remove(kind, id);
765
639
  }
640
+ };
641
+ for (const key of [
642
+ "set",
643
+ "delete",
644
+ "clear"
645
+ ]) {
646
+ const before = Object.getOwnPropertyDescriptor(map, key);
647
+ Object.defineProperty(map, key, {
648
+ value: replacements[key],
649
+ configurable: true,
650
+ writable: true
651
+ });
652
+ undo.push(() => {
653
+ if (map[key] !== replacements[key]) return;
654
+ if (before) Object.defineProperty(map, key, before);
655
+ else delete map[key];
656
+ });
766
657
  }
767
658
  }
768
- });
659
+ for (const key of ["searchProviderId", "fetchProviderId"]) {
660
+ const before = Object.getOwnPropertyDescriptor(runtime, key);
661
+ Object.defineProperty(runtime, key, {
662
+ ...before,
663
+ value: void 0
664
+ });
665
+ undo.push(() => {
666
+ if (runtime[key] === void 0) Object.defineProperty(runtime, key, before);
667
+ });
668
+ }
669
+ } catch (error) {
670
+ for (const restore of undo.reverse()) restore();
671
+ throw new WebError("无法接管 ctx.web 注册表,已恢复原状。", "WEB_REGISTRY_UNSUPPORTED", { cause: error });
672
+ }
673
+ let stopped = false;
674
+ return () => {
675
+ if (stopped) return;
676
+ stopped = true;
677
+ for (const restore of undo.reverse()) restore();
678
+ };
679
+ }
680
+ //#endregion
681
+ //#region src/builtins.ts
682
+ function registerBuiltins(web, configuration) {
683
+ const registered = [];
684
+ const registerSearch = (descriptor, factory) => {
685
+ if (descriptor.legacyId && hasWebProvider(web, "search", descriptor.legacyId)) return () => {};
686
+ if (hasWebProvider(web, "search", descriptor.id)) return () => {};
687
+ const off = registerNativeSearch(web, configuration, descriptor, factory);
688
+ registered.push(off);
689
+ return off;
690
+ };
691
+ const registerFetch = (descriptor, factory) => {
692
+ if (descriptor.legacyId && hasWebProvider(web, "fetch", descriptor.legacyId)) return () => {};
693
+ if (hasWebProvider(web, "fetch", descriptor.id)) return () => {};
694
+ const off = registerNativeFetch(web, configuration, descriptor, factory);
695
+ registered.push(off);
696
+ return off;
697
+ };
698
+ try {
699
+ registerSearch({
700
+ id: "ddg",
701
+ label: "DuckDuckGo",
702
+ billing: "none",
703
+ description: "无需注册。默认后端。"
704
+ }, () => new SearchEngineProvider());
705
+ registerSearch({
706
+ id: "deepseek-managed",
707
+ legacyId: "deepseek-official",
708
+ label: "DeepSeek 搜索",
709
+ description: "使用已配置的 DeepSeek API Key,可能产生额外搜索费用。",
710
+ defaultBaseURL: DEEPSEEK_DEFAULT_BASE_URL,
711
+ credentialRef: "DEEPSEEK_API_KEY",
712
+ credentialShared: true,
713
+ credentialHint: "与模型设置共用 Key",
714
+ billing: "model-and-tools",
715
+ signupUrl: "https://platform.deepseek.com/api_keys"
716
+ }, (options) => new DeepSeekSearchProvider(() => ({
717
+ apiKey: options.apiKey,
718
+ baseURL: options.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
719
+ model: DEEPSEEK_DEFAULT_MODEL,
720
+ apiVersion: DEEPSEEK_DEFAULT_API_VERSION,
721
+ maxTokens: DEEPSEEK_DEFAULT_MAX_TOKENS,
722
+ maxUses: DEEPSEEK_DEFAULT_MAX_USES
723
+ })));
724
+ registerSearch({
725
+ id: "exa",
726
+ label: "Exa",
727
+ description: "独立 Search API。",
728
+ defaultBaseURL: "https://api.exa.ai",
729
+ credentialRef: "DSH_EDITOR_WEB_EXA_API_KEY",
730
+ billing: "request",
731
+ signupUrl: "https://dashboard.exa.ai/api-keys"
732
+ }, (options) => new ExaSearchProvider({
733
+ apiKey: options.apiKey ?? "",
734
+ baseURL: options.baseURL ?? "https://api.exa.ai",
735
+ searchType: "auto",
736
+ highlightsPerResult: 1
737
+ }));
738
+ registerSearch({
739
+ id: "brave",
740
+ label: "Brave",
741
+ description: "Brave Search API。",
742
+ defaultBaseURL: "https://api.search.brave.com",
743
+ credentialRef: "DSH_EDITOR_WEB_BRAVE_API_KEY",
744
+ billing: "request",
745
+ signupUrl: "https://api.search.brave.com"
746
+ }, (options) => new BraveSearchProvider({
747
+ apiKey: options.apiKey ?? "",
748
+ baseURL: options.baseURL
749
+ }));
750
+ registerSearch({
751
+ id: "bocha",
752
+ label: "博查",
753
+ description: "中文网页搜索 API。",
754
+ defaultBaseURL: "https://api.bochaai.com",
755
+ credentialRef: "DSH_EDITOR_WEB_BOCHA_API_KEY",
756
+ billing: "request",
757
+ signupUrl: "https://open.bochaai.com"
758
+ }, (options) => new BochaSearchProvider({
759
+ apiKey: options.apiKey ?? "",
760
+ baseURL: options.baseURL
761
+ }));
762
+ registerSearch({
763
+ id: "serper",
764
+ label: "Serper",
765
+ description: "Google 结果的 Search API。",
766
+ defaultBaseURL: "https://google.serper.dev",
767
+ credentialRef: "DSH_EDITOR_WEB_SERPER_API_KEY",
768
+ billing: "request",
769
+ signupUrl: "https://serper.dev"
770
+ }, (options) => new SerperSearchProvider({
771
+ apiKey: options.apiKey ?? "",
772
+ baseURL: options.baseURL
773
+ }));
774
+ registerSearch({
775
+ id: "firecrawl",
776
+ label: "Firecrawl",
777
+ description: "网页搜索与提取 API。",
778
+ defaultBaseURL: "https://api.firecrawl.dev",
779
+ credentialRef: "DSH_EDITOR_WEB_FIRECRAWL_API_KEY",
780
+ billing: "request",
781
+ signupUrl: "https://www.firecrawl.dev"
782
+ }, (options) => new FirecrawlSearchProvider({
783
+ apiKey: options.apiKey ?? "",
784
+ baseURL: options.baseURL
785
+ }));
786
+ registerSearch({
787
+ id: "tavily",
788
+ label: "Tavily",
789
+ defaultBaseURL: "https://api.tavily.com",
790
+ description: "Search API;固定 basic 检索,不自动升级搜索深度。",
791
+ credentialRef: "DSH_EDITOR_WEB_TAVILY_API_KEY",
792
+ billing: "request",
793
+ signupUrl: "https://app.tavily.com"
794
+ }, (options) => new TavilySearchProvider({
795
+ apiKey: options.apiKey ?? "",
796
+ baseURL: options.baseURL
797
+ }));
798
+ registerFetch({
799
+ id: "http-managed",
800
+ legacyId: "http",
801
+ label: "网页读取",
802
+ billing: "none",
803
+ description: "优先匿名 Jina Reader 读取,失败后本机 HTTP 直连;无需 Key。"
804
+ }, (options) => {
805
+ const limits = {
806
+ maxResponseBytes: 5e6,
807
+ maxBodyChars: options.maxFetchChars,
808
+ timeoutMs: options.timeoutMs,
809
+ maxRedirects: 5,
810
+ userAgent: "dsh-editor/managed-web"
811
+ };
812
+ const readerTimeoutMs = Math.min(15e3, Math.floor(options.timeoutMs / 2));
813
+ return new JinaFirstFetchProvider({
814
+ reader: new HttpFetchProvider({
815
+ ...limits,
816
+ maxBodyChars: limits.maxResponseBytes,
817
+ timeoutMs: readerTimeoutMs
818
+ }),
819
+ direct: new HttpFetchProvider(limits),
820
+ readerTimeoutMs
821
+ });
822
+ });
823
+ } catch (error) {
824
+ for (const off of registered.reverse()) off();
825
+ throw error;
826
+ }
827
+ return () => {
828
+ for (const off of registered.splice(0).reverse()) off();
829
+ };
830
+ }
831
+ //#endregion
832
+ //#region src/host-rpc.ts
833
+ /** Keep the historical transport error text while sharing admission and request handling. */
834
+ function registerHostRpc$1(ctx, channel, call) {
835
+ return registerHostRpc(ctx, channel, call, { errorBody: (status) => status === 500 ? "network settings request failed" : void 0 });
769
836
  }
770
837
  //#endregion
771
838
  //#region src/manager.ts
@@ -805,6 +872,9 @@ var WebSearchManager = class {
805
872
  suspended = false;
806
873
  disposed = false;
807
874
  storageFailed = false;
875
+ refreshRevision = 0;
876
+ detach;
877
+ legacyRegistrations = /* @__PURE__ */ new Set();
808
878
  constructor(options) {
809
879
  this.options = options;
810
880
  const initial = structuredClone(options.initial ?? defaultSettings());
@@ -813,6 +883,18 @@ var WebSearchManager = class {
813
883
  ...initial,
814
884
  searchOrder: migrateSearchOrder(initial)
815
885
  };
886
+ this.detach = observeWebRegistry(options.web, {
887
+ add: (kind, provider) => this.discover(kind, provider),
888
+ remove: (kind, id) => {
889
+ const key = providerKey(kind, id);
890
+ const entry = this.entries.get(key);
891
+ if (entry) {
892
+ this.abort(entry);
893
+ this.entries.delete(key);
894
+ this.notify();
895
+ }
896
+ }
897
+ });
816
898
  }
817
899
  subscribe(listener) {
818
900
  this.listeners.add(listener);
@@ -832,7 +914,15 @@ var WebSearchManager = class {
832
914
  return Boolean(this.entries.get(providerKey("search", id))?.descriptor.credentialRef);
833
915
  }
834
916
  configured(id) {
835
- return Boolean(this.entries.get(providerKey("search", id))?.configured);
917
+ const entry = this.entries.get(providerKey("search", id));
918
+ return Boolean(entry && this.ready(entry));
919
+ }
920
+ ready(entry) {
921
+ try {
922
+ return entry.configured && entry.provider.available();
923
+ } catch {
924
+ return false;
925
+ }
836
926
  }
837
927
  searchOrder(settings = this.settings) {
838
928
  return resolveSearchOrder(this.knownSearchIds(), settings, (id) => this.keyed(id));
@@ -841,7 +931,8 @@ var WebSearchManager = class {
841
931
  return pickActiveSearch(this.searchOrder(settings), (id) => this.configured(id));
842
932
  }
843
933
  selected(entry) {
844
- if (this.disposed || this.suspended || !entry.configured) return false;
934
+ if (this.disposed || this.suspended || !this.ready(entry)) return false;
935
+ if (this.entries.get(providerKey(entry.kind, entry.descriptor.id)) !== entry) return false;
845
936
  if (entry.kind === "fetch") return this.settings.fetchEnabled && this.settings.fetchProvider === entry.descriptor.id && this.entries.get(providerKey("fetch", entry.descriptor.id)) === entry;
846
937
  return this.settings.searchEnabled && this.activeSearchId() === entry.descriptor.id;
847
938
  }
@@ -855,8 +946,9 @@ var WebSearchManager = class {
855
946
  providers: entries.map((entry) => ({
856
947
  ...entry.descriptor,
857
948
  kind: entry.kind,
858
- configured: entry.configured,
859
- baseURL: this.settings.endpoints[providerKey(entry.kind, entry.descriptor.id)] ?? entry.descriptor.defaultBaseURL,
949
+ configured: this.ready(entry),
950
+ configurationOwned: entry.descriptor.configurationOwner === WEB_MANAGER_OWNER,
951
+ baseURL: entry.descriptor.configurationOwner === "@klarkxy/dsh-web-search-manager" ? this.settings.endpoints[providerKey(entry.kind, entry.descriptor.id)] ?? entry.descriptor.defaultBaseURL : void 0,
860
952
  calls: entry.calls,
861
953
  failures: entry.failures
862
954
  })).sort((a, b) => providerKey(a.kind, a.id).localeCompare(providerKey(b.kind, b.id)))
@@ -864,18 +956,20 @@ var WebSearchManager = class {
864
956
  }
865
957
  async refresh() {
866
958
  const epoch = this.epoch;
959
+ const revision = ++this.refreshRevision;
867
960
  await Promise.all([...this.entries.entries()].map(async ([key, entry]) => {
868
- let configured = !entry.descriptor.credentialRef;
869
- if (entry.descriptor.credentialRef) try {
961
+ let configured = true;
962
+ if (entry.descriptor.configurationOwner === "@klarkxy/dsh-web-search-manager" && entry.descriptor.credentialRef) try {
870
963
  configured = Boolean((await this.options.resolveCredential(entry.descriptor.credentialRef))?.trim());
871
964
  } catch {
872
965
  configured = false;
873
966
  }
874
- if (this.disposed || epoch !== this.epoch || this.entries.get(key) !== entry) return;
967
+ if (this.disposed || epoch !== this.epoch || revision !== this.refreshRevision || this.entries.get(key) !== entry) return;
875
968
  if (entry.configured !== configured) {
876
969
  entry.configured = configured;
877
970
  if (!configured) this.abort(entry);
878
971
  }
972
+ if (!this.ready(entry)) this.abort(entry);
879
973
  }));
880
974
  this.notify();
881
975
  return this.status();
@@ -883,99 +977,97 @@ var WebSearchManager = class {
883
977
  abort(entry) {
884
978
  for (const target of entry ? [entry] : this.entries.values()) for (const controller of target.active) controller.abort();
885
979
  }
886
- add(kind, descriptor) {
887
- if (this.disposed) fail("WEB_DISABLED", "网络搜索插件已停止。");
888
- const key = providerKey(kind, descriptor.id);
889
- if (!/^[a-z][a-z0-9-]{0,63}$/.test(descriptor.id) || !descriptor.label.trim()) fail("WEB_INVALID_PROVIDER", "供应商标识或名称无效。");
890
- if (descriptor.credentialRef && !/^[A-Z][A-Z0-9_]{1,127}$/.test(descriptor.credentialRef)) fail("WEB_INVALID_PROVIDER", "供应商凭据引用无效。");
891
- if (descriptor.defaultBaseURL) validateBaseURL(descriptor.defaultBaseURL);
892
- for (const [value, label] of [[descriptor.signupUrl, "注册地址"], [descriptor.pricingUrl, "费用说明地址"]]) {
893
- if (!value) continue;
894
- try {
895
- const url = new URL(value);
896
- if (url.protocol !== "https:" || url.username || url.password) fail("WEB_INVALID_PROVIDER", `${label}须为 HTTPS。`);
897
- } catch {
898
- fail("WEB_INVALID_PROVIDER", `${label}须为 HTTPS。`);
899
- }
900
- }
901
- if (this.entries.has(key)) fail("WEB_DUPLICATE_PROVIDER", "供应商标识已注册。");
980
+ discover(kind, provider) {
981
+ const key = providerKey(kind, provider.id);
982
+ const old = this.entries.get(key);
983
+ if (old) this.abort(old);
984
+ const metadata = provider.dshWebManagement;
985
+ const descriptor = {
986
+ id: provider.id,
987
+ label: typeof metadata?.label === "string" && metadata.label ? metadata.label : provider.id,
988
+ description: typeof metadata?.description === "string" ? metadata.description : "",
989
+ billing: [
990
+ "none",
991
+ "request",
992
+ "model-and-tools"
993
+ ].includes(metadata?.billing ?? "") ? metadata.billing : "unknown",
994
+ configurationOwner: typeof metadata?.configurationOwner === "string" && metadata.configurationOwner ? metadata.configurationOwner : void 0,
995
+ configurationUrl: safeConfigurationUrl(metadata?.configurationUrl)
996
+ };
997
+ for (const field of [
998
+ "credentialHint",
999
+ "pricing",
1000
+ "defaultBaseURL"
1001
+ ]) if (typeof metadata?.[field] === "string") descriptor[field] = metadata[field];
1002
+ if (typeof metadata?.credentialRef === "string" && /^[A-Z][A-Z0-9_]{1,127}$/.test(metadata.credentialRef)) descriptor.credentialRef = metadata.credentialRef;
1003
+ if (metadata?.credentialShared === true) descriptor.credentialShared = true;
1004
+ for (const field of ["signupUrl", "pricingUrl"]) try {
1005
+ const url = new URL(metadata?.[field] ?? "");
1006
+ if (url.protocol === "https:" && !url.username && !url.password) descriptor[field] = metadata[field];
1007
+ } catch {}
902
1008
  const entry = {
903
- descriptor: Object.freeze({ ...descriptor }),
1009
+ descriptor: Object.freeze(descriptor),
904
1010
  kind,
905
- configured: !descriptor.credentialRef,
1011
+ provider,
1012
+ configured: descriptor.configurationOwner !== "@klarkxy/dsh-web-search-manager" || !descriptor.credentialRef,
906
1013
  calls: 0,
907
1014
  failures: 0,
908
- unregister() {},
909
1015
  active: /* @__PURE__ */ new Set()
910
1016
  };
911
1017
  this.entries.set(key, entry);
912
- return entry;
913
- }
914
- registration(entry, register) {
915
- const key = providerKey(entry.kind, entry.descriptor.id);
916
- try {
917
- entry.unregister = register();
918
- } catch (error) {
919
- this.entries.delete(key);
920
- throw error;
1018
+ const legacy = metadata?.legacyId;
1019
+ const expectedLegacy = kind === "search" && provider.id === "deepseek-managed" ? "deepseek-official" : kind === "fetch" && provider.id === "http-managed" ? "http" : void 0;
1020
+ if (descriptor.configurationOwner === "@klarkxy/dsh-web-search-manager" && legacy === expectedLegacy && legacy && !this.entries.has(providerKey(kind, legacy))) {
1021
+ if (kind === "search") {
1022
+ this.settings.searchOrder = this.settings.searchOrder.map((id) => id === legacy ? provider.id : id);
1023
+ if (this.settings.searchProvider === legacy) this.settings.searchProvider = provider.id;
1024
+ } else if (this.settings.fetchProvider === legacy) this.settings.fetchProvider = provider.id;
1025
+ const legacyKey = providerKey(kind, legacy);
1026
+ if (this.settings.endpoints[legacyKey] && !this.settings.endpoints[key]) this.settings.endpoints[key] = this.settings.endpoints[legacyKey];
921
1027
  }
922
1028
  this.refresh();
923
- let removed = false;
924
- return () => {
925
- if (removed) return;
926
- removed = true;
927
- this.abort(entry);
928
- this.entries.delete(key);
929
- entry.unregister();
930
- this.notify();
931
- };
932
- }
933
- registerSearchProvider(descriptor, factory) {
934
- const entry = this.add("search", descriptor);
935
- return this.registration(entry, () => this.options.web.registerSearchProvider({
936
- id: descriptor.id,
937
- available: () => this.selected(entry),
938
- search: (request, signal) => {
939
- if (typeof request.query !== "string" || !request.query.trim() || request.query.length > 4e3) fail("WEB_INVALID_REQUEST", "查询须为 1 至 4000 字符。");
940
- const count = Math.min(bounded(request.maxResults ?? this.settings.maxResults, 1, 100, "结果数"), this.settings.maxResults);
941
- return this.execute(entry, signal, async (options, combined) => {
942
- const provider = factory(options);
943
- if (!provider.available()) fail("WEB_PROVIDER_ERROR", "供应商配置不可用。");
944
- const result = await provider.search({
945
- query: request.query.trim(),
946
- maxResults: count
947
- }, combined);
948
- const sources = result.sources.slice(0, count).filter((source) => {
949
- try {
950
- const url = new URL(source.url);
951
- return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password;
952
- } catch {
953
- return false;
954
- }
955
- }).map((source) => ({
956
- ...source,
957
- title: source.title?.slice(0, 1e3),
958
- snippet: source.snippet?.slice(0, 8e3)
959
- }));
960
- return {
961
- ...result,
962
- content: result.content?.slice(0, 4e4),
963
- sources,
964
- truncated: result.truncated || result.sources.length > count
965
- };
966
- });
967
- }
968
- }));
969
- }
970
- registerFetchProvider(descriptor, factory) {
971
- const entry = this.add("fetch", descriptor);
972
- return this.registration(entry, () => this.options.web.registerFetchProvider({
973
- id: descriptor.id,
1029
+ if (kind === "search") {
1030
+ const search = provider;
1031
+ return {
1032
+ id: provider.id,
1033
+ available: () => this.selected(entry),
1034
+ search: (request, signal) => {
1035
+ if (typeof request.query !== "string" || !request.query.trim() || request.query.length > 4e3) fail("WEB_INVALID_REQUEST", "查询须为 1 至 4000 字符。");
1036
+ const count = Math.min(bounded(request.maxResults ?? this.settings.maxResults, 1, 100, "结果数"), this.settings.maxResults);
1037
+ return this.execute(entry, signal, async (_options, combined) => {
1038
+ const result = await search.search({
1039
+ ...request,
1040
+ query: request.query.trim(),
1041
+ maxResults: count
1042
+ }, combined);
1043
+ const sources = result.sources.slice(0, count).filter((source) => {
1044
+ try {
1045
+ const url = new URL(source.url);
1046
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password;
1047
+ } catch {
1048
+ return false;
1049
+ }
1050
+ }).map((source) => ({
1051
+ ...source,
1052
+ title: source.title?.slice(0, 1e3),
1053
+ snippet: source.snippet?.slice(0, 8e3)
1054
+ }));
1055
+ return {
1056
+ ...result,
1057
+ content: result.content?.slice(0, 4e4),
1058
+ sources,
1059
+ truncated: result.truncated || result.sources.length > count
1060
+ };
1061
+ });
1062
+ }
1063
+ };
1064
+ }
1065
+ const fetch = provider;
1066
+ return {
1067
+ id: provider.id,
974
1068
  available: () => this.selected(entry),
975
1069
  fetch: (request, signal) => this.execute(entry, signal, async (options, combined) => {
976
- const provider = factory(options);
977
- if (!provider.available()) fail("WEB_PROVIDER_ERROR", "供应商配置不可用。");
978
- const result = await provider.fetch(request, combined);
1070
+ const result = await fetch.fetch(request, combined);
979
1071
  const content = result.body.content.slice(0, options.maxFetchChars);
980
1072
  return {
981
1073
  ...result,
@@ -986,7 +1078,31 @@ var WebSearchManager = class {
986
1078
  truncated: result.truncated || content.length !== result.body.content.length
987
1079
  };
988
1080
  })
989
- }));
1081
+ };
1082
+ }
1083
+ /** @deprecated Provider plugins should register directly with ctx.web. */
1084
+ registerSearchProvider(descriptor, factory) {
1085
+ return this.legacyRegistration(() => registerNativeSearch(this.options.web, this.providerConfiguration(), descriptor, factory));
1086
+ }
1087
+ /** @deprecated Provider plugins should register directly with ctx.web. */
1088
+ registerFetchProvider(descriptor, factory) {
1089
+ return this.legacyRegistration(() => registerNativeFetch(this.options.web, this.providerConfiguration(), descriptor, factory));
1090
+ }
1091
+ providerConfiguration() {
1092
+ return {
1093
+ settings: () => structuredClone(this.settings),
1094
+ resolveCredential: (ref) => this.options.resolveCredential(ref)
1095
+ };
1096
+ }
1097
+ legacyRegistration(register) {
1098
+ if (this.disposed) fail("WEB_DISABLED", "网络搜索插件已停止。");
1099
+ const unregister = register();
1100
+ const off = () => {
1101
+ this.legacyRegistrations.delete(off);
1102
+ unregister();
1103
+ };
1104
+ this.legacyRegistrations.add(off);
1105
+ return off;
990
1106
  }
991
1107
  async execute(entry, signal, run) {
992
1108
  if (!this.selected(entry)) fail("WEB_DISABLED", "请在设置 → 网络搜索中配置凭据并启用。");
@@ -996,19 +1112,9 @@ var WebSearchManager = class {
996
1112
  const epoch = this.epoch;
997
1113
  entry.active.add(controller);
998
1114
  let started = false;
999
- let apiKey;
1000
1115
  try {
1001
- const ref = entry.descriptor.credentialRef;
1002
- apiKey = ref ? await abortable(() => this.options.resolveCredential(ref), combined) : void 0;
1003
- if (ref && !apiKey?.trim()) {
1004
- entry.configured = false;
1005
- this.abort(entry);
1006
- this.notify();
1007
- fail("WEB_CREDENTIAL_MISSING", "搜索凭据已删除或不可用,请重新配置。");
1008
- }
1009
1116
  if (epoch !== this.epoch || !this.selected(entry)) fail("WEB_DISABLED", "网络访问设置已改变,请重新发起请求。");
1010
1117
  const options = {
1011
- apiKey,
1012
1118
  baseURL: this.settings.endpoints[providerKey(entry.kind, entry.descriptor.id)] ?? entry.descriptor.defaultBaseURL,
1013
1119
  timeoutMs: this.settings.timeoutMs,
1014
1120
  maxFetchChars: this.settings.maxFetchChars
@@ -1020,8 +1126,13 @@ var WebSearchManager = class {
1020
1126
  return result;
1021
1127
  } catch (error) {
1022
1128
  if (started) entry.failures += 1;
1129
+ if (error instanceof WebError && error.code === "WEB_CREDENTIAL_MISSING") {
1130
+ entry.configured = false;
1131
+ this.abort(entry);
1132
+ this.notify();
1133
+ }
1023
1134
  if (combined.aborted && !(error instanceof WebError && error.code === "WEB_CREDENTIAL_MISSING")) throw new WebError("网络请求已取消或超时。", "WEB_ABORTED");
1024
- const safe = providerError(error, apiKey ? [apiKey] : []);
1135
+ const safe = providerError(error);
1025
1136
  throw new WebError(`[${entry.kind}:${entry.descriptor.id}] ${safe.message}`, safe.code);
1026
1137
  } finally {
1027
1138
  clearTimeout(timeout);
@@ -1042,7 +1153,7 @@ var WebSearchManager = class {
1042
1153
  bounded(proposed.maxFetchChars, 1e3, 2e5, "正文长度");
1043
1154
  for (const [key, value] of Object.entries(proposed.endpoints)) {
1044
1155
  const entry = this.entries.get(key);
1045
- if (entry && !entry.descriptor.defaultBaseURL) fail("WEB_INVALID_CONFIG", "供应商不支持自定义地址。");
1156
+ if (entry && (entry.descriptor.configurationOwner !== "@klarkxy/dsh-web-search-manager" || !entry.descriptor.defaultBaseURL) && value !== this.settings.endpoints[key]) fail("WEB_INVALID_CONFIG", "供应商不支持自定义地址。");
1046
1157
  try {
1047
1158
  proposed.endpoints[key] = validateBaseURL(value);
1048
1159
  } catch {
@@ -1050,18 +1161,13 @@ var WebSearchManager = class {
1050
1161
  }
1051
1162
  }
1052
1163
  await this.refresh();
1053
- proposed.searchOrder = this.searchOrder(proposed);
1164
+ proposed.searchOrder = [...new Set(proposed.searchOrder)];
1054
1165
  proposed.searchProvider = this.activeSearchId(proposed);
1055
- if (proposed.searchEnabled && !proposed.searchProvider) fail("WEB_CREDENTIAL_MISSING", "需选择已安装的供应商并填写有效凭据。");
1056
- if (proposed.searchEnabled && this.entries.get(providerKey("fetch", "http"))) {
1057
- proposed.fetchEnabled = true;
1058
- proposed.fetchProvider = "http";
1059
- }
1060
- if (!proposed.searchEnabled) proposed.fetchEnabled = false;
1166
+ const activatingSearch = !this.settings.searchEnabled || JSON.stringify(proposed.searchOrder) !== JSON.stringify(this.settings.searchOrder);
1167
+ if (proposed.searchEnabled && !proposed.searchProvider && activatingSearch) fail("WEB_CREDENTIAL_MISSING", "需选择已安装的供应商并填写有效凭据。");
1061
1168
  if (proposed.fetchEnabled) {
1062
1169
  const entry = this.entries.get(providerKey("fetch", proposed.fetchProvider));
1063
- if (!entry) proposed.fetchEnabled = false;
1064
- else if (!entry.configured) fail("WEB_CREDENTIAL_MISSING", "需选择已安装的供应商并填写有效凭据。");
1170
+ if ((!this.settings.fetchEnabled || proposed.fetchProvider !== this.settings.fetchProvider) && (!entry || !this.ready(entry))) fail("WEB_CREDENTIAL_MISSING", "需选择已安装的供应商并填写有效凭据。");
1065
1171
  }
1066
1172
  this.suspended = true;
1067
1173
  this.epoch += 1;
@@ -1088,10 +1194,11 @@ var WebSearchManager = class {
1088
1194
  this.epoch += 1;
1089
1195
  this.abort();
1090
1196
  this.notify();
1091
- for (const entry of this.entries.values()) entry.unregister();
1197
+ for (const off of [...this.legacyRegistrations]) off();
1198
+ await this.pending;
1199
+ this.detach();
1092
1200
  this.entries.clear();
1093
1201
  this.listeners.clear();
1094
- await this.pending;
1095
1202
  }
1096
1203
  };
1097
1204
  //#endregion
@@ -1135,20 +1242,30 @@ const inject = [
1135
1242
  async function apply(ctx) {
1136
1243
  const host = ctx;
1137
1244
  const domain = await ctx.storageDomain.open(webSettingsDomain);
1245
+ let managed;
1246
+ ctx.effect(() => async () => {
1247
+ await managed?.dispose();
1248
+ await domain.close();
1249
+ }, "web-search-manager.dispose");
1138
1250
  const table = domain.table("settings");
1139
- const manager = new WebSearchManager({
1251
+ const manager = managed = new WebSearchManager({
1140
1252
  web: ctx.web,
1141
1253
  initial: table.get("global"),
1142
1254
  resolveCredential: async (ref) => (await host.credentials.resolve(credentialRef(ref)))?.value,
1143
1255
  save: (settings) => table.put("global", settings)
1144
1256
  });
1145
- ctx.effect(() => async () => {
1146
- await manager.dispose();
1147
- await domain.close();
1148
- }, "web-search-manager.dispose");
1149
1257
  ctx.provide("webSearchManager", manager);
1150
- ctx.effect(() => registerBuiltins(manager), "web-search-manager.providers");
1151
- ctx.effect(() => registerHostRpc(host, WEB_SEARCH_RPC_CHANNEL, async (endpoint, payload, signal) => {
1258
+ ctx.on("credentials/reference-updated", () => {
1259
+ manager.refresh();
1260
+ });
1261
+ ctx.on(WEB_PROVIDER_AVAILABILITY_EVENT, () => {
1262
+ manager.refresh();
1263
+ });
1264
+ ctx.effect(() => registerBuiltins(ctx.web, {
1265
+ settings: () => manager.status().settings,
1266
+ resolveCredential: async (ref) => (await host.credentials.resolve(credentialRef(ref)))?.value
1267
+ }), "web-search-manager.providers");
1268
+ ctx.effect(() => registerHostRpc$1(host, WEB_SEARCH_RPC_CHANNEL, async (endpoint, payload, signal) => {
1152
1269
  try {
1153
1270
  signal.throwIfAborted();
1154
1271
  if (endpoint === "status") return {