@klarkxy/dsh-web-search-manager 0.1.10 → 0.2.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.
package/lib/index.js CHANGED
@@ -1,10 +1,11 @@
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
4
  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
5
  import { ExaSearchProvider } from "@deepseek-ai/dsh-web-search-exa";
6
6
  import { HttpFetchProvider } from "@deepseek-ai/dsh-web-fetch-http";
7
7
  import { isIP } from "node:net";
8
+ import { registerHostRpc } from "@klarkxy/dsh-plugin-kit/host-rpc";
8
9
  import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
9
10
  import { z } from "zod";
10
11
  //#region src/jina-fetch.ts
@@ -547,225 +548,331 @@ var TavilySearchProvider = class {
547
548
  }
548
549
  };
549
550
  //#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
551
+ //#region src/native-providers.ts
552
+ function validate(descriptor) {
553
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(descriptor.id) || !descriptor.label.trim()) throw new WebError("供应商标识或名称无效。", "WEB_INVALID_PROVIDER");
554
+ if (descriptor.credentialRef && !/^[A-Z][A-Z0-9_]{1,127}$/.test(descriptor.credentialRef)) throw new WebError("供应商凭据引用无效。", "WEB_INVALID_PROVIDER");
555
+ if (descriptor.defaultBaseURL) validateBaseURL(descriptor.defaultBaseURL);
556
+ for (const [value, label] of [[descriptor.signupUrl, "注册地址"], [descriptor.pricingUrl, "费用说明地址"]]) {
557
+ if (!value) continue;
558
+ try {
559
+ const url = new URL(value);
560
+ if (url.protocol !== "https:" || url.username || url.password) throw new Error();
561
+ } catch {
562
+ throw new WebError(`${label}须为 HTTPS。`, "WEB_INVALID_PROVIDER");
563
+ }
564
+ }
565
+ }
566
+ async function configuredCall(kind, descriptor, configuration, signal, run) {
567
+ let apiKey;
568
+ try {
569
+ signal?.throwIfAborted();
570
+ apiKey = descriptor.credentialRef ? await configuration.resolveCredential(descriptor.credentialRef) : void 0;
571
+ signal?.throwIfAborted();
572
+ if (descriptor.credentialRef && !apiKey?.trim()) throw new WebError("搜索凭据已删除或不可用,请重新配置。", "WEB_CREDENTIAL_MISSING");
573
+ const settings = configuration.settings();
574
+ return await run({
575
+ apiKey,
576
+ baseURL: settings.endpoints[providerKey(kind, descriptor.id)] ?? descriptor.defaultBaseURL,
577
+ timeoutMs: settings.timeoutMs,
578
+ maxFetchChars: settings.maxFetchChars
672
579
  });
580
+ } catch (error) {
581
+ throw providerError(error, apiKey ? [apiKey] : []);
582
+ }
583
+ }
584
+ /** Bundle-owned adapters register through the same native ctx.web entry as any plugin. */
585
+ function registerNativeSearch(web, configuration, descriptor, factory) {
586
+ validate(descriptor);
587
+ return web.registerSearchProvider({
588
+ id: descriptor.id,
589
+ dshWebManagement: Object.freeze({
590
+ ...descriptor,
591
+ configurationOwner: WEB_MANAGER_OWNER
592
+ }),
593
+ available: () => true,
594
+ search: (request, signal) => configuredCall("search", descriptor, configuration, signal, (options) => {
595
+ const provider = factory(options);
596
+ if (!provider.available()) throw new WebError("供应商配置不可用。", "WEB_PROVIDER_ERROR");
597
+ return provider.search(request, signal);
598
+ })
599
+ });
600
+ }
601
+ function registerNativeFetch(web, configuration, descriptor, factory) {
602
+ validate(descriptor);
603
+ return web.registerFetchProvider({
604
+ id: descriptor.id,
605
+ dshWebManagement: Object.freeze({
606
+ ...descriptor,
607
+ configurationOwner: WEB_MANAGER_OWNER
608
+ }),
609
+ available: () => true,
610
+ fetch: (request, signal) => configuredCall("fetch", descriptor, configuration, signal, (options) => {
611
+ const provider = factory(options);
612
+ if (!provider.available()) throw new WebError("供应商配置不可用。", "WEB_PROVIDER_ERROR");
613
+ return provider.fetch(request, signal);
614
+ })
673
615
  });
674
- return () => {
675
- offHttp();
676
- offTavily();
677
- offFirecrawl();
678
- offSerper();
679
- offBocha();
680
- offBrave();
681
- offExa();
682
- offDeepSeek();
683
- offEngine();
684
- };
685
616
  }
