mirror of
https://github.com/hansjone/dsh-search-mcp.git
synced 2026-10-12 00:30:46 +08:00
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>
1766 lines
72 KiB
JavaScript
1766 lines
72 KiB
JavaScript
/*
|
||
* 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;
|
||
}
|
||
});
|