dsh-search-mcp/lib/client.browser.js
oliver d729b03f70 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 <cursoragent@cursor.com>
2026-10-10 04:24:13 +08:00

1766 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* dsh-search-mcp — browser half.
*
* Surfaces (matching dsh-im-ops / netxops on Desktop 0.2):
* - Always register `settings.section` (Settings sidebar).
* - Soft-attach real form scopes into a deferred memory scope for Save.
* - Optional `plugins.item` when configForms attaches.
* - Do NOT use `settings.plugin.item` — missing on Desktop 0.2 and kills boot.
*
* Form: structured (no raw JSON) — default server, max results, timeout,
* quick-add presets, collapsible server rows.
*
* Loaded through window.__ModuleLoader__ like every shipped client bundle.
*/
window.__ModuleLoader__.load({
id: "dsh-search-mcp",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
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 = {};
try {
_deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
} catch {
_deepseek_ai_dsh_client_ui_primitives = {};
}
if (
_deepseek_ai_dsh_client_ui_primitives
&& _deepseek_ai_dsh_client_ui_primitives.default
&& typeof _deepseek_ai_dsh_client_ui_primitives.default === "object"
) {
_deepseek_ai_dsh_client_ui_primitives = {
..._deepseek_ai_dsh_client_ui_primitives.default,
..._deepseek_ai_dsh_client_ui_primitives,
};
}
// 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();
return {
getSnapshot() {
return state;
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
set(next) {
state = next;
for (const listener of listeners) {
try { listener(); } catch { /* ignore */ }
}
},
};
}
const createSnapshotStore = createSnapshotStoreFallback;
const _deepseek_ai_dsh_client_runtime_client = { createSnapshotStore };
//#region styles
const css = [
".smcp_section{display:flex;flex-direction:column;gap:12px;padding:4px 0 24px}",
".smcp_lede{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;line-height:1.5}",
".smcp_bodyFlat{border-top:none;margin:0;padding:0}",
".smcp_card{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}",
".smcp_card:hover{border-color:var(--dsw-alias-label-dimmed)}",
".smcp_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}",
".smcp_headBtn{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}",
".smcp_headBtn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}",
".smcp_head{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}",
".smcp_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}",
".smcp_desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}",
".smcp_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}",
".smcp_chevronOpen{transform:rotate(180deg)}",
".smcp_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px;display:flex;flex-direction:column;gap:0}",
".smcp_field{display:flex;flex-direction:column;gap:6px;padding:12px 0}",
".smcp_field + .smcp_field{border-top:1px solid var(--dsw-alias-border-l2)}",
".smcp_label{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:1.5}",
".smcp_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}",
".smcp_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5}",
".smcp_input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}",
".smcp_select{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 8px;font-size:13px;line-height:1.5}",
".smcp_select:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none}",
".smcp_invalid{border-color:var(--dsw-alias-label-error)}",
".smcp_error{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5}",
".smcp_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}",
".smcp_badgeMuted{white-space:nowrap;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px}",
".smcp_badgeDanger{white-space:nowrap;color:var(--dsw-alias-label-error);border:1px solid var(--dsw-alias-label-error);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px}",
".smcp_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5}",
".smcp_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}",
".smcp_reset:disabled{cursor:default}",
".smcp_footer{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2)}",
".smcp_btn{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 12px;height:auto;min-height:32px;font-size:13px;line-height:1.5}",
/* Match system plugin cards (e.g. dsh-ops-cron): primary text on contrast surface */
".smcp_btnPrimary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}",
".smcp_btnPrimary:disabled{opacity:.5;cursor:default}",
".smcp_btnGhost{border-color:var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary)}",
".smcp_btnGhost:disabled{opacity:.5;cursor:default}",
".smcp_failDetail{color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.45;text-align:left}",
".smcp_quickAdd{display:flex;flex-wrap:wrap;gap:8px;padding:2px 0 6px}",
".smcp_quickBtn{border:1px solid var(--dsw-alias-border-l2);background:0 0;color:var(--dsw-alias-label-secondary);border-radius:999px;padding:4px 12px;font-size:12px;line-height:1.5;cursor:pointer}",
".smcp_quickBtn:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}",
".smcp_quickBtn:disabled{opacity:.5;cursor:default}",
".smcp_row{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;background:var(--dsw-alias-bg-layer-2)}",
".smcp_rowHead{display:flex;align-items:center;gap:8px;min-width:0}",
".smcp_rowToggle{appearance:none;border:0;background:0 0;padding:2px;cursor:pointer;color:var(--dsw-alias-label-tertiary);display:inline-flex;align-items:center;flex:none;transition:transform .16s}",
".smcp_rowToggle:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px;border-radius:6px}",
".smcp_rowToggleOpen{transform:rotate(180deg)}",
".smcp_kindBadge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px;flex:none}",
".smcp_rowTitle{flex:0 0 auto;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600;line-height:1.5}",
".smcp_rowSummary{flex:1;min-width:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".smcp_rowBody{border-top:1px dashed var(--dsw-alias-border-l2);margin-top:2px;padding-top:8px;display:flex;flex-direction:column;gap:8px}",
".smcp_rowId{flex:1;min-width:0}",
".smcp_rowGrid{display:grid;grid-template-columns:1fr 1fr;gap:8px}",
".smcp_rowGrid3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px}",
".smcp_cell{display:flex;flex-direction:column;gap:4px;min-width:0}",
".smcp_cellLabel{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1.5}",
".smcp_cellHint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:11px;line-height:1.5}",
".smcp_add{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;border:1px dashed var(--dsw-alias-border-l2);background:0 0;color:var(--dsw-alias-label-secondary);border-radius:8px;padding:6px 12px;font-size:12px;line-height:1.5;cursor:pointer}",
".smcp_add:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}",
".smcp_del{border:1px solid var(--dsw-alias-border-l2);background:0 0;color:var(--dsw-alias-label-secondary);border-radius:8px;padding:4px 10px;font-size:12px;line-height:1.5;cursor:pointer;flex:none}",
".smcp_del:hover{color:var(--dsw-alias-label-error);border-color:var(--dsw-alias-label-error)}"
].join("");
const cssTag = "dsh-search-mcp/card.css";
if (typeof document !== "undefined" && document.querySelector(`style[data-plugin-css="${cssTag}"]`) === null) {
const tag = document.createElement("style");
tag.dataset.plugin = "dsh-search-mcp";
tag.dataset.pluginCss = cssTag;
tag.textContent = css;
document.head.appendChild(tag);
}
//#endregion
//#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 },
perplexity: { needsKey: true },
duckduckgo: { needsKey: false },
custom: { needsKey: false }
};
const KIND_OPTIONS = Object.keys(CATALOG);
const TRANSPORT_OPTIONS = ["http", "stdio"];
const AUTH_STYLE_OPTIONS = ["", "query", "header"];
/** Brand labels shown on the kind badge / quick-add buttons. */
const KIND_LABELS = {
bailian: "百炼",
tavily: "Tavily",
brave: "Brave",
exa: "Exa",
perplexity: "Perplexity",
duckduckgo: "DuckDuckGo",
custom: "Custom"
};
/** Kinds offered as one-click presets (custom is added via the dashed button). */
const QUICK_KINDS = ["bailian", "tavily", "brave", "exa", "perplexity", "duckduckgo"];
//#endregion
//#region locale
const en = {
title: "Search MCP",
description: "Search MCP servers behind the web_search tool; the built-in DeepSeek search stays disabled while this plugin is enabled.",
defaultServer: "Default server",
defaultServerHint: "Which server row serves searches (empty = first row).",
maxResults: "Max results",
maxResultsHint: "Sources returned per search (1–50; empty = default).",
searchTimeoutMs: "Search timeout (s)",
searchTimeoutMsHint: "Abort a search that takes longer than this.",
servers: "Search MCP servers",
serversHint: "Choose a provider and enter its CDKey/API key. Known providers keep connection details in the host catalog; custom rows expose advanced MCP settings.",
quickAdd: "Common providers",
quickAddHint: "Add a provider row, then enter its CDKey/API key before saving. No endpoint is required for known providers.",
addServer: "Add custom server",
removeServer: "Delete",
rowId: "ID",
rowIdHint: "Stable id used by “default server”.",
rowKind: "Provider",
rowTransport: "Transport",
rowUrl: "Endpoint (URL)",
rowCommand: "Command",
rowArgs: "Args (comma separated)",
rowApiKey: "API key",
cdKey: "CDKey / API key",
cdKeyHint: "enter CDKey / API key",
keyNotRequired: "no key required",
rowApiKeyEnv: "Key env/credential ref",
rowAuthStyle: "Key placement",
rowAuthParam: "Key param / env name",
rowToolName: "MCP tool name",
rowMaxResults: "Max results",
rowMaxResultsHint: "Empty = follow the global max results.",
missingId: "ID is required.",
dupId: "This ID is already used by another row.",
overridden: "Overridden",
overridesGlobal: "overrides global",
keySet: "key set",
keyRef: "credential ref",
keyConfigured: "credential configured",
keyRefMissing: "credential missing",
keyMissing: "key missing",
legacyKey: "legacy key",
legacyKeyBlocked: "This server still uses a hidden legacy literal key. Enter a replacement key or credential reference before changing the server list.",
customKind: "Custom",
reset: "Reset to default",
readOnly: "This deployment stores settings read-only.",
save: "Save",
saving: "Saving…",
discard: "Discard",
unsaved: "Unsaved",
saveFailed: "The deployment did not accept these values; they were left for you to correct.",
placeholderUrl: "https://mcp.example.com/mcp/",
placeholderCommand: "npx",
placeholderArgs: "-y, duckduckgo-mcp-server",
placeholderToolName: "tavily_search",
placeholderAuthParam: "tavilyApiKey"
};
const zh = {
title: "搜索 MCP",
description: "web_search 工具背后的搜索 MCP 服务器;插件启用期间内置 DeepSeek 搜索保持禁用。",
defaultServer: "默认服务器",
defaultServerHint: "使用哪一行服务器(留空 = 第一行)。",
maxResults: "结果数上限",
maxResultsHint: "每次搜索最多返回的条数(1–50;留空 = 默认)。",
searchTimeoutMs: "搜索超时(秒)",
searchTimeoutMsHint: "超过该时长即中止搜索。",
servers: "搜索 MCP 服务器",
serversHint: "选择提供商并填写 CDKey/API key。已知提供商的连接细节由 Host catalog 管理;自定义行才显示 MCP 高级设置。",
quickAdd: "常用提供商",
quickAddHint: "添加提供商行后填写 CDKey/API key 再保存。已知提供商不需要填写端点链接。",
addServer: "添加自定义服务器",
removeServer: "删除",
rowId: "ID",
rowIdHint: "“默认服务器”下拉框引用的稳定标识。",
rowKind: "提供商",
rowTransport: "传输方式",
rowUrl: "端点(URL)",
rowCommand: "命令",
rowArgs: "参数(逗号分隔)",
rowApiKey: "API 密钥",
cdKey: "CDKey / API key",
cdKeyHint: "请填写 CDKey / API key",
keyNotRequired: "无需密钥",
rowApiKeyEnv: "密钥环境变量/凭证引用",
rowAuthStyle: "密钥位置",
rowAuthParam: "密钥参数名/环境变量名",
rowToolName: "MCP 工具名",
rowMaxResults: "结果数上限",
rowMaxResultsHint: "留空 = 跟随全局结果数上限。",
missingId: "ID 不能为空。",
dupId: "该 ID 已被另一行占用。",
overridden: "已覆盖",
overridesGlobal: "覆盖全局",
keySet: "已填密钥",
keyRef: "凭证引用",
keyConfigured: "凭证已配置",
keyRefMissing: "凭证未配置",
keyMissing: "缺少密钥",
legacyKey: "旧版密钥",
legacyKeyBlocked: "此服务器仍使用客户端不可见的旧版字面密钥。修改服务器列表前,请输入替代密钥或凭证引用。",
customKind: "自定义",
reset: "恢复默认",
readOnly: "本部署的设置为只读。",
save: "保存",
saving: "保存中…",
discard: "放弃修改",
unsaved: "未保存",
saveFailed: "本部署没有接受这些值,已保留供你修改。",
placeholderUrl: "https://mcp.example.com/mcp/",
placeholderCommand: "npx",
placeholderArgs: "-y, duckduckgo-mcp-server",
placeholderToolName: "tavily_search",
placeholderAuthParam: "tavilyApiKey"
};
//#endregion
const NS = "search-mcp";
const LOCALE_NS = "settings.search-mcp";
//#region row helpers
function emptyRow() {
return {
id: "",
kind: "custom",
transport: "http",
url: "",
command: "",
args: "",
apiKey: "",
apiKeyEnv: "",
authStyle: "",
authParam: "",
authPrefix: "",
toolName: "",
maxResults: ""
};
}
/** Convert a stored server entry (host shape) to a row draft. */
function rowFromEntry(entry) {
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,
transport: known ? "" : (entry.transport ?? "http"),
url: known ? "" : (entry.url ?? ""),
command: known ? "" : (entry.command ?? ""),
args: known ? "" : (Array.isArray(entry.args) ? entry.args.join(", ") : ""),
apiKey: entry.apiKey ?? "",
apiKeyEnv: entry.apiKeyEnv || (known ? presetEnv : "") || "",
authStyle: known ? "" : (entry.authStyle ?? ""),
authParam: known ? "" : (entry.authParam ?? ""),
authPrefix: known ? "" : (entry.authPrefix ?? ""),
toolName: known ? "" : (entry.toolName ?? ""),
maxResults: entry.maxResults === undefined ? "" : String(entry.maxResults)
};
}
/** Convert a row draft to the minimal known entry or full custom entry. */
function entryFromRow(row) {
const entry = { id: row.id.trim(), kind: row.kind };
const known = Object.hasOwn(CATALOG, row.kind) && row.kind !== "custom";
if (!known) {
entry.transport = row.transport || "http";
if (entry.transport === "stdio") {
if (row.command.trim() !== "") entry.command = row.command.trim();
const args = row.args.split(",").map((a) => a.trim()).filter(Boolean);
if (args.length > 0) entry.args = args;
} else {
const url = normalizeServerUrl(row);
if (url !== "") entry.url = url;
}
if (row.authStyle !== "") entry.authStyle = row.authStyle;
if (row.authParam.trim() !== "") entry.authParam = row.authParam.trim();
const prefix = resolveAuthPrefix(row);
if (prefix !== "") entry.authPrefix = prefix;
if (row.toolName.trim() !== "") entry.toolName = row.toolName.trim();
}
if (row.apiKey.trim() !== "") entry.apiKey = row.apiKey.trim();
if (row.apiKeyEnv.trim() !== "") entry.apiKeyEnv = row.apiKeyEnv.trim();
if (row.maxResults.trim() !== "") {
const n = Number(row.maxResults);
if (Number.isFinite(n) && n > 0) entry.maxResults = Math.round(n);
}
return entry;
}
/** Changing provider starts a clean credential/connection draft. */
function applyKindDefaults(row, kind) {
const presetEnv = CATALOG[kind]?.apiKeyEnv || "";
const next = { ...row, kind, apiKey: "", apiKeyEnv: presetEnv };
if (kind !== "custom") {
return { ...next, transport: "", url: "", command: "", args: "", authStyle: "", authParam: "", toolName: "" };
}
return { ...next, transport: row.transport || "http" };
}
/** Suggest a unique row id for a preset kind (tavily, tavily2, …). */
function suggestId(servers, kind) {
const used = new Set(servers.map((row) => row.id.trim()).filter(Boolean));
let id = kind;
let n = 2;
while (used.has(id)) id = `${kind}${n++}`;
return id;
}
/** Brand label for a kind, localized for `custom`. */
function kindLabel(kind, t) {
if (kind === "custom") return t("customKind");
return KIND_LABELS[kind] ?? kind;
}
function deepEqualJson(left, right) {
return JSON.stringify(left) === JSON.stringify(right);
}
/** Snapshot-safe copy — `createSnapshotStore.set` deep-freezes state in
* non-production, so the live editable draft must never be published by reference. */
function cloneDraft(draft) {
return {
defaultServer: draft.defaultServer,
maxResults: draft.maxResults,
searchTimeoutMs: draft.searchTimeoutMs,
servers: draft.servers.map((row) => ({ ...row })),
};
}
function credentialRefFor(row) {
if (row.apiKeyEnv.trim() !== "") return row.apiKeyEnv.trim();
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`;
}
/** Normalize Bailian WebSearch endpoint if the user truncated it. */
function normalizeServerUrl(row) {
let url = (row.url || "").trim();
if (!url) return url;
if (row.toolName === "bailian_web_search" || url.includes("dashscope.aliyuncs.com")) {
if (url === "https://dashscope.aliyuncs.com/api/v1"
|| url === "https://dashscope.aliyuncs.com/api/v1/"
|| /\/api\/v1\/?$/.test(url) && url.includes("dashscope")) {
url = "https://dashscope.aliyuncs.com/api/v1/mcps/WebSearch/mcp";
}
if (url.endsWith("/WebSearch/sse")) {
url = url.replace(/\/WebSearch\/sse$/, "/WebSearch/mcp");
}
}
return url;
}
function resolveAuthPrefix(row) {
const explicit = (row.authPrefix || "").trim();
if (explicit !== "") return explicit.endsWith(" ") ? explicit : `${explicit} `;
if (row.authStyle === "header" && row.authParam.trim() === "Authorization") return "Bearer ";
return "";
}
const CREDENTIAL_DESCRIBE_BATCH_SIZE = 64;
/** 0.1.2+ Remote face: `remote.credentials.describe(refs)` → `{ ok, value }`. */
async function describeCredentialRefs(remote, refs) {
const states = {};
for (let index = 0; index < refs.length; index += CREDENTIAL_DESCRIBE_BATCH_SIZE) {
const batch = refs.slice(index, index + CREDENTIAL_DESCRIBE_BATCH_SIZE);
const response = await remote.credentials.describe(batch);
if (!response.ok) throw new Error(response.error?.message || "credential state lookup failed");
Object.assign(states, response.value ?? {});
}
return states;
}
async function rollbackNewCredentialRefs(remote, refs) {
for (const ref of [...new Set(refs)].reverse()) {
try {
await remote.credentials.unset(ref);
} catch {
// The save already failed; preserve the original error state.
}
}
}
async function rollbackSettingsWrites(scope, writes) {
for (const [field, previous] of writes.reverse()) {
try {
if (previous === undefined) await scope.unset(field);
else await scope.set(field, previous);
} catch {
// The UI remains failed so the user can retry or discard the draft.
}
}
}
//#endregion
//#region controller (whole-section staged draft)
var SearchMcpCardController = class {
constructor(scope, remoteOrGetter, describeFace) {
this.scope = scope;
// 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();
this.saving = false;
this.failed = false;
this.failDetail = "";
this.legacyBlocked = false;
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 {
this.store = createSnapshotStoreFallback(this.project());
}
if (!this.store || typeof this.store.set !== "function") {
this.store = createSnapshotStoreFallback(this.project());
}
scope.subscribe(() => {
if (this.draft === null) this.publish();
void this.readSecretState();
});
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();
}
value() {
return this.section().value ?? {};
}
userLayer() {
return this.section().user;
}
baseDraft() {
const value = this.value();
return {
defaultServer: typeof value.defaultServer === "string" ? value.defaultServer : "",
maxResults: typeof value.maxResults === "number" ? String(value.maxResults) : "",
searchTimeoutMs: typeof value.searchTimeoutMs === "number" ? String(value.searchTimeoutMs) : "",
servers: Array.isArray(value.servers) ? value.servers.map((entry) => ({
...rowFromEntry(entry),
legacySecret: this.legacySecretIds.has(entry.id)
})) : []
};
}
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 remote.settings.describe();
if (response.ok) {
view = (response.value?.namespaces ?? []).find((candidate) => candidate.ns === NS);
}
} 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;
const index = Number(secret.path[1]);
const id = Number.isInteger(index) ? servers[index]?.id : undefined;
if (typeof id === "string" && id.length > 0) ids.add(id);
}
const wasReady = this.secretStateReady;
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() : ""),
...scopeServers.map((entry) => typeof entry.apiKeyEnv === "string" ? entry.apiKeyEnv.trim() : ""),
...this.knownConfiguredRefs,
].filter(Boolean))];
if (refs.length > 0 && remote.__stub !== true) {
try {
const described = await describeCredentialRefs(remote, refs);
this.credentialStates = this.applyKnownConfigured(described);
} catch {
this.credentialStates = this.applyKnownConfigured(this.credentialStates);
}
} else {
// Never wipe optimistic Save marks when describe is empty/unavailable.
this.credentialStates = this.applyKnownConfigured(this.credentialStates);
}
this.secretStateReady = true;
const credentialsChanged = !deepEqualJson(previousCredentialStates, this.credentialStates);
if (this.draft === null && (changed || credentialsChanged || !wasReady)) this.publish();
}
ensureDraft() {
if (this.draft === null) this.draft = this.baseDraft();
return this.draft;
}
project() {
const snapshot = this.section();
const draft = this.draft;
const sectionValue = snapshot.value ?? {};
const dirty = draft !== null && JSON.stringify(draft) !== JSON.stringify(this.baseDraft());
const ids = draft !== null ? draft.servers.map((row) => row.id.trim()) : [];
const invalid = draft !== null && (ids.some((id) => id === "") || new Set(ids.filter(Boolean)).size !== ids.filter(Boolean).length);
const serverKinds = draft !== null
? Object.fromEntries(draft.servers.map((row) => [row.id.trim(), row.kind]))
: Object.fromEntries((Array.isArray(sectionValue.servers) ? sectionValue.servers : []).map((s) => [s.id, s.kind]));
return {
available: snapshot.status === "ready",
// Do not gate edits on secretStateReady — a failed describe must not freeze the form.
writable: snapshot.writable === true,
dirty,
invalid,
saving: this.saving,
failed: this.failed,
failDetail: this.failDetail,
legacyBlocked: this.legacyBlocked,
overridden: this.userLayer() !== null && typeof this.userLayer() === "object" && !Array.isArray(this.userLayer())
? Object.keys(this.userLayer()).length > 0
: false,
value: draft !== null ? cloneDraft(draft) : this.baseDraft(),
serverIds: draft !== null
? ids.filter(Boolean)
: (Array.isArray(sectionValue.servers) ? sectionValue.servers.map((s) => s.id).filter(Boolean) : []),
serverKinds,
credentialStates: this.applyKnownConfigured(this.credentialStates),
configuredRefs: [...this.knownConfiguredRefs],
};
}
publish() {
if (!this.store || typeof this.store.set !== "function") return;
this.store.set(this.project());
}
actions() {
return {
editScalar: (field, text) => {
this.ensureDraft()[field] = text;
this.failed = false;
this.legacyBlocked = false;
this.publish();
},
editRow: (index, field, value) => {
const draft = this.ensureDraft();
draft.servers[index] = { ...draft.servers[index], [field]: value };
this.failed = false;
this.legacyBlocked = false;
this.publish();
},
changeKind: (index, kind) => {
const draft = this.ensureDraft();
draft.servers[index] = applyKindDefaults(draft.servers[index], kind);
this.failed = false;
this.legacyBlocked = false;
this.publish();
},
addServer: () => {
const draft = this.ensureDraft();
draft.servers.push(emptyRow());
this.failed = false;
this.legacyBlocked = false;
this.publish();
},
quickAdd: (kind) => {
const draft = this.ensureDraft();
const id = suggestId(draft.servers, kind);
const row = applyKindDefaults({ ...emptyRow(), id, kind }, kind);
draft.servers.push(row);
this.failed = false;
this.legacyBlocked = false;
this.publish();
},
removeServer: (index) => {
const draft = this.ensureDraft();
draft.servers.splice(index, 1);
this.failed = false;
this.legacyBlocked = false;
this.publish();
},
discard: () => {
if (this.draft === null && !this.failed && !this.legacyBlocked) return;
this.draft = null;
this.failed = false;
this.failDetail = "";
this.legacyBlocked = false;
this.publish();
},
save: () => {
this.save();
}
};
}
async save() {
if (this.draft === null || this.saving) return;
const draft = this.draft;
const ids = draft.servers.map((row) => row.id.trim());
const invalid = ids.some((id) => id === "") || new Set(ids.filter(Boolean)).size !== ids.filter(Boolean).length;
if (invalid) return;
const base = this.baseDraft();
const serversChanged = !deepEqualJson(draft.servers, base.servers);
if (serversChanged) {
const blocked = draft.servers.some((row) => row.legacySecret && row.apiKey.trim() === "" && row.apiKeyEnv.trim() === "");
if (blocked) {
this.legacyBlocked = true;
this.publish();
return;
}
}
this.saving = true;
this.failed = false;
this.failDetail = "";
this.legacyBlocked = false;
this.publish();
let remote = this.getRemote();
let landed = true;
let failDetail = "";
let serverEntries;
const createdCredentialRefs = [];
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 (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 && !inlineSecrets && credentialWrites.some(({ ref }) => credentialBefore[ref]?.writable === false)) {
landed = false;
failDetail = "credential is not writable";
}
if (landed && !inlineSecrets) {
for (const { ref, value } of credentialWrites) {
if (!credentialBefore[ref]?.configured) createdCredentialRefs.push(ref);
try {
const response = await remote.credentials.set(ref, value);
if (!response.ok) {
landed = false;
failDetail = response.error?.message || "credentials.set failed";
break;
}
} catch (error) {
landed = false;
failDetail = error instanceof Error ? error.message : String(error);
break;
}
}
}
if (!landed) {
await rollbackNewCredentialRefs(remote, createdCredentialRefs);
this.saving = false;
this.failed = true;
this.failDetail = failDetail;
this.publish();
return;
}
for (const row of draft.servers) {
const entry = entryFromRow(row);
if (row.apiKey.trim() !== "") {
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";
}
delete entry.legacySecret;
serverEntries.push(entry);
}
}
const ops = [];
const planned = [
["defaultServer", draft.defaultServer.trim(), base.defaultServer],
["maxResults", draft.maxResults.trim() === "" ? undefined : Number(draft.maxResults), base.maxResults === "" ? undefined : Number(base.maxResults)],
["searchTimeoutMs", draft.searchTimeoutMs.trim() === "" ? undefined : Number(draft.searchTimeoutMs), base.searchTimeoutMs === "" ? undefined : Number(base.searchTimeoutMs)],
...(serversChanged ? [["servers", serverEntries, this.value().servers]] : [])
];
for (const [field, value, previous] of planned) {
if (deepEqualJson(value, previous)) continue;
if (value === undefined) ops.push({ op: "unset", path: [field] });
else ops.push({ op: "set", path: [field], 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);
}
}
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;
}
}
if (!landed) {
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) void this.readSecretState();
}
inject() {
return {
hooks: { searchMcpCard: this.store },
...this.actions()
};
}
};
//#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);
}
function SearchMcpCard(props) {
return SearchMcpView(props, false);
}
function SearchMcpView(props, asSection) {
const t = typeof props.t === "function" ? props.t : (key) => key;
const [open, setOpen] = (0, react.useState)(!!asSection);
// 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 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: "—" },
...state.serverIds.map((id) => ({ value: id, label: `${id} · ${kindLabel(state.serverKinds[id], t)}` }))
];
const rowIds = value.servers.map((row) => row.id.trim()).filter(Boolean);
const dupIds = new Set(rowIds.filter((id, index) => rowIds.indexOf(id) !== index));
const showBody = asSection || open;
const formBody = showBody ? (0, react_jsx_runtime.jsxs)("div", {
className: asSection ? "smcp_body smcp_bodyFlat" : "smcp_body",
children: [
!state.writable ? (0, react_jsx_runtime.jsx)("p", { className: "smcp_hint", children: t("readOnly") }) : null,
(0, react_jsx_runtime.jsx)(SelectField, {
id: "smcp-default-server",
label: t("defaultServer"),
hint: t("defaultServerHint"),
disabled,
options: serverOptions,
value: value.defaultServer,
onEdit: (text) => props.editScalar("defaultServer", text)
}),
(0, react_jsx_runtime.jsx)(TextField, {
id: "smcp-max-results",
label: t("maxResults"),
hint: t("maxResultsHint"),
numeric: true,
disabled,
value: value.maxResults,
onEdit: (text) => props.editScalar("maxResults", text)
}),
(0, react_jsx_runtime.jsx)(TextField, {
id: "smcp-timeout",
label: t("searchTimeoutMs"),
hint: t("searchTimeoutMsHint"),
numeric: true,
disabled,
value: value.searchTimeoutMs === "" ? "" : String(Math.round(Number(value.searchTimeoutMs) / 1000)),
onEdit: (text) => props.editScalar("searchTimeoutMs", text === "" ? "" : String(Math.round((Number(text) || 0) * 1000)))
}),
(0, react_jsx_runtime.jsxs)("div", {
className: "smcp_field",
children: [
(0, react_jsx_runtime.jsx)("span", { className: "smcp_label", children: t("servers") }),
(0, react_jsx_runtime.jsx)("p", { className: "smcp_hint", children: t("serversHint") }),
(0, react_jsx_runtime.jsx)("p", { className: "smcp_hint", children: t("quickAddHint") }),
(0, react_jsx_runtime.jsxs)("div", {
className: "smcp_quickAdd",
children: QUICK_KINDS.map((kind) => (0, react_jsx_runtime.jsx)(
"button",
{
type: "button",
className: "smcp_quickBtn",
disabled,
onClick: () => props.quickAdd(kind),
children: `+ ${KIND_LABELS[kind]}`
},
`smcp-quick-${kind}`
))
}),
value.servers.map((row, index) => (0, react_jsx_runtime.jsx)(
ServerRow,
{
t,
row,
index,
disabled,
dupIds,
credentialStates: state.credentialStates,
configuredRefs: state.configuredRefs,
onEdit: (field, val) => props.editRow(index, field, val),
onKind: (kind) => props.changeKind(index, kind),
onRemove: () => props.removeServer(index)
},
`smcp-row-${index}`
)),
(0, react_jsx_runtime.jsx)("button", {
type: "button",
className: "smcp_add",
disabled,
onClick: props.addServer,
children: `+ ${t("addServer")}`
})
]
}),
state.legacyBlocked ? (0, react_jsx_runtime.jsx)("p", { role: "status", className: "smcp_error", children: t("legacyKeyBlocked") }) : null,
(0, react_jsx_runtime.jsxs)("div", {
className: "smcp_footer",
children: [
state.failed ? (0, react_jsx_runtime.jsx)("p", { role: "status", className: "smcp_failDetail", children: state.failDetail ? (t("saveFailed") + " — " + state.failDetail) : t("saveFailed") }) : null,
(0, react_jsx_runtime.jsx)("button", {
type: "button",
className: "smcp_btn smcp_btnGhost",
disabled: !state.dirty || state.saving,
onClick: props.discard,
children: t("discard")
}),
(0, react_jsx_runtime.jsx)("button", {
type: "button",
className: "smcp_btn smcp_btnPrimary",
disabled: !state.dirty || state.invalid || state.saving,
onClick: props.save,
children: t(state.saving ? "saving" : "save")
})
]
})
]
}) : null;
if (asSection) {
return (0, react_jsx_runtime.jsxs)("div", {
className: "smcp_section",
children: [
(0, react_jsx_runtime.jsx)("p", { className: "smcp_lede", children: t("description") }),
state.overridden ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badge", children: t("overridden") }) : null,
state.dirty ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeMuted", children: t("unsaved") }) : null,
formBody
]
});
}
return (0, react_jsx_runtime.jsxs)("li", {
className: "smcp_card" + (open ? " smcp_cardOpen" : ""),
children: [
(0, react_jsx_runtime.jsxs)("button", {
type: "button",
className: "smcp_headBtn",
"aria-expanded": open,
"aria-label": `${open ? "收起" : "展开"}: ${title}`,
onClick: () => { setOpen(!open); },
children: [
(0, react_jsx_runtime.jsxs)("span", {
className: "smcp_head",
children: [
(0, react_jsx_runtime.jsx)("span", { className: "smcp_name", children: title }),
(0, react_jsx_runtime.jsx)("span", { className: "smcp_desc", children: t("description") }),
state.overridden ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badge", children: t("overridden") }) : null,
state.dirty ? (0, react_jsx_runtime.jsx)("span", { className: "smcp_badgeMuted", children: t("unsaved") }) : null
]
}),
jsx(IconChevronDownOutline14, {
className: "smcp_chevron" + (open ? " smcp_chevronOpen" : "")
})
]
}),
formBody
]
});
}
function ServerRow(props) {
const { t, row, index, disabled, onEdit, onKind, onRemove } = props;
const [rowOpen, setRowOpen] = (0, react.useState)(false);
const rowInvalid = row.id.trim() === "";
const dup = !rowInvalid && row.id.trim() !== "" && props.dupIds && props.dupIds.has(row.id.trim());
const known = row.kind !== "custom";
const needsKey = (CATALOG[row.kind] ?? CATALOG.custom).needsKey;
const hasKey = row.apiKey.trim() !== "";
const hasKeyEnv = row.apiKeyEnv.trim() !== "";
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")}: ${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")}: ${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"));
return (0, react_jsx_runtime.jsxs)("div", {
className: "smcp_row",
children: [
(0, react_jsx_runtime.jsxs)("div", {
className: "smcp_rowHead",
children: [
(0, react_jsx_runtime.jsx)("button", {
type: "button",
className: "smcp_rowToggle" + (rowOpen ? " smcp_rowToggleOpen" : ""),
"aria-expanded": rowOpen,
"aria-label": `${rowOpen ? "收起" : "展开"} ${row.id || t("rowId")}`,
disabled,
onClick: () => setRowOpen(!rowOpen),
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") }),
(0, react_jsx_runtime.jsx)("span", { className: "smcp_rowSummary", children: summary }),
...badges,
(0, react_jsx_runtime.jsx)("button", { type: "button", className: "smcp_del", disabled, onClick: onRemove, children: t("removeServer") })
]
}),
rowInvalid ? (0, react_jsx_runtime.jsx)("p", { className: "smcp_error", children: t("missingId") }) : null,
dup ? (0, react_jsx_runtime.jsx)("p", { className: "smcp_error", children: t("dupId") }) : null,
rowOpen ? (0, react_jsx_runtime.jsxs)("div", {
className: "smcp_rowBody",
children: known ? [
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowId"), placeholder: t("rowIdHint"), value: row.id, disabled, onEdit: (v) => onEdit("id", v) }),
(0, react_jsx_runtime.jsxs)("div", { className: "smcp_rowGrid3", children: [
(0, react_jsx_runtime.jsx)(CellSelect, { label: t("rowKind"), options: KIND_OPTIONS, value: row.kind, disabled, onEdit: onKind }),
needsKey ? (0, react_jsx_runtime.jsx)(CellInput, { label: t("cdKey"), type: "password", placeholder: t("cdKeyHint"), value: row.apiKey, disabled, onEdit: (v) => onEdit("apiKey", v) }) : null,
needsKey ? (0, react_jsx_runtime.jsx)(CellInput, { label: t("rowApiKeyEnv"), placeholder: t("rowApiKeyEnv"), value: row.apiKeyEnv, disabled, onEdit: (v) => onEdit("apiKeyEnv", v) }) : null
] }),
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowMaxResults"), placeholder: t("rowMaxResultsHint"), value: row.maxResults, disabled, onEdit: (v) => onEdit("maxResults", v) })
] : [
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowId"), placeholder: t("rowIdHint"), value: row.id, disabled, onEdit: (v) => onEdit("id", v) }),
(0, react_jsx_runtime.jsxs)("div", { className: "smcp_rowGrid3", children: [
(0, react_jsx_runtime.jsx)(CellSelect, { label: t("rowKind"), options: KIND_OPTIONS, value: row.kind, disabled, onEdit: onKind }),
(0, react_jsx_runtime.jsx)(CellSelect, { label: t("rowTransport"), options: TRANSPORT_OPTIONS, value: row.transport, disabled, onEdit: (v) => onEdit("transport", v) }),
(0, react_jsx_runtime.jsx)(CellSelect, { label: t("rowAuthStyle"), options: AUTH_STYLE_OPTIONS, value: row.authStyle, disabled, onEdit: (v) => onEdit("authStyle", v) })
] }),
(0, react_jsx_runtime.jsxs)("div", { className: "smcp_rowGrid", children: [
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowUrl"), placeholder: t("placeholderUrl"), value: row.url, disabled, onEdit: (v) => onEdit("url", v) }),
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowApiKey"), type: "password", placeholder: t("cdKeyHint"), value: row.apiKey, disabled, onEdit: (v) => onEdit("apiKey", v) })
] }),
(0, react_jsx_runtime.jsxs)("div", { className: "smcp_rowGrid", children: [
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowApiKeyEnv"), placeholder: t("rowApiKeyEnv"), value: row.apiKeyEnv, disabled, onEdit: (v) => onEdit("apiKeyEnv", v) }),
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowAuthParam"), placeholder: t("placeholderAuthParam"), value: row.authParam, disabled, onEdit: (v) => onEdit("authParam", v) })
] }),
(0, react_jsx_runtime.jsxs)("div", { className: "smcp_rowGrid", children: [
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowCommand"), placeholder: t("placeholderCommand"), value: row.command, disabled, onEdit: (v) => onEdit("command", v) }),
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowArgs"), placeholder: t("placeholderArgs"), value: row.args, disabled, onEdit: (v) => onEdit("args", v) })
] }),
(0, react_jsx_runtime.jsxs)("div", { className: "smcp_rowGrid", children: [
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowToolName"), placeholder: t("placeholderToolName"), value: row.toolName, disabled, onEdit: (v) => onEdit("toolName", v) }),
(0, react_jsx_runtime.jsx)(CellInput, { label: t("rowMaxResults"), placeholder: t("rowMaxResultsHint"), value: row.maxResults, disabled, onEdit: (v) => onEdit("maxResults", v) })
] })
]
}) : null
]
});
}
function CellInput(props) {
return (0, react_jsx_runtime.jsxs)("label", {
className: "smcp_cell",
children: [
(0, react_jsx_runtime.jsx)("span", { className: "smcp_cellLabel", children: props.label }),
(0, react_jsx_runtime.jsx)("input", {
className: "smcp_input",
type: props.type ?? "text",
placeholder: props.placeholder,
value: props.value,
disabled: props.disabled,
onChange: (e) => props.onEdit(e.target.value)
})
]
});
}
function CellSelect(props) {
return (0, react_jsx_runtime.jsxs)("label", {
className: "smcp_cell",
children: [
(0, react_jsx_runtime.jsx)("span", { className: "smcp_cellLabel", children: props.label }),
(0, react_jsx_runtime.jsx)("select", {
className: "smcp_select",
value: props.value,
disabled: props.disabled,
onChange: (e) => props.onEdit(e.target.value),
children: props.options.map((option) => (0, react_jsx_runtime.jsx)("option", { value: option, children: option === "" ? "—" : option }, option))
})
]
});
}
function TextField(props) {
return (0, react_jsx_runtime.jsxs)("div", {
className: "smcp_field",
children: [
(0, react_jsx_runtime.jsx)("label", { className: "smcp_label", htmlFor: props.id, children: props.label }),
(0, react_jsx_runtime.jsx)("input", {
id: props.id,
className: "smcp_input",
type: "text",
inputMode: props.numeric ? "numeric" : undefined,
value: props.value,
disabled: props.disabled,
onChange: (e) => props.onEdit(e.target.value)
}),
(0, react_jsx_runtime.jsx)("p", { className: "smcp_hint", children: props.hint })
]
});
}
function SelectField(props) {
const options = props.options.map((option) => typeof option === "string" ? { value: option, label: option } : option);
return (0, react_jsx_runtime.jsxs)("div", {
className: "smcp_field",
children: [
(0, react_jsx_runtime.jsx)("label", { className: "smcp_label", htmlFor: props.id, children: props.label }),
(0, react_jsx_runtime.jsx)("select", {
id: props.id,
className: "smcp_select",
value: props.value,
disabled: props.disabled,
onChange: (e) => props.onEdit(e.target.value),
children: options.map((option) => (0, react_jsx_runtime.jsx)("option", { value: option.value, children: option.label }, option.value))
}),
(0, react_jsx_runtime.jsx)("p", { className: "smcp_hint", children: props.hint })
]
});
}
//#endregion
//#region apply
/**
* 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: () => {} };
}
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: "bailian",
maxResults: 8,
searchTimeoutMs: 30000,
servers: [
{
id: "bailian",
kind: "bailian",
apiKeyEnv: "DASHSCOPE_API_KEY",
},
],
};
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);
return () => { listeners.delete(listener); };
},
set: async (field, next) => {
value = { ...value, [field]: next };
revision += 1;
notify();
return true;
},
unset: async (field) => {
const next = { ...value };
delete next[field];
value = next;
revision += 1;
notify();
return true;
},
};
}
function createDeferredScope(fallback) {
let inner = fallback;
const outerListeners = new Set();
let innerOff;
const relay = () => { for (const listener of outerListeners) listener(); };
const bindInner = () => {
innerOff?.();
innerOff = inner.subscribe(relay);
};
bindInner();
return {
get __memory() { return inner.__memory === true; },
getSnapshot: () => inner.getSnapshot(),
subscribe: (listener) => {
outerListeners.add(listener);
return () => { outerListeners.delete(listener); };
},
set: (field, next) => inner.set(field, next),
unset: (field) => inner.unset(field),
attach: (real) => {
if (inner === real) return;
inner = real;
bindInner();
relay();
},
};
}
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 out = [];
for (const item of raw) {
if (typeof item !== "string" || !item.trim()) continue;
const id = item.replace(/^:/, "").trim();
if (!id || out.includes(id)) continue;
out.push(id);
}
if (!out.includes(NS)) out.push(NS);
return out;
}
function apply(ctx) {
try {
ctx.effect(() => {
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: locales");
} catch { /* optional */ }
const t = (() => {
try { return ctx.locale.bind(LOCALE_NS); } catch { return (key) => key; }
})();
const sectionLabel = () => {
try {
const text = t("title");
if (typeof text === "string" && text.trim() && text !== "title") return text;
} catch { /* fall through */ }
return "搜索 MCP";
};
const deferred = createDeferredScope(createMemoryScope(MEMORY_DEFAULTS));
let controller = null;
const remoteParts = { credentials: null, settings: null, $on: null };
let credInjectStarted = false;
let settingsInjectStarted = false;
/** 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 {
ctx.inject(deps, (inner) => {
try { run(inner); }
catch (error) {
inner.logger?.warn?.("dsh-search-mcp: %s handler failed: %s", label, error);
}
});
} catch (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 () => {};
}
});
} catch (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;
return module.exports;
}
});