686
617
  //#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();
618
+ //#region src/web-registry.ts
619
+ const owner = Symbol.for("@klarkxy/dsh-web-search-manager/registry-owner");
620
+ /** The only private-host boundary. Verified against DSH 0.2.0-rc.2.
621
+ * Keep the actual Maps: Cordis effects capture them for automatic unregistration.
622
+ * Replace this bridge when the host exposes enumeration/selection publicly.
623
+ */
624
+ function runtimeOf(web) {
625
+ const runtime = web;
626
+ 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");
627
+ return runtime;
628
+ }
629
+ function hasWebProvider(web, kind, id) {
630
+ return runtimeOf(web)[kind === "search" ? "searchProviders" : "fetchProviders"].has(id);
631
+ }
632
+ function observeWebRegistry(web, hooks) {
633
+ const runtime = runtimeOf(web);
634
+ const maps = [runtime.searchProviders, runtime.fetchProviders];
635
+ if (maps.some((map) => Object.hasOwn(map, owner))) throw new WebError("ctx.web 已由另一个搜索管理器管理。", "WEB_REGISTRY_MANAGED");
636
+ const undo = [];
637
+ try {
638
+ for (const [kind, map] of [["search", maps[0]], ["fetch", maps[1]]]) {
639
+ Object.defineProperty(map, owner, {
640
+ value: true,
641
+ configurable: true
719
642
  });
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;
643
+ undo.push(() => {
644
+ delete map[owner];
645
+ });
646
+ const originals = /* @__PURE__ */ new Map();
647
+ const set = map.set, remove = map.delete, clear = map.clear;
648
+ const wrap = (id, provider) => {
649
+ const wrapped = hooks.add(kind, provider);
650
+ originals.set(id, {
651
+ original: provider,
652
+ wrapped
653
+ });
654
+ return wrapped;
655
+ };
656
+ undo.push(() => {
657
+ for (const [id, row] of originals) if (map.get(id) === row.wrapped) set.call(map, id, row.original);
658
+ });
659
+ for (const [id, provider] of map) set.call(map, id, wrap(id, provider));
660
+ const replacements = {
661
+ set(id, provider) {
662
+ const wrapped = wrap(id, provider);
663
+ set.call(map, id, wrapped);
664
+ return map;
665
+ },
666
+ delete(id) {
667
+ const removed = remove.call(map, id);
668
+ if (removed) {
669
+ originals.delete(id);
670
+ hooks.remove(kind, id);
731
671
  }
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");
672
+ return removed;
673
+ },
674
+ clear() {
675
+ const ids = [...map.keys()];
676
+ clear.call(map);
677
+ originals.clear();
678
+ for (const id of ids) hooks.remove(kind, id);
765
679
  }
680
+ };
681
+ for (const key of [
682
+ "set",
683
+ "delete",
684
+ "clear"
685
+ ]) {
686
+ const before = Object.getOwnPropertyDescriptor(map, key);
687
+ Object.defineProperty(map, key, {
688
+ value: replacements[key],
689
+ configurable: true,
690
+ writable: true
691
+ });
692
+ undo.push(() => {
693
+ if (map[key] !== replacements[key]) return;
694
+ if (before) Object.defineProperty(map, key, before);
695
+ else delete map[key];
696
+ });
766
697
  }
767
698
  }
768
- });
699
+ for (const key of ["searchProviderId", "fetchProviderId"]) {
700
+ const before = Object.getOwnPropertyDescriptor(runtime, key);
701
+ Object.defineProperty(runtime, key, {
702
+ ...before,
703
+ value: void 0
704
+ });
705
+ undo.push(() => {
706
+ if (runtime[key] === void 0) Object.defineProperty(runtime, key, before);
707
+ });
708
+ }
709
+ } catch (error) {
710
+ for (const restore of undo.reverse()) restore();
711
+ throw new WebError("无法接管 ctx.web 注册表,已恢复原状。", "WEB_REGISTRY_UNSUPPORTED", { cause: error });
712
+ }
713
+ let stopped = false;
714
+ return () => {
715
+ if (stopped) return;
716
+ stopped = true;
717
+ for (const restore of undo.reverse()) restore();
718
+ };
719
+ }
720
+ //#endregion
721
+ //#region src/builtins.ts
722
+ function registerBuiltins(web, configuration) {
723
+ const registered = [];
724
+ const registerSearch = (descriptor, factory) => {
725
+ if (descriptor.legacyId && hasWebProvider(web, "search", descriptor.legacyId)) return () => {};
726
+ if (hasWebProvider(web, "search", descriptor.id)) return () => {};
727
+ const off = registerNativeSearch(web, configuration, descriptor, factory);
728
+ registered.push(off);
729
+ return off;
730
+ };
731
+ const registerFetch = (descriptor, factory) => {
732
+ if (descriptor.legacyId && hasWebProvider(web, "fetch", descriptor.legacyId)) return () => {};
733
+ if (hasWebProvider(web, "fetch", descriptor.id)) return () => {};
734
+ const off = registerNativeFetch(web, configuration, descriptor, factory);
735
+ registered.push(off);
736
+ return off;
737
+ };
738
+ try {
739
+ registerSearch({
740
+ id: "ddg",
741
+ label: "DuckDuckGo",
742
+ billing: "none",
743
+ description: "无需注册。默认后端。"
744
+ }, () => new SearchEngineProvider());
745
+ registerSearch({
746
+ id: "deepseek-managed",
747
+ legacyId: "deepseek-official",
748
+ label: "DeepSeek 搜索",
749
+ description: "使用已配置的 DeepSeek API Key,可能产生额外搜索费用。",
750
+ defaultBaseURL: DEEPSEEK_DEFAULT_BASE_URL,
751
+ credentialRef: "DEEPSEEK_API_KEY",
752
+ credentialShared: true,
753
+ credentialHint: "与模型设置共用 Key",
754
+ billing: "model-and-tools",
755
+ signupUrl: "https://platform.deepseek.com/api_keys"
756
+ }, (options) => new DeepSeekSearchProvider(() => ({
757
+ apiKey: options.apiKey,
758
+ baseURL: options.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL,
759
+ model: DEEPSEEK_DEFAULT_MODEL,
760
+ apiVersion: DEEPSEEK_DEFAULT_API_VERSION,
761
+ maxTokens: DEEPSEEK_DEFAULT_MAX_TOKENS,
762
+ maxUses: DEEPSEEK_DEFAULT_MAX_USES
763
+ })));
764
+ registerSearch({
765
+ id: "exa",
766
+ label: "Exa",
767
+ description: "独立 Search API。",
768
+ defaultBaseURL: "https://api.exa.ai",
769
+ credentialRef: "DSH_EDITOR_WEB_EXA_API_KEY",
770
+ billing: "request",
771
+ signupUrl: "https://dashboard.exa.ai/api-keys"
772
+ }, (options) => new ExaSearchProvider({
773
+ apiKey: options.apiKey ?? "",
774
+ baseURL: options.baseURL ?? "https://api.exa.ai",
775
+ searchType: "auto",
776
+ highlightsPerResult: 1
777
+ }));
778
+ registerSearch({
779
+ id: "brave",
780
+ label: "Brave",
781
+ description: "Brave Search API。",
782
+ defaultBaseURL: "https://api.search.brave.com",
783
+ credentialRef: "DSH_EDITOR_WEB_BRAVE_API_KEY",
784
+ billing: "request",
785
+ signupUrl: "https://api.search.brave.com"
786
+ }, (options) => new BraveSearchProvider({
787
+ apiKey: options.apiKey ?? "",
788
+ baseURL: options.baseURL
789
+ }));
790
+ registerSearch({
791
+ id: "bocha",
792
+ label: "博查",
793
+ description: "中文网页搜索 API。",
794
+ defaultBaseURL: "https://api.bochaai.com",
795
+ credentialRef: "DSH_EDITOR_WEB_BOCHA_API_KEY",
796
+ billing: "request",
797
+ signupUrl: "https://open.bochaai.com"
798
+ }, (options) => new BochaSearchProvider({
799
+ apiKey: options.apiKey ?? "",
800
+ baseURL: options.baseURL
801
+ }));
802
+ registerSearch({
803
+ id: "serper",
804
+ label: "Serper",
805
+ description: "Google 结果的 Search API。",
806
+ defaultBaseURL: "https://google.serper.dev",
807
+ credentialRef: "DSH_EDITOR_WEB_SERPER_API_KEY",
808
+ billing: "request",
809
+ signupUrl: "https://serper.dev"
810
+ }, (options) => new SerperSearchProvider({
811
+ apiKey: options.apiKey ?? "",
812
+ baseURL: options.baseURL
813
+ }));
814
+ registerSearch({
815
+ id: "firecrawl",
816
+ label: "Firecrawl",
817
+ description: "网页搜索与提取 API。",
818
+ defaultBaseURL: "https://api.firecrawl.dev",
819
+ credentialRef: "DSH_EDITOR_WEB_FIRECRAWL_API_KEY",
820
+ billing: "request",
821
+ signupUrl: "https://www.firecrawl.dev"
822
+ }, (options) => new FirecrawlSearchProvider({
823
+ apiKey: options.apiKey ?? "",
824
+ baseURL: options.baseURL
825
+ }));
826
+ registerSearch({
827
+ id: "tavily",
828
+ label: "Tavily",
829
+ defaultBaseURL: "https://api.tavily.com",
830
+ description: "Search API;固定 basic 检索,不自动升级搜索深度。",
831
+ credentialRef: "DSH_EDITOR_WEB_TAVILY_API_KEY",
832
+ billing: "request",
833
+ signupUrl: "https://app.tavily.com"
834
+ }, (options) => new TavilySearchProvider({
835
+ apiKey: options.apiKey ?? "",
836
+ baseURL: options.baseURL
837
+ }));
838
+ registerFetch({
839
+ id: "http-managed",
840
+ legacyId: "http",
841
+ label: "网页读取",
842
+ billing: "none",
843
+ description: "优先匿名 Jina Reader 读取,失败后本机 HTTP 直连;无需 Key。"
844
+ }, (options) => {
845
+ const limits = {
846
+ maxResponseBytes: 5e6,
847
+ maxBodyChars: options.maxFetchChars,
848
+ timeoutMs: options.timeoutMs,
849
+ maxRedirects: 5,
850
+ userAgent: "dsh-editor/managed-web"
851
+ };
852
+ const readerTimeoutMs = Math.min(15e3, Math.floor(options.timeoutMs / 2));
853
+ return new JinaFirstFetchProvider({
854
+ reader: new HttpFetchProvider({
855
+ ...limits,
856
+ maxBodyChars: limits.maxResponseBytes,
857
+ timeoutMs: readerTimeoutMs
858
+ }),
859
+ direct: new HttpFetchProvider(limits),
860
+ readerTimeoutMs
861
+ });
862
+ });
863
+ } catch (error) {
864
+ for (const off of registered.reverse()) off();
865
+ throw error;
866
+ }
867
+ return () => {
868
+ for (const off of registered.splice(0).reverse()) off();
869
+ };
870
+ }
871
+ //#endregion
872
+ //#region src/host-rpc.ts
873
+ /** Keep the historical transport error text while sharing admission and request handling. */
874
+ function registerHostRpc$1(ctx, channel, call) {
875
+ return registerHostRpc(ctx, channel, call, { errorBody: (status) => status === 500 ? "network settings request failed" : void 0 });
769
876
  }
