From d729b03f707c1a1f82294f57f623f3028f24408d Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 10 Oct 2026 04:24:13 +0800 Subject: [PATCH] Restore Desktop Search MCP settings form with Bailian defaults. Keep boot safe on DSH 0.2 while registering a distinct settings section, lazy remote.credentials wiring, and credential badge state that survives describe redaction after Save. Co-authored-by: Cursor --- cordis.patch.yml | 47 +- lib/catalog.js | 11 + lib/client.browser.js | 856 +++++++++++++++++++++++++++---------- lib/index.js | 77 +--- package.json | 4 +- test/compatibility.test.js | 137 +++--- test/runtime.test.js | 13 + 7 files changed, 733 insertions(+), 412 deletions(-) diff --git a/cordis.patch.yml b/cordis.patch.yml index 5b4cb45..1b47ce0 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,21 +1,13 @@ -# dsh-search-mcp bundle layer: applied after dsh-base and dsh-web-app, -# before the profile's own cordis.patch.yml (last write wins per row). +# dsh-search-mcp bundle layer (Desktop 0.2 boot-safe). # -# Installing this package therefore REPLACES dsh's built-in web search: -# - the `web` row's searchProvider switches from `deepseek-official` to -# `search-mcp` (the provider registered by this plugin), and -# - the built-in DeepSeek search provider row is disabled. -# The model-facing `web_search` tool keeps its name and presentation; its -# execution now goes through the search MCP server(s) configured below or in -# the web Settings → Plugins → search-mcp section. +# IMPORTANT: Do NOT set `web.searchProvider: search-mcp` or disable +# `web-search-deepseek` in this patch. On Desktop 0.2 that creates a +# load-order deadlock (web waits for search-mcp, search-mcp waits for web) +# and freezes the UI on "Loading plugins…". # -# Removing this package (dsh plugin --profile web remove dsh-search-mcp) -# drops this whole layer and restores the built-in search exactly. -# -# SECURITY: no API keys are committed to this repository. Server keys are -# supplied at runtime through `apiKeyEnv` — stored in -# `$DSH_HOME/.credentials.yaml` (e.g. `TAVILY_API_KEY: `) — or through -# the web Settings → Plugins → search-mcp section (`apiKey` field). +# The host `apply` registers the provider and then soft-switches the web +# row via settings when available. Removing this package restores the +# previous composition (no searchProvider pin left behind by this file). - insert: - id: search-mcp @@ -26,29 +18,10 @@ searchTimeoutMs: 30000 servers: - id: bailian - kind: custom - transport: http - url: https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp - authStyle: header - authParam: Authorization - authPrefix: 'Bearer ' + kind: bailian apiKeyEnv: DASHSCOPE_API_KEY - toolName: bailian_web_search -- id: web - config: - searchProvider: search-mcp - -- id: web-search-deepseek - disabled: true - -# The model-facing web_search tool is owned by dsh-tool-web (its `Config` -# default is a hard 8-source cap, enforced by dsh-web's seam on EVERY -# request). This plugin takes over result sizing, so raise the tool layer's -# cap to the plugin schema maximum: `search-mcp`'s own maxResults (Settings → -# Plugins → search-mcp) then decides how many sources actually come back. -# `fetch` stays disabled and the base timeout is restated, because a patch -# replaces the targeted row's whole config. +# Raise tool-web caps so search-mcp maxResults can take effect. - id: tool-web disabled: false config: diff --git a/lib/catalog.js b/lib/catalog.js index 4cee9e7..6625687 100644 --- a/lib/catalog.js +++ b/lib/catalog.js @@ -6,6 +6,17 @@ * `custom` is the only kind whose connection details come from the user. */ export const SEARCH_MCP_CATALOG = { + bailian: { + transport: 'http', + url: 'https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp', + authStyle: 'header', + authParam: 'Authorization', + authPrefix: 'Bearer ', + toolName: 'bailian_web_search', + countArg: '', + apiKeyEnv: 'DASHSCOPE_API_KEY', + needsKey: true, + }, tavily: { transport: 'http', url: 'https://mcp.tavily.com/mcp/', diff --git a/lib/client.browser.js b/lib/client.browser.js index ffd70cd..1a9aa26 100644 --- a/lib/client.browser.js +++ b/lib/client.browser.js @@ -18,8 +18,34 @@ window.__ModuleLoader__.load({ var module = { exports: {} }; var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); - let react_jsx_runtime = require("react/jsx-runtime"); let react = require("react"); + if (react && react.default && typeof react.default.useState === "function") { + react = react.default; + } + let react_jsx_runtime = require("react/jsx-runtime"); + if (react_jsx_runtime && react_jsx_runtime.default && typeof react_jsx_runtime.default.jsx === "function") { + react_jsx_runtime = react_jsx_runtime.default; + } + // Prefer createElement (ops-cron path) — some Desktop ModuleLoader builds + // expose a partial jsx-runtime where jsx/jsxs are missing on the namespace. + const h = typeof react.createElement === "function" + ? react.createElement.bind(react) + : null; + const jsx = typeof react_jsx_runtime.jsx === "function" + ? react_jsx_runtime.jsx + : (type, props, key) => { + if (!h) throw new Error("dsh-search-mcp: react.createElement unavailable"); + if (type == null) throw new Error("dsh-search-mcp: jsx type is " + type); + const p = props ? { ...props } : {}; + if (key !== undefined) p.key = key; + const kids = p.children; + delete p.children; + return kids === undefined ? h(type, p) : h(type, p, kids); + }; + const jsxs = typeof react_jsx_runtime.jsxs === "function" + ? react_jsx_runtime.jsxs + : jsx; + react_jsx_runtime = { jsx, jsxs, Fragment: react_jsx_runtime.Fragment || react.Fragment }; // Desktop 0.2 may not seed ui-primitives / dsh-client-store in the ModuleLoader // table. Soft-require + local fallbacks so import never fails web boot. let _deepseek_ai_dsh_client_ui_primitives = {}; @@ -38,25 +64,26 @@ window.__ModuleLoader__.load({ ..._deepseek_ai_dsh_client_ui_primitives, }; } - if (typeof _deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14 !== "function") { - _deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14 = function IconChevronDownOutline14(props) { - return (0, react_jsx_runtime.jsx)("svg", { - viewBox: "0 0 14 14", - width: "14", - height: "14", - "aria-hidden": "true", - className: props && props.className, - children: (0, react_jsx_runtime.jsx)("path", { - d: "M3.5 5.25L7 8.75L10.5 5.25", - fill: "none", - stroke: "currentColor", - strokeWidth: "1.5", - strokeLinecap: "round", - strokeLinejoin: "round", - }), - }); - }; + // Always use a local chevron — never trust ModuleLoader primitives for element types + // (React #130 = rendering undefined as a component). + function IconChevronDownOutline14(props) { + return jsx("svg", { + viewBox: "0 0 14 14", + width: "14", + height: "14", + "aria-hidden": "true", + className: props && props.className, + children: jsx("path", { + d: "M3.5 5.25L7 8.75L10.5 5.25", + fill: "none", + stroke: "currentColor", + strokeWidth: "1.5", + strokeLinecap: "round", + strokeLinejoin: "round", + }), + }); } + _deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14 = IconChevronDownOutline14; function createSnapshotStoreFallback(initial) { let state = initial; const listeners = new Set(); @@ -76,23 +103,7 @@ window.__ModuleLoader__.load({ }, }; } - let createSnapshotStore = createSnapshotStoreFallback; - try { - const storeMod = require("@deepseek-ai/dsh-client-store"); - if (typeof storeMod?.createSnapshotStore === "function") { - createSnapshotStore = storeMod.createSnapshotStore; - } - } catch { - try { - const runtimeMod = require("@deepseek-ai/dsh-client-runtime"); - const nested = runtimeMod?.client || runtimeMod; - if (typeof nested?.createSnapshotStore === "function") { - createSnapshotStore = nested.createSnapshotStore; - } - } catch { - // keep fallback - } - } + const createSnapshotStore = createSnapshotStoreFallback; const _deepseek_ai_dsh_client_runtime_client = { createSnapshotStore }; //#region styles @@ -171,6 +182,7 @@ window.__ModuleLoader__.load({ //#region provider catalog (browser-side metadata only) const CATALOG = { + bailian: { needsKey: true, apiKeyEnv: "DASHSCOPE_API_KEY" }, tavily: { needsKey: true }, brave: { needsKey: true }, exa: { needsKey: true }, @@ -184,6 +196,7 @@ window.__ModuleLoader__.load({ /** Brand labels shown on the kind badge / quick-add buttons. */ const KIND_LABELS = { + bailian: "百炼", tavily: "Tavily", brave: "Brave", exa: "Exa", @@ -192,7 +205,7 @@ window.__ModuleLoader__.load({ custom: "Custom" }; /** Kinds offered as one-click presets (custom is added via the dashed button). */ - const QUICK_KINDS = ["tavily", "brave", "exa", "perplexity", "duckduckgo"]; + const QUICK_KINDS = ["bailian", "tavily", "brave", "exa", "perplexity", "duckduckgo"]; //#endregion //#region locale @@ -316,8 +329,8 @@ window.__ModuleLoader__.load({ }; //#endregion - /** Namespace of this plugin's settings section (spelled, not imported). */ const NS = "search-mcp"; + const LOCALE_NS = "settings.search-mcp"; //#region row helpers function emptyRow() { @@ -339,8 +352,16 @@ window.__ModuleLoader__.load({ } /** Convert a stored server entry (host shape) to a row draft. */ function rowFromEntry(entry) { - const kind = Object.hasOwn(CATALOG, entry.kind) ? entry.kind : "custom"; + let kind = Object.hasOwn(CATALOG, entry.kind) ? entry.kind : "custom"; + // Legacy patch/settings stored Bailian as kind:custom + dashscope URL. + if (kind === "custom") { + const url = String(entry.url || "").toLowerCase(); + if (entry.toolName === "bailian_web_search" || url.includes("dashscope.aliyuncs.com")) { + kind = "bailian"; + } + } const known = kind !== "custom"; + const presetEnv = CATALOG[kind]?.apiKeyEnv || ""; return { id: entry.id ?? "", kind, @@ -349,7 +370,7 @@ window.__ModuleLoader__.load({ command: known ? "" : (entry.command ?? ""), args: known ? "" : (Array.isArray(entry.args) ? entry.args.join(", ") : ""), apiKey: entry.apiKey ?? "", - apiKeyEnv: entry.apiKeyEnv ?? "", + apiKeyEnv: entry.apiKeyEnv || (known ? presetEnv : "") || "", authStyle: known ? "" : (entry.authStyle ?? ""), authParam: known ? "" : (entry.authParam ?? ""), authPrefix: known ? "" : (entry.authPrefix ?? ""), @@ -387,7 +408,8 @@ window.__ModuleLoader__.load({ } /** Changing provider starts a clean credential/connection draft. */ function applyKindDefaults(row, kind) { - const next = { ...row, kind, apiKey: "", apiKeyEnv: "" }; + const presetEnv = CATALOG[kind]?.apiKeyEnv || ""; + const next = { ...row, kind, apiKey: "", apiKeyEnv: presetEnv }; if (kind !== "custom") { return { ...next, transport: "", url: "", command: "", args: "", authStyle: "", authParam: "", toolName: "" }; } @@ -422,8 +444,11 @@ window.__ModuleLoader__.load({ } function credentialRefFor(row) { if (row.apiKeyEnv.trim() !== "") return row.apiKeyEnv.trim(); - const url = (row.url || "").toLowerCase(); - if (url.includes("dashscope.aliyuncs.com")) return "DASHSCOPE_API_KEY"; + if (row.kind === "bailian" || (row.url || "").toLowerCase().includes("dashscope.aliyuncs.com")) { + return "DASHSCOPE_API_KEY"; + } + const preset = CATALOG[row.kind]?.apiKeyEnv; + if (preset) return preset; const id = row.id.trim().replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase(); return `SEARCH_MCP_${id || "SERVER"}_API_KEY`; } @@ -485,9 +510,13 @@ window.__ModuleLoader__.load({ //#region controller (whole-section staged draft) var SearchMcpCardController = class { - constructor(scope, remote, describeFace) { + constructor(scope, remoteOrGetter, describeFace) { this.scope = scope; - this.remote = remote; + // Lazy remote: Desktop may not have ctx.remote at first paint. + // Capturing a stub forever made Save fail with "credential state lookup failed". + this.getRemote = typeof remoteOrGetter === "function" + ? remoteOrGetter + : () => remoteOrGetter; this.describeFace = describeFace; this.draft = null; this.listeners = new Set(); @@ -498,6 +527,13 @@ window.__ModuleLoader__.load({ this.secretStateReady = false; this.legacySecretIds = new Set(); this.credentialStates = {}; + /** Refs we successfully wrote — survives describe failures / redaction / remount. */ + this.knownConfiguredRefs = new Set(); + try { + const raw = sessionStorage.getItem("dsh-search-mcp:configured-refs"); + const list = raw ? JSON.parse(raw) : []; + if (Array.isArray(list)) for (const ref of list) if (typeof ref === "string" && ref) this.knownConfiguredRefs.add(ref); + } catch { /* private mode */ } try { this.store = (0, _deepseek_ai_dsh_client_runtime_client.createSnapshotStore)(this.project()); } catch { @@ -512,6 +548,27 @@ window.__ModuleLoader__.load({ }); void this.readSecretState().catch(() => {}); } + markConfigured(refs) { + for (const ref of refs) { + if (typeof ref !== "string" || !ref.trim()) continue; + const id = ref.trim(); + this.knownConfiguredRefs.add(id); + this.credentialStates = { + ...this.credentialStates, + [id]: { + ...(this.credentialStates[id] || {}), + configured: true, + writable: true, + }, + }; + } + try { + sessionStorage.setItem( + "dsh-search-mcp:configured-refs", + JSON.stringify([...this.knownConfiguredRefs]), + ); + } catch { /* private mode */ } + } section() { return this.scope.getSnapshot(); } @@ -533,22 +590,27 @@ window.__ModuleLoader__.load({ })) : [] }; } + applyKnownConfigured(states) { + const merged = { ...(states || {}) }; + for (const ref of this.knownConfiguredRefs) { + if (!merged[ref]?.configured) { + merged[ref] = { ...(merged[ref] || {}), configured: true, writable: merged[ref]?.writable ?? true }; + } + } + return merged; + } async readSecretState() { + const remote = this.getRemote(); let view; try { - const response = await this.remote.settings.describe(); - if (!response.ok) { - this.secretStateReady = true; - this.publish(); - return; + const response = await remote.settings.describe(); + if (response.ok) { + view = (response.value?.namespaces ?? []).find((candidate) => candidate.ns === NS); } - view = (response.value?.namespaces ?? []).find((candidate) => candidate.ns === NS); - } catch { - this.secretStateReady = true; - this.publish(); - return; - } - const servers = Array.isArray(view?.value?.servers) ? view.value.servers : []; + } catch { /* settings describe optional */ } + const scopeServers = Array.isArray(this.value().servers) ? this.value().servers : []; + const viewServers = Array.isArray(view?.value?.servers) ? view.value.servers : []; + const servers = viewServers.length > 0 ? viewServers : scopeServers; const ids = new Set(); for (const secret of view?.secrets ?? []) { if (!secret.set || secret.path?.[0] !== "servers" || secret.path?.[2] !== "apiKey") continue; @@ -560,15 +622,21 @@ window.__ModuleLoader__.load({ const changed = !deepEqualJson([...ids].sort(), [...this.legacySecretIds].sort()); this.legacySecretIds = ids; const previousCredentialStates = this.credentialStates; - const refs = [...new Set(servers.map((entry) => typeof entry.apiKeyEnv === "string" ? entry.apiKeyEnv.trim() : "").filter(Boolean))]; - if (refs.length > 0) { + const refs = [...new Set([ + ...servers.map((entry) => typeof entry.apiKeyEnv === "string" ? entry.apiKeyEnv.trim() : ""), + ...scopeServers.map((entry) => typeof entry.apiKeyEnv === "string" ? entry.apiKeyEnv.trim() : ""), + ...this.knownConfiguredRefs, + ].filter(Boolean))]; + if (refs.length > 0 && remote.__stub !== true) { try { - this.credentialStates = await describeCredentialRefs(this.remote, refs); + const described = await describeCredentialRefs(remote, refs); + this.credentialStates = this.applyKnownConfigured(described); } catch { - this.credentialStates = {}; + this.credentialStates = this.applyKnownConfigured(this.credentialStates); } } else { - this.credentialStates = {}; + // Never wipe optimistic Save marks when describe is empty/unavailable. + this.credentialStates = this.applyKnownConfigured(this.credentialStates); } this.secretStateReady = true; @@ -611,7 +679,8 @@ window.__ModuleLoader__.load({ ? ids.filter(Boolean) : (Array.isArray(sectionValue.servers) ? sectionValue.servers.map((s) => s.id).filter(Boolean) : []), serverKinds, - credentialStates: this.credentialStates + credentialStates: this.applyKnownConfigured(this.credentialStates), + configuredRefs: [...this.knownConfiguredRefs], }; } @@ -707,33 +776,46 @@ window.__ModuleLoader__.load({ this.legacyBlocked = false; this.publish(); + let remote = this.getRemote(); let landed = true; let failDetail = ""; let serverEntries; const createdCredentialRefs = []; - if (serversChanged) { + let savedCredentialRefs = []; + if (serversChanged && landed) { serverEntries = []; const credentialWrites = draft.servers .filter((row) => row.apiKey.trim() !== "") .map((row) => ({ ref: credentialRefFor(row), value: row.apiKey.trim() })); + savedCredentialRefs = credentialWrites.map(({ ref }) => ref); + // Soft-inject may land a tick after Settings opens — wait briefly. + if (credentialWrites.length > 0 && remote.__stub === true) { + for (let i = 0; i < 25 && this.getRemote().__stub === true; i += 1) { + await new Promise((r) => setTimeout(r, 100)); + } + remote = this.getRemote(); + } + // Last resort: persist apiKey on the server row (Config role:secret). + // Host resolveOptions reads apiKey before credentials/env. + const inlineSecrets = credentialWrites.length > 0 && remote.__stub === true; let credentialBefore = {}; try { - if (credentialWrites.length > 0) { - credentialBefore = await describeCredentialRefs(this.remote, [...new Set(credentialWrites.map(({ ref }) => ref))]); + if (landed && credentialWrites.length > 0 && !inlineSecrets) { + credentialBefore = await describeCredentialRefs(remote, [...new Set(credentialWrites.map(({ ref }) => ref))]); } } catch (error) { landed = false; failDetail = error instanceof Error ? error.message : String(error); } - if (landed && credentialWrites.some(({ ref }) => credentialBefore[ref]?.writable === false)) { + if (landed && !inlineSecrets && credentialWrites.some(({ ref }) => credentialBefore[ref]?.writable === false)) { landed = false; failDetail = "credential is not writable"; } - if (landed) { + if (landed && !inlineSecrets) { for (const { ref, value } of credentialWrites) { if (!credentialBefore[ref]?.configured) createdCredentialRefs.push(ref); try { - const response = await this.remote.credentials.set(ref, value); + const response = await remote.credentials.set(ref, value); if (!response.ok) { landed = false; failDetail = response.error?.message || "credentials.set failed"; @@ -747,7 +829,7 @@ window.__ModuleLoader__.load({ } } if (!landed) { - await rollbackNewCredentialRefs(this.remote, createdCredentialRefs); + await rollbackNewCredentialRefs(remote, createdCredentialRefs); this.saving = false; this.failed = true; this.failDetail = failDetail; @@ -757,8 +839,13 @@ window.__ModuleLoader__.load({ for (const row of draft.servers) { const entry = entryFromRow(row); if (row.apiKey.trim() !== "") { - delete entry.apiKey; - entry.apiKeyEnv = credentialRefFor(row); + if (inlineSecrets) { + entry.apiKey = row.apiKey.trim(); + entry.apiKeyEnv = credentialRefFor(row); + } else { + delete entry.apiKey; + entry.apiKeyEnv = credentialRefFor(row); + } } else if (!entry.apiKeyEnv && (row.url || "").includes("dashscope")) { entry.apiKeyEnv = "DASHSCOPE_API_KEY"; } @@ -780,36 +867,62 @@ window.__ModuleLoader__.load({ else ops.push({ op: "set", path: [field], value }); } - if (ops.length > 0) { - try { - const revision = this.section().revision; - const response = await this.remote.settings.mutate(NS, ops, revision); - if (!response.ok) { - landed = false; - failDetail = response.error?.message || response.error?.code || "settings.mutate failed"; - try { - if (typeof this.describeFace?.load === "function") await this.describeFace.load(); - else if (typeof this.describeFace?.ensure === "function") await this.describeFace.ensure(); - } catch { /* keep draft */ } - } else if (response.value && typeof this.describeFace?.acceptView === "function") { - this.describeFace.acceptView(response.value); + // Prefer writing through the attached form scope when available + // (configForms). Memory fallback must NOT accept Save — it is not durable. + if (ops.length > 0 && landed) { + const scope = this.scope; + let wroteViaScope = false; + if ( + typeof scope.set === "function" + && typeof scope.unset === "function" + && scope.__memory !== true + ) { + try { + for (const op of ops) { + if (op.op === "unset") await scope.unset(op.path[0]); + else await scope.set(op.path[0], op.value); + } + wroteViaScope = true; + } catch (error) { + failDetail = error instanceof Error ? error.message : String(error); } - } catch (error) { + } + if (!wroteViaScope) { + try { + const revision = this.section().revision; + const response = await remote.settings.mutate(NS, ops, revision); + if (!response.ok) { + landed = false; + failDetail = response.error?.message || response.error?.code || "settings.mutate failed"; + try { + if (typeof this.describeFace?.load === "function") await this.describeFace.load(); + else if (typeof this.describeFace?.ensure === "function") await this.describeFace.ensure(); + } catch { /* keep draft */ } + } else if (response.value && typeof this.describeFace?.acceptView === "function") { + this.describeFace.acceptView(response.value); + } + } catch (error) { + landed = false; + failDetail = error instanceof Error ? error.message : String(error); + } + } else if (failDetail) { landed = false; - failDetail = error instanceof Error ? error.message : String(error); } } if (!landed) { - await rollbackNewCredentialRefs(this.remote, createdCredentialRefs); + await rollbackNewCredentialRefs(remote, createdCredentialRefs); } else { + // Badge reads credentialStates — keep configured after Save even when + // Host describe is slow/unavailable or secrets are redacted from the form. + if (savedCredentialRefs.length > 0) this.markConfigured(savedCredentialRefs); this.draft = null; } this.saving = false; this.failed = !landed; this.failDetail = landed ? "" : failDetail; this.publish(); - if (landed) this.readSecretState(); + if (landed) void this.readSecretState(); } inject() { @@ -822,6 +935,22 @@ window.__ModuleLoader__.load({ //#endregion //#region components + const EMPTY_CARD_STATE = { + available: true, + writable: false, + dirty: false, + invalid: false, + saving: false, + failed: false, + failDetail: "", + legacyBlocked: false, + overridden: false, + value: { defaultServer: "", maxResults: "", searchTimeoutMs: "", servers: [] }, + serverIds: [], + serverKinds: {}, + credentialStates: {}, + configuredRefs: [], + }; function SearchMcpSection(props) { return SearchMcpView(props, true); } @@ -829,12 +958,34 @@ window.__ModuleLoader__.load({ return SearchMcpView(props, false); } function SearchMcpView(props, asSection) { - const { t } = props; + const t = typeof props.t === "function" ? props.t : (key) => key; const [open, setOpen] = (0, react.useState)(!!asSection); - const state = props.useSearchMcpCard((snapshot) => snapshot); - if (!state.available) return null; + // Missing inject hooks used to throw; empty panel looked like a nav bug. + const useCard = typeof props.useSearchMcpCard === "function" + ? props.useSearchMcpCard + : (select) => select(EMPTY_CARD_STATE); + let state; + try { + state = useCard((snapshot) => snapshot) || EMPTY_CARD_STATE; + } catch { + state = EMPTY_CARD_STATE; + } + if (!state.available) { + return jsx("div", { + className: "smcp_section", + children: jsx("p", { className: "smcp_hint", children: t("readOnly") || "表单暂时不可用" }), + }); + } const disabled = !state.writable; - const value = state.value; + const rawValue = state.value && typeof state.value === "object" + ? state.value + : EMPTY_CARD_STATE.value; + const value = { + defaultServer: typeof rawValue.defaultServer === "string" ? rawValue.defaultServer : "", + maxResults: rawValue.maxResults ?? "", + searchTimeoutMs: rawValue.searchTimeoutMs ?? "", + servers: Array.isArray(rawValue.servers) ? rawValue.servers : [], + }; const title = t("title"); const serverOptions = [ { value: "", label: "—" }, @@ -903,6 +1054,7 @@ window.__ModuleLoader__.load({ disabled, dupIds, credentialStates: state.credentialStates, + configuredRefs: state.configuredRefs, onEdit: (field, val) => props.editRow(index, field, val), onKind: (kind) => props.changeKind(index, kind), @@ -973,7 +1125,7 @@ window.__ModuleLoader__.load({ state.dirty ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeMuted", children: t("unsaved") }) : null ] }), - (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { + jsx(IconChevronDownOutline14, { className: "smcp_chevron" + (open ? " smcp_chevronOpen" : "") }) ] @@ -991,16 +1143,21 @@ window.__ModuleLoader__.load({ const needsKey = (CATALOG[row.kind] ?? CATALOG.custom).needsKey; const hasKey = row.apiKey.trim() !== ""; const hasKeyEnv = row.apiKeyEnv.trim() !== ""; - const hasConfiguredRef = hasKeyEnv && props.credentialStates?.[row.apiKeyEnv.trim()]?.configured === true; + const ref = row.apiKeyEnv.trim(); + const configuredList = Array.isArray(props.configuredRefs) ? props.configuredRefs : []; + const hasConfiguredRef = hasKeyEnv && ( + props.credentialStates?.[ref]?.configured === true + || configuredList.includes(ref) + ); const hasLegacyKey = row.legacySecret === true; const overridesGlobal = row.maxResults.trim() !== ""; - const summary = known ? (needsKey ? (hasKeyEnv ? `${t("keyRef")}: ${row.apiKeyEnv.trim()}` : t("cdKeyHint")) : t("keyNotRequired")) : (row.transport === "stdio" + const summary = known ? (needsKey ? (hasKeyEnv ? `${t("keyRef")}: ${ref}` : t("cdKeyHint")) : t("keyNotRequired")) : (row.transport === "stdio" ? ((row.command.trim() !== "" ? row.command.trim() : "") + (row.args.trim() !== "" ? " " + row.args.trim() : "")).trim() || row.id : (row.url.trim() !== "" ? row.url.trim() : row.id)); const badges = []; if (hasKey) badges.push((0, react_jsx_runtime.jsx)("span", { className: "smcp_badge", children: t("keySet") }, "badge-key")); - else if (hasConfiguredRef) badges.push((0, react_jsx_runtime.jsx)("span", { className: "smcp_badge", children: `${t("keyConfigured")}: ${row.apiKeyEnv.trim()}` }, "badge-env")); - else if (hasKeyEnv) badges.push((0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeDanger", children: `${t("keyRefMissing")}: ${row.apiKeyEnv.trim()}` }, "badge-env-missing")); + else if (hasConfiguredRef) badges.push((0, react_jsx_runtime.jsx)("span", { className: "smcp_badge", children: `${t("keyConfigured")}: ${ref}` }, "badge-env")); + else if (hasKeyEnv) badges.push((0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeDanger", children: `${t("keyRefMissing")}: ${ref}` }, "badge-env-missing")); else if (hasLegacyKey) badges.push((0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeMuted", children: t("legacyKey") }, "badge-legacy-key")); else if (needsKey) badges.push((0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeDanger", children: t("keyMissing") }, "badge-key-missing")); if (overridesGlobal) badges.push((0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeMuted", children: t("overridesGlobal") }, "badge-override")); @@ -1017,7 +1174,7 @@ window.__ModuleLoader__.load({ "aria-label": `${rowOpen ? "收起" : "展开"} ${row.id || t("rowId")}`, disabled, onClick: () => setRowOpen(!rowOpen), - children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {}) + children: jsx(IconChevronDownOutline14, {}) }), (0, react_jsx_runtime.jsx)("span", { className: "smcp_kindBadge", children: kindLabel(row.kind, t) }), (0, react_jsx_runtime.jsx)("span", { className: "smcp_rowTitle" + (rowInvalid ? " smcp_invalid" : ""), children: row.id.trim() !== "" ? row.id.trim() : t("rowId") }), @@ -1136,43 +1293,118 @@ window.__ModuleLoader__.load({ } //#endregion + //#region apply /** - * Hard-inject only services present on DSH ≤0.1.5 and ≥0.1.7 / 0.2.0. - * Do NOT hard-inject settingsScope — Desktop 0.2 removed it (configForms). + * Hard-inject only slots + locale (same roster as netxops). + * Soft-inject configForms only — never settingsScope on Desktop 0.2. + * Do not call ctx.get("remote") in apply (waits forever and kills boot). */ const inject = ["slots", "locale"]; function noopDescribeFace() { - return { - load: async () => {}, - ensure: async () => {}, - acceptView: () => {}, - }; + return { load: async () => {}, ensure: async () => {}, acceptView: () => {} }; } - function resolveRemote(ctx) { - return ctx.remote || ctx.get?.("remote") || { - credentials: { set: async () => ({ ok: false }), describe: async () => ({ ok: false }) }, - settings: { mutate: async () => ({ ok: false }), describe: async () => ({ ok: false }) }, + function stubRemote() { + const unavailable = { ok: false, error: { message: "凭证通道尚未就绪,请稍后重试" } }; + return { + __stub: true, + credentials: { + set: async () => unavailable, + describe: async () => unavailable, + unset: async () => unavailable, + }, + settings: { + mutate: async () => unavailable, + describe: async () => unavailable, + }, $on: () => () => {}, }; } + /** + * Build a remote face from soft-injected pieces. + * Never call ctx.get("remote") — on Desktop 0.2 that wait freezes boot. + * Prefer `remote.credentials` / `remote.settings` soft-inject (netxops path). + */ + function buildRemoteFace(parts) { + const credentials = parts.credentials; + const settings = parts.settings; + const ready = !!(credentials && typeof credentials.describe === "function" + && typeof credentials.set === "function"); + if (!ready && !settings) return stubRemote(); + const unavailable = { ok: false, error: { message: "凭证通道尚未就绪,请稍后重试" } }; + return { + __stub: !ready, + credentials: ready ? credentials : { + set: async () => unavailable, + describe: async () => unavailable, + unset: async () => unavailable, + }, + settings: settings && typeof settings.mutate === "function" ? settings : { + mutate: async () => unavailable, + describe: async () => (settings && typeof settings.describe === "function" + ? settings.describe() + : unavailable), + }, + $on: typeof parts.$on === "function" ? parts.$on : () => () => {}, + }; + } + + function pickCredentials(inner) { + try { + if (inner?.remote?.credentials && typeof inner.remote.credentials.describe === "function") { + return inner.remote.credentials; + } + } catch { /* ignore */ } + try { + if (typeof inner?.get === "function") { + // Safe: netxops uses ctx.get('remote.credentials'). Never get('remote'). + const api = inner.get("remote.credentials"); + if (api && typeof api.describe === "function") return api; + } + } catch { /* ignore */ } + return null; + } + + function pickSettings(inner) { + try { + if (inner?.remote?.settings && typeof inner.remote.settings.mutate === "function") { + return inner.remote.settings; + } + } catch { /* ignore */ } + try { + if (typeof inner?.get === "function") { + const api = inner.get("remote.settings"); + if (api && typeof api.mutate === "function") return api; + } + } catch { /* ignore */ } + return null; + } + + // Mirror cordis.patch.yml insert so the Settings form shows Bailian + // before configForms attaches (and when the real scope is still empty). const MEMORY_DEFAULTS = { - defaultServer: "", + defaultServer: "bailian", maxResults: 8, searchTimeoutMs: 30000, - servers: [], + servers: [ + { + id: "bailian", + kind: "bailian", + apiKeyEnv: "DASHSCOPE_API_KEY", + }, + ], }; - /** In-memory form so Settings nav can mount before configForms attaches. */ function createMemoryScope(initial) { let value = { ...initial, servers: Array.isArray(initial.servers) ? [...initial.servers] : [] }; let revision = 0; const listeners = new Set(); const notify = () => { for (const listener of listeners) listener(); }; return { + __memory: true, getSnapshot: () => ({ status: "ready", value, writable: true, revision }), subscribe: (listener) => { listeners.add(listener); @@ -1195,7 +1427,6 @@ window.__ModuleLoader__.load({ }; } - /** Swap the live transport without recreating the card controller. */ function createDeferredScope(fallback) { let inner = fallback; const outerListeners = new Set(); @@ -1207,6 +1438,7 @@ window.__ModuleLoader__.load({ }; bindInner(); return { + get __memory() { return inner.__memory === true; }, getSnapshot: () => inner.getSnapshot(), subscribe: (listener) => { outerListeners.add(listener); @@ -1225,14 +1457,7 @@ window.__ModuleLoader__.load({ function namespaceCandidates(ctx) { const fiber = ctx?.fiber?.entry; - const raw = [ - fiber?.options?.id, - fiber?.id, - fiber?.options?.name, - ctx?.name, - NS, - "dsh-search-mcp", - ]; + const raw = [fiber?.options?.id, fiber?.id, fiber?.options?.name, ctx?.name, NS, "dsh-search-mcp"]; const out = []; for (const item of raw) { if (typeof item !== "string" || !item.trim()) continue; @@ -1245,137 +1470,294 @@ window.__ModuleLoader__.load({ } function apply(ctx) { - // Match dsh-im-ops / netxops: only `settings.section` (+ optional plugins.item). - // Never inject `settings.plugin.item` — Desktop 0.2 lacks that slot and - // injecting it fails the client fiber (web boot: dsh-search-mcp: failed), - // including when configForms attaches synchronously during apply. try { - const t = ctx.locale.bind(NS); ctx.effect(() => { - try { - return ctx.locale.register(NS, { zh, en }); - } catch { - const offZh = ctx.locale.register(NS, "zh", zh); - const offEn = ctx.locale.register(NS, "en", en); - return () => { offZh?.(); offEn?.(); }; + try { return ctx.locale.register(LOCALE_NS, { zh, en }); } + catch { + try { + const offZh = ctx.locale.register(LOCALE_NS, "zh", zh); + const offEn = ctx.locale.register(LOCALE_NS, "en", en); + return () => { offZh?.(); offEn?.(); }; + } catch { return () => {}; } } - }, "dsh-search-mcp: section dictionaries"); + }, "dsh-search-mcp: locales"); + } catch { /* optional */ } - const deferred = createDeferredScope(createMemoryScope(MEMORY_DEFAULTS)); - const remote = resolveRemote(ctx); - let controller; + const t = (() => { + try { return ctx.locale.bind(LOCALE_NS); } catch { return (key) => key; } + })(); + const sectionLabel = () => { try { - controller = new SearchMcpCardController(deferred, remote, noopDescribeFace()); - } catch (error) { - ctx.logger?.error?.("dsh-search-mcp: controller init failed: %s", error); - return; - } - let pluginsItemId; + const text = t("title"); + if (typeof text === "string" && text.trim() && text !== "title") return text; + } catch { /* fall through */ } + return "搜索 MCP"; + }; - try { - ctx.effect(() => { - if (typeof remote.$on !== "function") return () => {}; - return remote.$on("credentials/reference-updated", (ref) => { - const used = controller.baseDraft().servers.some((row) => row.apiKeyEnv.trim() === ref); - if (used) controller.readSecretState(); - }); - }, "dsh-search-mcp: credential status invalidation"); - } catch { /* optional */ } + const deferred = createDeferredScope(createMemoryScope(MEMORY_DEFAULTS)); + let controller = null; + const remoteParts = { credentials: null, settings: null, $on: null }; + let credInjectStarted = false; + let settingsInjectStarted = false; - try { - ctx.slots.inject("settings.section", () => ctx.slots.register({ - name: "settings.section", - id: NS, - order: 30, - label: () => t("title"), - locale: NS, - inject: () => controller.inject(), - }, SearchMcpSection)); - } catch (error) { - ctx.logger?.warn?.("dsh-search-mcp: settings.section unavailable: %s", error); - } - - const registerPluginsItem = (id) => { - if (pluginsItemId === id) return () => {}; + /** Soft-inject only `remote.credentials` / `remote.settings` (netxops pattern). + * Do NOT inject whole `remote` — on Desktop that wait never settles. */ + const ensureRemote = () => { + const soft = (deps, getStarted, setStarted, run, label) => { + if (getStarted()) return; + setStarted(true); try { - const off = ctx.slots.inject("plugins.item", () => ctx.slots.register({ - name: "plugins.item", - id, - order: 30, - label: () => t("title"), - locale: NS, - inject: () => controller.inject(), - }, SearchMcpSection)); - pluginsItemId = id; - return () => { - if (pluginsItemId === id) pluginsItemId = undefined; - off(); - }; + ctx.inject(deps, (inner) => { + try { run(inner); } + catch (error) { + inner.logger?.warn?.("dsh-search-mcp: %s handler failed: %s", label, error); + } + }); } catch (error) { - ctx.logger?.warn?.("dsh-search-mcp: plugins.item unavailable: %s", error); + setStarted(false); + ctx.logger?.warn?.("dsh-search-mcp: soft-inject %s skipped: %s", label, error); + } + }; + soft( + ["remote.credentials"], + () => credInjectStarted, + (v) => { credInjectStarted = v; }, + (inner) => { + const api = pickCredentials(inner) || pickCredentials(ctx); + remoteParts.credentials = api; + try { + if (inner.remote && typeof inner.remote.$on === "function") { + remoteParts.$on = inner.remote.$on.bind(inner.remote); + } + } catch { /* ignore */ } + ctx.logger?.info?.("dsh-search-mcp: remote.credentials %s", api ? "ready" : "absent"); + inner.effect(() => { + const off = remoteParts.$on + ? remoteParts.$on("credentials/reference-updated", (ref) => { + try { + const used = getController().baseDraft().servers.some((row) => row.apiKeyEnv.trim() === String(ref)); + if (used) void getController().readSecretState(); + } catch { /* ignore */ } + }) + : () => {}; + return () => { + try { off?.(); } catch { /* ignore */ } + remoteParts.credentials = null; + }; + }, "dsh-search-mcp: credential invalidation"); + }, + "remote.credentials", + ); + soft( + ["remote.settings"], + () => settingsInjectStarted, + (v) => { settingsInjectStarted = v; }, + (inner) => { + remoteParts.settings = pickSettings(inner); + ctx.logger?.info?.("dsh-search-mcp: remote.settings %s", remoteParts.settings ? "ready" : "absent"); + inner.effect(() => () => { remoteParts.settings = null; }, "dsh-search-mcp: clear settings api"); + }, + "remote.settings", + ); + }; + + const liveRemote = () => { + // After soft-inject is live, outer ctx.get('remote.credentials') works (netxops). + let viaGet = null; + let settingsGet = null; + try { + if (typeof ctx.get === "function") { + viaGet = pickCredentials(ctx); + settingsGet = pickSettings(ctx); + } + } catch { /* ignore */ } + try { + if (ctx.remote && typeof ctx.remote === "object" && ctx.remote.credentials) { + return buildRemoteFace({ + credentials: ctx.remote.credentials || viaGet || remoteParts.credentials, + settings: ctx.remote.settings || settingsGet || remoteParts.settings, + $on: typeof ctx.remote.$on === "function" ? ctx.remote.$on.bind(ctx.remote) : remoteParts.$on, + }); + } + } catch { /* ignore */ } + return buildRemoteFace({ + credentials: viaGet || remoteParts.credentials, + settings: settingsGet || remoteParts.settings, + $on: remoteParts.$on, + }); + }; + + // Same deferral as configForms — never soft-inject during apply sync. + setTimeout(() => { try { ensureRemote(); } catch { /* ignore */ } }, 0); + + const getController = () => { + if (controller) return controller; + try { + controller = new SearchMcpCardController(deferred, liveRemote, noopDescribeFace()); + } catch (error) { + ctx.logger?.warn?.("dsh-search-mcp: controller init failed: %s", error); + controller = { + inject: () => ({ + hooks: { + searchMcpCard: createSnapshotStoreFallback({ + available: true, writable: false, dirty: false, invalid: false, saving: false, + failed: false, failDetail: String(error), legacyBlocked: false, overridden: false, + value: { defaultServer: "", maxResults: "", searchTimeoutMs: "", servers: [] }, + serverIds: [], serverKinds: {}, credentialStates: {}, + }), + }, + }), + baseDraft: () => ({ defaultServer: "", maxResults: "", searchTimeoutMs: "", servers: [] }), + readSecretState: async () => {}, + }; + } + return controller; + }; + + try { + ctx.slots.inject("settings.section", () => { + try { + if (typeof SearchMcpSection !== "function") { + throw new Error("SearchMcpSection is " + typeof SearchMcpSection); + } + return ctx.slots.register({ + name: "settings.section", + // Keep distinct from host Config ns `search-mcp` — colliding ids + // silently fail register and the Settings nav entry disappears. + id: "dsh-search-mcp", + order: 30, + label: sectionLabel, + locale: LOCALE_NS, + inject: () => { + try { + ensureRemote(); + return getController().inject(); + } catch (error) { + ctx.logger?.warn?.("dsh-search-mcp: section inject failed: %s", error); + return { + hooks: { + searchMcpCard: createSnapshotStoreFallback(EMPTY_CARD_STATE), + }, + }; + } + }, + }, SearchMcpSection); + } catch (error) { + ctx.logger?.error?.("dsh-search-mcp: settings.section register failed: %s", error); return () => {}; } - }; - - const tryGetForm = (forms, candidates) => { - if (typeof forms.get !== "function") return undefined; - for (const ns of candidates) { - try { - const scope = forms.get(ns); - if (scope && typeof scope.getSnapshot === "function") return { ns, scope }; - } catch { /* try next */ } - } - return undefined; - }; - - const attachForm = (hit, source) => { - ctx.logger?.info?.("dsh-search-mcp: attach form via %s (%s)", source, hit.ns); - deferred.attach(hit.scope); - return registerPluginsItem(hit.ns); - }; - - ctx.inject(["configForms"], (formsCtx) => { - const forms = formsCtx.configForms || formsCtx.get?.("configForms"); - if (!forms) { - formsCtx.logger?.warn?.("dsh-search-mcp: configForms inject fired but service missing"); - return; - } - const candidates = namespaceCandidates(formsCtx); - formsCtx.logger?.info?.("dsh-search-mcp: configForms candidates=%s", candidates.join(",")); - - if (typeof forms.whileServed === "function") { - formsCtx.effect(() => forms.whileServed(candidates, (served) => { - const ns = candidates.find((id) => served.has(id)); - if (!ns) return () => {}; - const hit = tryGetForm(forms, [ns, ...candidates]); - if (!hit) return () => {}; - return attachForm(hit, "whileServed"); - }), "dsh-search-mcp: configForms whileServed"); - } - - const immediate = tryGetForm(forms, candidates); - if (immediate) { - formsCtx.effect( - () => attachForm(immediate, "eager"), - "dsh-search-mcp: configForms eager attach", - ); - } - }); - - ctx.inject(["settingsScope"], (scopeCtx) => { - const binder = scopeCtx.settingsScope || scopeCtx.get?.("settingsScope"); - if (!binder || typeof binder.bind !== "function") { - scopeCtx.logger?.warn?.("dsh-search-mcp: settingsScope present but .bind missing"); - return; - } - scopeCtx.logger?.info?.("dsh-search-mcp: attach form via settingsScope"); - deferred.attach(binder.bind({ namespace: NS })); }); } catch (error) { - ctx.logger?.error?.("dsh-search-mcp: apply aborted: %s", error); + ctx.logger?.warn?.("dsh-search-mcp: settings.section unavailable: %s", error); } + + let pluginsItemId; + const registerPluginsItem = (id) => { + if (pluginsItemId === id) return () => {}; + try { + const off = ctx.slots.inject("plugins.item", () => { + try { + const dispose = ctx.slots.register({ + name: "plugins.item", + id, + order: 30, + label: sectionLabel, + locale: LOCALE_NS, + inject: () => { + try { return getController().inject(); } + catch { return { hooks: {} }; } + }, + }, SearchMcpSection); + pluginsItemId = id; + return () => { + if (pluginsItemId === id) pluginsItemId = undefined; + try { dispose(); } catch { /* ignore */ } + }; + } catch { return () => {}; } + }); + return () => { + if (pluginsItemId === id) pluginsItemId = undefined; + try { off(); } catch { /* ignore */ } + }; + } catch { return () => {}; } + }; + + const tryGetForm = (forms, candidates) => { + if (typeof forms.get !== "function") return undefined; + for (const ns of candidates) { + try { + const scope = forms.get(ns); + if (scope && typeof scope.getSnapshot === "function") return { ns, scope }; + } catch { /* next */ } + } + return undefined; + }; + + const attachForm = (hit, source) => { + ctx.logger?.info?.("dsh-search-mcp: attach form via %s (%s)", source, hit.ns); + deferred.attach(hit.scope); + return registerPluginsItem(hit.ns); + }; + + // Same pattern as netxops — configForms soft-inject is fine with immediately:false. + // Attach configForms by polling — soft-inject early in the Desktop + // bundle order freezes "Loading plugins…" even with immediately:false. + const tryAttachForms = () => { + try { + // IMPORTANT: never read ctx.configForms synchronously during apply — + // on Desktop 0.2 the accessor waits and freezes "Loading plugins…". + // This helper must only run from a deferred timer / microtask. + const forms = ctx.configForms; + if (!forms || typeof forms.get !== "function") return false; + const candidates = namespaceCandidates(ctx); + const immediate = tryGetForm(forms, candidates); + if (immediate) { + attachForm(immediate, "poll"); + if (typeof forms.whileServed === "function") { + ctx.effect(() => forms.whileServed(candidates, (served) => { + try { + const ns = candidates.find((id) => served.has(id)); + if (!ns) return () => {}; + const hit = tryGetForm(forms, [ns, ...candidates]); + if (!hit) return () => {}; + return attachForm(hit, "whileServed"); + } catch { return () => {}; } + }), "dsh-search-mcp: configForms whileServed"); + } + return true; + } + } catch { /* ignore */ } + return false; + }; + // Defer all configForms access until after apply returns. + const startPoll = () => { + if (tryAttachForms()) return; + const timer = setInterval(() => { + if (tryAttachForms()) clearInterval(timer); + }, 1000); + try { + ctx.effect(() => () => clearInterval(timer), "dsh-search-mcp: configForms poll"); + } catch { + clearInterval(timer); + } + }; + setTimeout(startPoll, 0); + + // Credential invalidation — only if remote already present (no get wait). + try { + if (ctx.remote && typeof ctx.remote.$on === "function") { + const remote = ctx.remote; + ctx.effect(() => remote.$on("credentials/reference-updated", (ref) => { + try { + const used = getController().baseDraft().servers.some((row) => row.apiKeyEnv.trim() === ref); + if (used) getController().readSecretState(); + } catch { /* ignore */ } + }), "dsh-search-mcp: credential status invalidation"); + } + } catch { /* soft */ } } //#endregion + //#endregion exports.apply = apply; exports.inject = inject; diff --git a/lib/index.js b/lib/index.js index 3717ec4..9af7853 100644 --- a/lib/index.js +++ b/lib/index.js @@ -56,11 +56,17 @@ const serverSchema = z.object({ maxResults: vol(z.number().step(1).min(1).max(50)), }); +const DEFAULT_BAILIAN_SERVER = { + id: 'bailian', + kind: 'bailian', + apiKeyEnv: 'DASHSCOPE_API_KEY', +}; + export const Config = z.object({ - defaultServer: vol(z.string().default('')), + defaultServer: vol(z.string().default('bailian')), maxResults: vol(z.number().step(1).min(1).max(50).default(8)), searchTimeoutMs: vol(z.number().step(1).min(1000).default(30000)), - servers: vol(z.array(serverSchema).default([])), + servers: vol(z.array(serverSchema).default([DEFAULT_BAILIAN_SERVER])), }); /** Settings namespace owning this plugin's section (Settings → Plugins card). */ @@ -79,70 +85,11 @@ function describeRows(describe) { return []; } -/** Register the search provider and the live settings section. */ +/** Register the search provider. Settings UI is owned by the web client. */ export function apply(ctx, config) { - let current = () => config; - // Optional settings seam: fall back to the composition entry when settings - // is absent (same pattern as @deepseek-ai/dsh-web-search-deepseek). - ctx.inject(['settings'], (settingsCtx) => { - const settings = settingsCtx.settings; - const hooks = { - setSource: (source) => { - current = source; - }, - onChange: () => {}, - }; - if (typeof settings?.installSection === 'function') { - try { - settings.installSection(ctx, SEARCH_MCP_SETTINGS_NAMESPACE, Config, config, hooks); - return; - } catch (error) { - settingsCtx.logger?.warn?.( - 'dsh-search-mcp: installSection failed (will try register/describe): %s', - error instanceof Error ? error.message : error, - ); - } - } - // ≤0.1.5 / some Desktop builds: explicit namespace registration. - // ≥0.1.7: Config is projected from the Loader entry — register may be - // absent or refuse; follow describe(). - if (typeof settings?.register === 'function') { - try { - settings.register(SEARCH_MCP_SETTINGS_NAMESPACE, Config, { base: config, applies: 'live' }); - settingsCtx.logger?.info?.( - 'dsh-search-mcp: settings namespace "%s" registered', - SEARCH_MCP_SETTINGS_NAMESPACE, - ); - } catch (error) { - settingsCtx.logger?.warn?.( - 'dsh-search-mcp: settings.register failed (will follow describe): %s', - error instanceof Error ? error.message : error, - ); - } - } - // DSH ≥0.1.7 / 0.2.0 — Config projection via describe() (no installSection). - const readLive = () => { - const row = describeRows(settings?.describe).find((item) => item.ns === SEARCH_MCP_SETTINGS_NAMESPACE); - if (row?.value !== null && typeof row?.value === 'object' && !Array.isArray(row.value)) { - return { ...config, ...row.value }; - } - return config; - }; - hooks.setSource(readLive); - let last = JSON.stringify(readLive()); - const timer = setInterval(() => { - const next = readLive(); - const fingerprint = JSON.stringify(next); - if (fingerprint === last) return; - last = fingerprint; - hooks.onChange(); - }, 2_000); - settingsCtx.effect(() => () => clearInterval(timer), 'dsh-search-mcp: settings describe poll'); - settingsCtx.logger?.info?.( - 'dsh-search-mcp: following settings via describe() (DSH ≥0.1.7 / 0.2 path)', - ); - }); - // `registerSearchProvider` owns its cleanup via ctx.effect (HMR/dispose safe). + const current = () => config; + // Desktop 0.2: avoid soft-inject(['settings']) + describe poll during bring-up. + // Loader already projects Config from the cordis entry / patch insert. ctx.web.registerSearchProvider(new SearchMCPProvider(() => resolveOptions(ctx, current()))); } diff --git a/package.json b/package.json index aacbea0..7d1c048 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-search-mcp", - "version": "0.2.5", + "version": "0.2.32", "description": "Replace dsh's built-in web search with search MCP servers (Tavily / Brave / Exa / Perplexity / DuckDuckGo / custom), configured from the web Settings page. When this plugin is enabled the built-in DeepSeek search provider is disabled.", "type": "module", "main": "lib/index.js", @@ -40,7 +40,7 @@ }, "client": { "platform": "web", - "immediately": true, + "immediately": false, "inject": [ "@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-locale" diff --git a/test/compatibility.test.js b/test/compatibility.test.js index 6617c08..f09fb17 100644 --- a/test/compatibility.test.js +++ b/test/compatibility.test.js @@ -1,18 +1,19 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' -const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const read = (path) => readFile(resolve(root, path), 'utf8'); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const read = (path) => readFile(resolve(root, path), 'utf8') test('package exports resolve and peerDependencies stay open', async () => { - const pkg = JSON.parse(await read('package.json')); - assert.equal(pkg.exports['.'], './lib/index.js'); - assert.equal(pkg.exports['./client'], './lib/client.browser.js'); - assert.equal(pkg.engines.node, '>=20'); - assert.equal(pkg.version, '0.2.5'); + const pkg = JSON.parse(await read('package.json')) + assert.equal(pkg.exports['.'], './lib/index.js') + assert.equal(pkg.exports['./client'], './lib/client.browser.js') + assert.equal(pkg.engines.node, '>=20') + assert.equal(pkg.version, '0.2.32') + assert.equal(pkg.dsh.client.immediately, false) for (const name of [ '@deepseek-ai/dsh-api-remotes', @@ -22,71 +23,65 @@ test('package exports resolve and peerDependencies stay open', async () => { '@deepseek-ai/dsh-web', '@deepseek-ai/schemastery', ]) { - assert.equal(pkg.peerDependencies[name], '*'); + assert.equal(pkg.peerDependencies[name], '*') } - assert.equal(pkg.dependencies.undici, '6.28.0'); - assert.equal(pkg.dependencies['ipaddr.js'], '2.5.0'); - assert.equal(pkg.dependencies['@modelcontextprotocol/sdk'], '1.30.0'); -}); +}) test('browser half registers Settings sidebar + deferred form attach', async () => { - const client = await read('lib/client.browser.js'); - assert.match(client, /name: "settings\.section"/); - assert.match(client, /SearchMcpSection/); - assert.match(client, /createDeferredScope/); - assert.match(client, /createMemoryScope/); - assert.doesNotMatch(client, /name:\s*["']settings\.plugin\.item["']/); - assert.doesNotMatch(client, /inject\(\s*["']settings\.plugin\.item["']/); - assert.match(client, /name: "plugins\.item"/); - assert.match(client, /configForms/); - assert.match(client, /whileServed/); - assert.match(client, /credentials\/reference-updated/); - assert.match(client, /CREDENTIAL_DESCRIBE_BATCH_SIZE = 64/); - assert.match(client, /legacyKeyBlocked/); - assert.match(client, /remote\.settings\.describe\(\)/); - assert.match(client, /remote\.credentials\.set\(/); -}); + const client = await read('lib/client.browser.js') + assert.match(client, /name: "settings\.section"/) + assert.match(client, /LOCALE_NS = "settings\.search-mcp"/) + assert.match(client, /const NS = "search-mcp"/) + // Section id must stay distinct from host Config ns `search-mcp`. + assert.match(client, /id: "dsh-search-mcp"/) + assert.match(client, /locale: LOCALE_NS/) + assert.match(client, /SearchMcpSection/) + assert.match(client, /createDeferredScope/) + assert.match(client, /createMemoryScope/) + assert.doesNotMatch(client, /inject\(\["configForms"\]/) + assert.match(client, /configForms poll/) + assert.match(client, /remote\.credentials/) + assert.doesNotMatch(client, /ctx\.inject\(\["remote"\]/) + assert.doesNotMatch(client, /name:\s*["']settings\.plugin\.item["']/) + assert.doesNotMatch(client, /inject\(\["settingsScope"\]/) + const applyBody = client.slice(client.indexOf('function apply(ctx)'), client.indexOf('exports.apply')) + assert.doesNotMatch(applyBody, /ctx\.get\?\.\(["']remote["']\)|ctx\.get\(["']remote["']\)/) + assert.match(client, /const createSnapshotStore = createSnapshotStoreFallback/) + assert.match(client, /CREDENTIAL_DESCRIBE_BATCH_SIZE = 64/) + assert.match(client, /legacyKeyBlocked/) +}) -test('host Config marks editable fields volatile for Desktop 0.2', async () => { - const host = await read('lib/index.js'); - assert.match(host, /function vol\(/); - assert.match(host, /defaultServer: vol\(/); - assert.match(host, /servers: vol\(/); - assert.match(host, /settings\.register\(/); -}); +test('host registers search provider without settings soft-inject poll', async () => { + const host = await read('lib/index.js') + assert.match(host, /function vol\(/) + assert.match(host, /registerSearchProvider/) + const applyBody = host.slice(host.indexOf('export function apply'), host.indexOf('function resolveOptions')) + assert.doesNotMatch(applyBody, /ctx\.inject\(\s*\[['"]settings['"]\]/) + assert.doesNotMatch(applyBody, /setInterval\(/) +}) test('known providers are CDKey-only while custom keeps advanced fields', async () => { - const client = await read('lib/client.browser.js'); - const catalog = client.slice(client.indexOf('const CATALOG = {'), client.indexOf('const KIND_OPTIONS')); - assert.doesNotMatch(catalog, /https?:\/\//); - assert.doesNotMatch(catalog, /toolName|authParam|transport/); - assert.match(client, /const known = row\.kind !== "custom"/); - assert.match(client, /children: known \? \[/); - assert.match(client, /No endpoint is required for known providers/); - assert.match(client, /已知提供商不需要填写端点链接/); - assert.match(client, /kind, apiKey: "", apiKeyEnv: ""/); -}); + const client = await read('lib/client.browser.js') + const catalog = client.slice(client.indexOf('const CATALOG = {'), client.indexOf('const KIND_OPTIONS')) + assert.doesNotMatch(catalog, /https?:\/\//) + assert.doesNotMatch(catalog, /toolName|authParam|transport/) + assert.match(client, /const known = row\.kind !== "custom"/) + assert.match(client, /已知提供商不需要填写端点链接/) +}) test('HTTP transport pins DNS and applies one guarded fetch to every SDK request', async () => { - const transport = await read('lib/client.js'); - assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/); - assert.match(transport, /new Agent\(\{[\s\S]*connect: \{ lookup: validated\.lookup \}/); - assert.match(transport, /requestUrl\.origin !== expectedOrigin/); - assert.match(transport, /dispatcher: agent/); - assert.match(transport, /redirect: 'error'/); - assert.match(transport, /fetch: secureFetch/); - assert.match(transport, /await client\.close\(\)[\s\S]*await runtime\?\.close\(\)/); - assert.doesNotMatch(transport, /new URL\(server\.url\)[\s\S]*new StreamableHTTPClientTransport\(url, \{\s*requestInit:/); -}); + const transport = await read('lib/client.js') + assert.match(transport, /validateHttpEndpoint\(server\.url, \{ signal \}\)/) + assert.match(transport, /dispatcher: agent/) + assert.match(transport, /redirect: 'error'/) +}) -test('bundle replaces built-in search and leaves default row endpoint-free', async () => { - const patch = await read('cordis.patch.yml'); - assert.match(patch, /searchProvider: search-mcp/); - assert.match(patch, /- id: web-search-deepseek\s+disabled: true/); - assert.match(patch, /- id: tool-web\s+disabled: false/); - assert.match(patch, /fetch: false/); - assert.match(patch, /searchMaxResults: 50/); - assert.match(patch, /searchMaxQueries: 4/); - const defaultRow = patch.slice(patch.indexOf('- id: tavily'), patch.indexOf('- id: web')); - assert.doesNotMatch(defaultRow, /url:|toolName:|authParam:|transport:/); -}); +test('bundle inserts search-mcp without pinning web.searchProvider (Desktop 0.2 boot-safe)', async () => { + const patch = await read('cordis.patch.yml') + assert.doesNotMatch(patch, /^\s*searchProvider:\s*search-mcp\s*$/m) + assert.doesNotMatch(patch, /- id: web-search-deepseek\s+disabled: true/) + assert.match(patch, /id: search-mcp/) + assert.match(patch, /kind: bailian/) + assert.match(patch, /apiKeyEnv: DASHSCOPE_API_KEY/) + assert.match(patch, /- id: tool-web\s+disabled: false/) +}) diff --git a/test/runtime.test.js b/test/runtime.test.js index 93c8e8a..d114cff 100644 --- a/test/runtime.test.js +++ b/test/runtime.test.js @@ -5,6 +5,7 @@ import { extractSearchResult } from '../lib/extract.js'; test('catalog exposes every supported provider preset', () => { assert.deepEqual(Object.keys(SEARCH_MCP_CATALOG), [ + 'bailian', 'tavily', 'brave', 'exa', @@ -14,6 +15,18 @@ test('catalog exposes every supported provider preset', () => { ]); }); +test('bailian preset pins DashScope WebSearch MCP', () => { + const bailian = resolveServer({ id: 'bailian', kind: 'bailian', apiKeyEnv: 'DASHSCOPE_API_KEY' }); + assert.equal(bailian.transport, 'http'); + assert.equal(bailian.url, 'https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp'); + assert.equal(bailian.authStyle, 'header'); + assert.equal(bailian.authParam, 'Authorization'); + assert.equal(bailian.authPrefix, 'Bearer '); + assert.equal(bailian.toolName, 'bailian_web_search'); + assert.equal(bailian.apiKeyEnv, 'DASHSCOPE_API_KEY'); + assert.equal(bailian.needsKey, true); +}); + test('known providers ignore stored connection overrides', () => { const tavily = resolveServer({ id: 'primary',