770
877
  //#endregion
771
878
  //#region src/manager.ts
@@ -805,6 +912,9 @@ var WebSearchManager = class {
805
912
  suspended = false;
806
913
  disposed = false;
807
914
  storageFailed = false;
915
+ refreshRevision = 0;
916
+ detach;
917
+ legacyRegistrations = /* @__PURE__ */ new Set();
808
918
  constructor(options) {
809
919
  this.options = options;
810
920
  const initial = structuredClone(options.initial ?? defaultSettings());
@@ -813,6 +923,18 @@ var WebSearchManager = class {
813
923
  ...initial,
814
924
  searchOrder: migrateSearchOrder(initial)
815
925
  };
926
+ this.detach = observeWebRegistry(options.web, {
927
+ add: (kind, provider) => this.discover(kind, provider),
928
+ remove: (kind, id) => {
929
+ const key = providerKey(kind, id);
930
+ const entry = this.entries.get(key);
931
+ if (entry) {
932
+ this.abort(entry);
933
+ this.entries.delete(key);
934
+ this.notify();
935
+ }
936
+ }
937
+ });
816
938
  }
817
939
  subscribe(listener) {
818
940
  this.listeners.add(listener);
@@ -832,7 +954,15 @@ var WebSearchManager = class {
832
954
  return Boolean(this.entries.get(providerKey("search", id))?.descriptor.credentialRef);
833
955
  }
834
956
  configured(id) {
835
- return Boolean(this.entries.get(providerKey("search", id))?.configured);
957
+ const entry = this.entries.get(providerKey("search", id));
958
+ return Boolean(entry && this.ready(entry));
959
+ }
960
+ ready(entry) {
961
+ try {
962
+ return entry.configured && entry.provider.available();
963
+ } catch {
964
+ return false;
965
+ }
836
966
  }
837
967
  searchOrder(settings = this.settings) {
838
968
  return resolveSearchOrder(this.knownSearchIds(), settings, (id) => this.keyed(id));
@@ -841,7 +971,8 @@ var WebSearchManager = class {
841
971
  return pickActiveSearch(this.searchOrder(settings), (id) => this.configured(id));
842
972
  }
843
973
  selected(entry) {
844
- if (this.disposed || this.suspended || !entry.configured) return false;
974
+ if (this.disposed || this.suspended || !this.ready(entry)) return false;
975
+ if (this.entries.get(providerKey(entry.kind, entry.descriptor.id)) !== entry) return false;
845
976
  if (entry.kind === "fetch") return this.settings.fetchEnabled && this.settings.fetchProvider === entry.descriptor.id && this.entries.get(providerKey("fetch", entry.descriptor.id)) === entry;
846
977
  return this.settings.searchEnabled && this.activeSearchId() === entry.descriptor.id;
847
978
  }
@@ -855,8 +986,9 @@ var WebSearchManager = class {
855
986
  providers: entries.map((entry) => ({
856
987
  ...entry.descriptor,
857
988
  kind: entry.kind,
858
- configured: entry.configured,
859
- baseURL: this.settings.endpoints[providerKey(entry.kind, entry.descriptor.id)] ?? entry.descriptor.defaultBaseURL,
989
+ configured: this.ready(entry),
990
+ configurationOwned: entry.descriptor.configurationOwner === WEB_MANAGER_OWNER,
991
+ baseURL: entry.descriptor.configurationOwner === "@klarkxy/dsh-web-search-manager" ? this.settings.endpoints[providerKey(entry.kind, entry.descriptor.id)] ?? entry.descriptor.defaultBaseURL : void 0,
860
992
  calls: entry.calls,
861
993
  failures: entry.failures
862
994
  })).sort((a, b) => providerKey(a.kind, a.id).localeCompare(providerKey(b.kind, b.id)))
@@ -864,18 +996,20 @@ var WebSearchManager = class {
864
996
  }
865
997
  async refresh() {
866
998
  const epoch = this.epoch;
999
+ const revision = ++this.refreshRevision;
867
1000
  await Promise.all([...this.entries.entries()].map(async ([key, entry]) => {
868
- let configured = !entry.descriptor.credentialRef;
869
- if (entry.descriptor.credentialRef) try {
1001
+ let configured = true;
1002
+ if (entry.descriptor.configurationOwner === "@klarkxy/dsh-web-search-manager" && entry.descriptor.credentialRef) try {
870
1003
  configured = Boolean((await this.options.resolveCredential(entry.descriptor.credentialRef))?.trim());
871
1004
  } catch {
872
1005
  configured = false;
873
1006
  }
874
- if (this.disposed || epoch !== this.epoch || this.entries.get(key) !== entry) return;
1007
+ if (this.disposed || epoch !== this.epoch || revision !== this.refreshRevision || this.entries.get(key) !== entry) return;
875
1008
  if (entry.configured !== configured) {
876
1009
  entry.configured = configured;
877
1010
  if (!configured) this.abort(entry);
878
1011
  }
1012
+ if (!this.ready(entry)) this.abort(entry);
879
1013
  }));
880
1014
  this.notify();
881
1015
  return this.status();
@@ -883,99 +1017,97 @@ var WebSearchManager = class {
883
1017
  abort(entry) {
884
1018
  for (const target of entry ? [entry] : this.entries.values()) for (const controller of target.active) controller.abort();
885
1019
  }
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", "供应商标识已注册。");
1020
+ discover(kind, provider) {
1021
+ const key = providerKey(kind, provider.id);
1022
+ const old = this.entries.get(key);
1023
+ if (old) this.abort(old);
1024
+ const metadata = provider.dshWebManagement;
1025
+ const descriptor = {
1026
+ id: provider.id,
1027
+ label: typeof metadata?.label === "string" && metadata.label ? metadata.label : provider.id,
1028
+ description: typeof metadata?.description === "string" ? metadata.description : "",
1029
+ billing: [
1030
+ "none",
1031
+ "request",
1032
+ "model-and-tools"
1033
+ ].includes(metadata?.billing ?? "") ? metadata.billing : "unknown",
1034
+ configurationOwner: typeof metadata?.configurationOwner === "string" && metadata.configurationOwner ? metadata.configurationOwner : void 0,
1035
+ configurationUrl: safeConfigurationUrl(metadata?.configurationUrl)
1036
+ };
1037
+ for (const field of [
1038
+ "credentialHint",
1039
+ "pricing",
1040
+ "defaultBaseURL"
1041
+ ]) if (typeof metadata?.[field] === "string") descriptor[field] = metadata[field];
1042
+ if (typeof metadata?.credentialRef === "string" && /^[A-Z][A-Z0-9_]{1,127}$/.test(metadata.credentialRef)) descriptor.credentialRef = metadata.credentialRef;
1043
+ if (metadata?.credentialShared === true) descriptor.credentialShared = true;
1044
+ for (const field of ["signupUrl", "pricingUrl"]) try {
1045
+ const url = new URL(metadata?.[field] ?? "");
1046
+ if (url.protocol === "https:" && !url.username && !url.password) descriptor[field] = metadata[field];
1047
+ } catch {}
902
1048
  const entry = {
903
- descriptor: Object.freeze({ ...descriptor }),
1049
+ descriptor: Object.freeze(descriptor),
904
1050
  kind,
905
- configured: !descriptor.credentialRef,
1051
+ provider,
1052
+ configured: descriptor.configurationOwner !== "@klarkxy/dsh-web-search-manager" || !descriptor.credentialRef,
906
1053
  calls: 0,
907
1054
  failures: 0,
908
- unregister() {},
909
1055
  active: /* @__PURE__ */ new Set()
910
1056
  };
911
1057
  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;
1058
+ const legacy = metadata?.legacyId;
1059
+ const expectedLegacy = kind === "search" && provider.id === "deepseek-managed" ? "deepseek-official" : kind === "fetch" && provider.id === "http-managed" ? "http" : void 0;
1060
+ if (descriptor.configurationOwner === "@klarkxy/dsh-web-search-manager" && legacy === expectedLegacy && legacy && !this.entries.has(providerKey(kind, legacy))) {
1061
+ if (kind === "search") {
1062
+ this.settings.searchOrder = this.settings.searchOrder.map((id) => id === legacy ? provider.id : id);
1063
+ if (this.settings.searchProvider === legacy) this.settings.searchProvider = provider.id;
1064
+ } else if (this.settings.fetchProvider === legacy) this.settings.fetchProvider = provider.id;
1065
+ const legacyKey = providerKey(kind, legacy);
1066
+ if (this.settings.endpoints[legacyKey] && !this.settings.endpoints[key]) this.settings.endpoints[key] = this.settings.endpoints[legacyKey];
921
1067
  }
922
1068
  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,
1069
+ if (kind === "search") {
1070
+ const search = provider;
1071
+ return {
1072
+ id: provider.id,
1073
+ available: () => this.selected(entry),
1074
+ search: (request, signal) => {
1075
+ if (typeof request.query !== "string" || !request.query.trim() || request.query.length > 4e3) fail("WEB_INVALID_REQUEST", "查询须为 1 至 4000 字符。");
1076
+ const count = Math.min(bounded(request.maxResults ?? this.settings.maxResults, 1, 100, "结果数"), this.settings.maxResults);
1077
+ return this.execute(entry, signal, async (_options, combined) => {
1078
+ const result = await search.search({
1079
+ ...request,
1080
+ query: request.query.trim(),
1081
+ maxResults: count
1082
+ }, combined);
1083
+ const sources = result.sources.slice(0, count).filter((source) => {
1084
+ try {
1085
+ const url = new URL(source.url);
1086
+ return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password;
1087
+ } catch {
1088
+ return false;
1089
+ }
1090
+ }).map((source) => ({
1091
+ ...source,
1092
+ title: source.title?.slice(0, 1e3),
1093
+ snippet: source.snippet?.slice(0, 8e3)
1094
+ }));
1095
+ return {
1096
+ ...result,
1097
+ content: result.content?.slice(0, 4e4),
1098
+ sources,
1099
+ truncated: result.truncated || result.sources.length > count
1100
+ };
1101
+ });
1102
+ }
1103
+ };
1104
+ }
1105
+ const fetch = provider;
1106
+ return {
1107
+ id: provider.id,
974
1108
  available: () => this.selected(entry),
975
1109
  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);
1110
+ const result = await fetch.fetch(request, combined);
979
1111
  const content = result.body.content.slice(0, options.maxFetchChars);
980
1112
  return {
981
1113
  ...result,
@@ -986,7 +1118,31 @@ var WebSearchManager = class {
986
1118
  truncated: result.truncated || content.length !== result.body.content.length
987
1119
  };
988
1120
  })
989
- }));
1121
+ };
1122
+ }
1123
+ /** @deprecated Provider plugins should register directly with ctx.web. */
1124
+ registerSearchProvider(descriptor, factory) {
1125
+ return this.legacyRegistration(() => registerNativeSearch(this.options.web, this.providerConfiguration(), descriptor, factory));
1126
+ }
1127
+ /** @deprecated Provider plugins should register directly with ctx.web. */
1128
+ registerFetchProvider(descriptor, factory) {
1129
+ return this.legacyRegistration(() => registerNativeFetch(this.options.web, this.providerConfiguration(), descriptor, factory));
1130
+ }
1131
+ providerConfiguration() {
1132
+ return {
1133
+ settings: () => structuredClone(this.settings),
1134
+ resolveCredential: (ref) => this.options.resolveCredential(ref)
1135
+ };
1136
+ }
1137
+ legacyRegistration(register) {
1138
+ if (this.disposed) fail("WEB_DISABLED", "网络搜索插件已停止。");
1139
+ const unregister = register();
1140
+ const off = () => {
1141
+ this.legacyRegistrations.delete(off);
1142
+ unregister();
1143
+ };
1144
+ this.legacyRegistrations.add(off);
1145
+ return off;
990
1146
  }
991
1147
  async execute(entry, signal, run) {
992
1148
  if (!this.selected(entry)) fail("WEB_DISABLED", "请在设置 → 网络搜索中配置凭据并启用。");
@@ -996,19 +1152,9 @@ var WebSearchManager = class {
996
1152
  const epoch = this.epoch;
997
1153
  entry.active.add(controller);
998
1154
  let started = false;
999
- let apiKey;
1000
1155
  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
1156
  if (epoch !== this.epoch || !this.selected(entry)) fail("WEB_DISABLED", "网络访问设置已改变,请重新发起请求。");
1010
1157
  const options = {
1011
- apiKey,
1012
1158
  baseURL: this.settings.endpoints[providerKey(entry.kind, entry.descriptor.id)] ?? entry.descriptor.defaultBaseURL,
1013
1159
  timeoutMs: this.settings.timeoutMs,
1014
1160
  maxFetchChars: this.settings.maxFetchChars
@@ -1020,8 +1166,13 @@ var WebSearchManager = class {
1020
1166
  return result;
1021
1167
  } catch (error) {
1022
1168
  if (started) entry.failures += 1;
1169
+ if (error instanceof WebError && error.code === "WEB_CREDENTIAL_MISSING") {
1170
+ entry.configured = false;
1171
+ this.abort(entry);
1172
+ this.notify();
1173
+ }
1023
1174
  if (combined.aborted && !(error instanceof WebError && error.code === "WEB_CREDENTIAL_MISSING")) throw new WebError("网络请求已取消或超时。", "WEB_ABORTED");
1024
- const safe = providerError(error, apiKey ? [apiKey] : []);
1175
+ const safe = providerError(error);
1025
1176
  throw new WebError(`[${entry.kind}:${entry.descriptor.id}] ${safe.message}`, safe.code);
1026
1177
  } finally {
1027
1178
  clearTimeout(timeout);
@@ -1042,7 +1193,7 @@ var WebSearchManager = class {
1042
1193
  bounded(proposed.maxFetchChars, 1e3, 2e5, "正文长度");
1043
1194
  for (const [key, value] of Object.entries(proposed.endpoints)) {
1044
1195
  const entry = this.entries.get(key);
1045
- if (entry && !entry.descriptor.defaultBaseURL) fail("WEB_INVALID_CONFIG", "供应商不支持自定义地址。");
1196
+ if (entry && (entry.descriptor.configurationOwner !== "@klarkxy/dsh-web-search-manager" || !entry.descriptor.defaultBaseURL) && value !== this.settings.endpoints[key]) fail("WEB_INVALID_CONFIG", "供应商不支持自定义地址。");
1046
1197
  try {
1047
1198
  proposed.endpoints[key] = validateBaseURL(value);
1048
1199
  } catch {
@@ -1050,18 +1201,13 @@ var WebSearchManager = class {
1050
1201
  }
1051
1202
  }
1052
1203
  await this.refresh();
1053
- proposed.searchOrder = this.searchOrder(proposed);
1204
+ proposed.searchOrder = [...new Set(proposed.searchOrder)];
1054
1205
  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;
1206
+ const activatingSearch = !this.settings.searchEnabled || JSON.stringify(proposed.searchOrder) !== JSON.stringify(this.settings.searchOrder);
1207
+ if (proposed.searchEnabled && !proposed.searchProvider && activatingSearch) fail("WEB_CREDENTIAL_MISSING", "需选择已安装的供应商并填写有效凭据。");
1061
1208
  if (proposed.fetchEnabled) {
1062
1209
  const entry = this.entries.get(providerKey("fetch", proposed.fetchProvider));
1063
- if (!entry) proposed.fetchEnabled = false;
1064
- else if (!entry.configured) fail("WEB_CREDENTIAL_MISSING", "需选择已安装的供应商并填写有效凭据。");
1210
+ if ((!this.settings.fetchEnabled || proposed.fetchProvider !== this.settings.fetchProvider) && (!entry || !this.ready(entry))) fail("WEB_CREDENTIAL_MISSING", "需选择已安装的供应商并填写有效凭据。");
1065
1211
  }
1066
1212
  this.suspended = true;
1067
1213
  this.epoch += 1;
@@ -1088,10 +1234,11 @@ var WebSearchManager = class {
1088
1234
  this.epoch += 1;
1089
1235
  this.abort();
1090
1236
  this.notify();
1091
- for (const entry of this.entries.values()) entry.unregister();
1237
+ for (const off of [...this.legacyRegistrations]) off();
1238
+ await this.pending;
1239
+ this.detach();
1092
1240
  this.entries.clear();
1093
1241
  this.listeners.clear();
1094
- await this.pending;
1095
1242
  }
1096
1243
  };
1097
1244
  //#endregion
@@ -1135,20 +1282,30 @@ const inject = [
1135
1282
  async function apply(ctx) {
1136
1283
  const host = ctx;
1137
1284
  const domain = await ctx.storageDomain.open(webSettingsDomain);
1285
+ let managed;
1286
+ ctx.effect(() => async () => {
1287
+ await managed?.dispose();
1288
+ await domain.close();
1289
+ }, "web-search-manager.dispose");
1138
1290
  const table = domain.table("settings");
1139
- const manager = new WebSearchManager({
1291
+ const manager = managed = new WebSearchManager({
1140
1292
  web: ctx.web,
1141
1293
  initial: table.get("global"),
1142
1294
  resolveCredential: async (ref) => (await host.credentials.resolve(credentialRef(ref)))?.value,
1143
1295
  save: (settings) => table.put("global", settings)
1144
1296
  });
1145
- ctx.effect(() => async () => {
1146
- await manager.dispose();
1147
- await domain.close();
1148
- }, "web-search-manager.dispose");
1149
1297
  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) => {
1298
+ ctx.on("credentials/reference-updated", () => {
1299
+ manager.refresh();
1300
+ });
1301
+ ctx.on("web/provider-availability-updated", () => {
1302
+ manager.refresh();
1303
+ });
1304
+ ctx.effect(() => registerBuiltins(ctx.web, {
1305
+ settings: () => manager.status().settings,
1306
+ resolveCredential: async (ref) => (await host.credentials.resolve(credentialRef(ref)))?.value
1307
+ }), "web-search-manager.providers");
1308
+ ctx.effect(() => registerHostRpc$1(host, WEB_SEARCH_RPC_CHANNEL, async (endpoint, payload, signal) => {
1152
1309
  try {
1153
1310
  signal.throwIfAborted();
1154
1311
  if (endpoint === "status") return {