dsh-armory 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +7 -1
- package/README.md +9 -2
- package/lib/client.js +556 -31
- package/lib/index.js +7 -2
- package/lib/mcp-oauth.js +1 -1
- package/lib/typert.js +2 -0
- package/lib/wb-awesome-market.js +304 -36
- package/lib/whats-new.js +17 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -277,7 +277,7 @@ const EN_US_MESSAGES = {
|
|
|
277
277
|
"目录缓存已过期(更新源暂时不可达,显示的是本地缓存)。": "The cached catalog is stale (update sources unreachable; showing the local cache).",
|
|
278
278
|
"DSH 社区目录有新版本(本地缓存 {0})。点「重新加载」可拉取最新目录。": "The DSH community catalog has a newer version (local cache {0}). Click \"Reload\" to fetch the latest catalog.",
|
|
279
279
|
"本地还没有 DSH 社区目录缓存,点「重新加载」可首次拉取目录。": "No local DSH community catalog cache yet. Click \"Reload\" to fetch it for the first time.",
|
|
280
|
-
|
|
280
|
+
|
|
281
281
|
"正在加载 DSH 社区目录…": "Loading the DSH community catalog…",
|
|
282
282
|
"DSH 社区目录暂时是空的": "The DSH community catalog is temporarily empty",
|
|
283
283
|
"目录来自 awesome-dsh-plugins 社区清单(jsdelivr CDN 分发),拉取失败可重试。": "The catalog comes from the awesome-dsh-plugins community registry (jsdelivr CDN); retry if fetching failed.",
|
|
@@ -550,8 +550,6 @@ const EN_US_MESSAGES = {
|
|
|
550
550
|
"正在召唤 {0}…": "Summoning {0}…",
|
|
551
551
|
"正在安装 {0}…": "Installing {0}…",
|
|
552
552
|
"正在扫描本机客户端配置…": "Scanning local client configs…",
|
|
553
|
-
"正在拉取人设与技能清单…": "Fetching persona and skill list…",
|
|
554
|
-
"正在拉取详情…": "Fetching details…",
|
|
555
553
|
"正在挂载专家…": "Attaching expert…",
|
|
556
554
|
"正在读取 MCP 目录…": "Reading MCP catalog…",
|
|
557
555
|
"正在读取…": "Reading…",
|
|
@@ -888,6 +886,15 @@ const EN_US_MESSAGES = {
|
|
|
888
886
|
"复制 SKILL.md 路径": "Copy SKILL.md path",
|
|
889
887
|
"复制 @会话引用": "Copy @session reference",
|
|
890
888
|
|
|
889
|
+
// DSH 社区插件 README 预览弹层(点卡片打开)。
|
|
890
|
+
"查看 README 介绍与插图": "View README intro & images",
|
|
891
|
+
"{0} README 预览": "{0} · README preview",
|
|
892
|
+
"正在读取 README…": "Loading README…",
|
|
893
|
+
"README 过长,已截断显示。": "README is long; showing a truncated version.",
|
|
894
|
+
"重新读取": "Reload",
|
|
895
|
+
"读取 README 失败:未知错误": "Failed to read README: unknown error",
|
|
896
|
+
"预览服务尚未加载,请完全退出 DSH 后重新打开再试": "The preview service is not loaded yet. Fully quit DSH, reopen it, then try again.",
|
|
897
|
+
|
|
891
898
|
":": ": ",
|
|
892
899
|
};
|
|
893
900
|
// OPT-14 · 1.1:仅当判定为英文 locale 时装载 en-US 表;zh-CN(源语言)保持空字典
|
|
@@ -969,6 +976,18 @@ function writeAwesomeSnapshot(payload) {
|
|
|
969
976
|
} catch { /* 配额溢出 / 隐私模式:快照只是加速,失败不致命 */ }
|
|
970
977
|
}
|
|
971
978
|
|
|
979
|
+
// ── 更新探测节流:把「每次进面板都 HEAD 一发」压到 6 小时一次 ────────────────
|
|
980
|
+
// 目录本身已有服务端 stale-while-revalidate 在后台自动换新,这个 ETag 探测只是
|
|
981
|
+
// 锦上添花;没必要每开一次面板就多打一次网络、多一次误报机会。
|
|
982
|
+
const AWESOME_PROBE_AT_KEY = KEY_PREFIX + '.awesomeProbeAt.v1';
|
|
983
|
+
const AWESOME_PROBE_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
984
|
+
function readAwesomeProbeAt() {
|
|
985
|
+
try { return Number(localStorage.getItem(AWESOME_PROBE_AT_KEY)) || 0; } catch { return 0; }
|
|
986
|
+
}
|
|
987
|
+
function writeAwesomeProbeAt(value) {
|
|
988
|
+
try { localStorage.setItem(AWESOME_PROBE_AT_KEY, String(value)); } catch { /* 隐私模式:退化为不节流 */ }
|
|
989
|
+
}
|
|
990
|
+
|
|
972
991
|
// ── 安装轻提示:挂在 document.body 上的常驻浮层 ─────────────────────────────
|
|
973
992
|
// 安装进度此前只存在组件本地 state(busy / busyLabel):用户一切到别的面板或窗口,
|
|
974
993
|
// 组件卸载 → 进度消失 → 看起来像「安装被取消了」。这个浮层直接挂 document.body,
|
|
@@ -1083,6 +1102,420 @@ function isInstallRunning(name) {
|
|
|
1083
1102
|
return Boolean(task && task.phase === 'running');
|
|
1084
1103
|
}
|
|
1085
1104
|
|
|
1105
|
+
// ── README 预览:极简 markdown 渲染 + 插图还原 ────────────────────────────────
|
|
1106
|
+
//
|
|
1107
|
+
// 为什么自己写:本文件没有 react-dom,也没有任何 markdown 依赖;README 又是公网
|
|
1108
|
+
// 数据(不可信)。于是这里只做一件事——把 markdown 扫成块,逐块构造 React 元素,
|
|
1109
|
+
// 全程不拼字符串、不用 innerHTML,链接一律过 URL 白名单。
|
|
1110
|
+
//
|
|
1111
|
+
// 覆盖 README 真正会用到的那部分语法:标题 / 段落 / 行内代码 / 围栏代码 / 无序与
|
|
1112
|
+
// 有序列表 / 引用 / 分隔线 / 表格 / 粗体 / 斜体 / 删除线 / 链接 / 图片,外加裸 HTML
|
|
1113
|
+
// 里的 <img>、<a>、<br>(插件展示页几乎都用 <p align="center"><img …> 摆横幅)。
|
|
1114
|
+
|
|
1115
|
+
const README_STYLE_ID = KEY_PREFIX + '.readme-style';
|
|
1116
|
+
let readmeStyleInjected = false;
|
|
1117
|
+
|
|
1118
|
+
function ensureReadmeStyle() {
|
|
1119
|
+
if (readmeStyleInjected || typeof document === 'undefined') return;
|
|
1120
|
+
readmeStyleInjected = true;
|
|
1121
|
+
if (document.getElementById(README_STYLE_ID)) return;
|
|
1122
|
+
const style = document.createElement('style');
|
|
1123
|
+
style.id = README_STYLE_ID;
|
|
1124
|
+
style.textContent = [
|
|
1125
|
+
'.dsh-mcp-readme-modal{width:min(880px,94vw);max-width:94vw;max-height:86vh}',
|
|
1126
|
+
'.dsh-mcp-readme-meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px;font-size:12px;opacity:.75}',
|
|
1127
|
+
'.dsh-mcp-readme-body{margin-top:12px;overflow-wrap:anywhere}',
|
|
1128
|
+
'.dsh-mcp-md-h{margin:20px 0 8px;font-weight:600;line-height:1.35;letter-spacing:0}',
|
|
1129
|
+
'.dsh-mcp-md-h:first-child{margin-top:0}',
|
|
1130
|
+
'.dsh-mcp-md-p{margin:8px 0;line-height:1.72}',
|
|
1131
|
+
'.dsh-mcp-md-ul,.dsh-mcp-md-ol{margin:8px 0;padding-inline-start:22px}',
|
|
1132
|
+
'.dsh-mcp-md-li{margin:3px 0;line-height:1.7}',
|
|
1133
|
+
'.dsh-mcp-md-quote{margin:10px 0;padding:4px 12px;border-inline-start:3px solid var(--dsw-alias-border-strong,rgba(127,127,127,.4));opacity:.9}',
|
|
1134
|
+
'.dsh-mcp-md-code{padding:1px 5px;border-radius:4px;background:var(--dsw-alias-bg-subtle,rgba(127,127,127,.14));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.92em}',
|
|
1135
|
+
'.dsh-mcp-md-pre{margin:10px 0;padding:10px 12px;border-radius:8px;overflow:auto;background:var(--dsw-alias-bg-subtle,rgba(127,127,127,.12));font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.88em;line-height:1.6}',
|
|
1136
|
+
// 插图尺寸:README 里的截图常是 1600~3000px 的巨图,只限宽会把弹层撑成一根滚动条。
|
|
1137
|
+
// 这里同时限宽与限高(40vh:小窗口自动更小,一屏必能看到整张),等比缩放不裁切;
|
|
1138
|
+
// width:auto 保证小徽章不被放大糊成一片。真正的硬约束写在 img 的 inline style 上,
|
|
1139
|
+
// 这两条只是同值的样式兜底,避免「样式没注入成功就失控」。
|
|
1140
|
+
'.dsh-mcp-md-img{display:block;box-sizing:border-box;width:auto;max-width:100%;max-height:min(40vh,420px);height:auto;margin:10px 0;border-radius:8px;object-fit:contain;background:var(--dsw-alias-bg-subtle,rgba(127,127,127,.08))}',
|
|
1141
|
+
'.dsh-mcp-md-img-inline{display:inline-block;max-height:22px;max-width:min(100%,320px);width:auto;height:auto;margin:0 2px;vertical-align:middle;border-radius:4px;object-fit:contain}',
|
|
1142
|
+
// 兜底:任何来源塞进正文的 img(含裸 HTML 块里未被抽出的)都不许超出弹层宽度。
|
|
1143
|
+
'.dsh-mcp-readme-body img{max-width:100%}',
|
|
1144
|
+
'.dsh-mcp-readme-body table img{max-height:min(24vh,220px)}',
|
|
1145
|
+
'.dsh-mcp-md-table{width:100%;margin:10px 0;border-collapse:collapse;font-size:.92em}',
|
|
1146
|
+
'.dsh-mcp-md-table th,.dsh-mcp-md-table td{padding:6px 10px;border:1px solid var(--dsw-alias-border-subtle,rgba(127,127,127,.25));text-align:start;vertical-align:top}',
|
|
1147
|
+
'.dsh-mcp-md-table th{background:var(--dsw-alias-bg-subtle,rgba(127,127,127,.10));font-weight:600}',
|
|
1148
|
+
'.dsh-mcp-md-a{color:var(--dsw-alias-accent,#3b82f6);text-decoration:none}',
|
|
1149
|
+
'.dsh-mcp-md-a:hover{text-decoration:underline}',
|
|
1150
|
+
'.dsh-mcp-md-hr{margin:16px 0;border:0;border-top:1px solid var(--dsw-alias-border-subtle,rgba(127,127,127,.25))}',
|
|
1151
|
+
].join('\n');
|
|
1152
|
+
document.head.appendChild(style);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** 链接/图片地址白名单:只放行 http(s) 与 data:image,其余(javascript: 等)一律丢弃。 */
|
|
1156
|
+
function readmeSafeUrl(raw, base) {
|
|
1157
|
+
const text = String(raw == null ? '' : raw).trim().replace(/^<|>$/g, '');
|
|
1158
|
+
if (!text) return '';
|
|
1159
|
+
if (/^data:image\//i.test(text)) return text;
|
|
1160
|
+
if (/^mailto:/i.test(text)) return text;
|
|
1161
|
+
if (/^\/\//.test(text)) return 'https:' + text;
|
|
1162
|
+
if (/^https?:\/\//i.test(text)) return text;
|
|
1163
|
+
if (text.startsWith('#')) return text;
|
|
1164
|
+
if (!base) return '';
|
|
1165
|
+
const rel = text.replace(/^\.\//, '');
|
|
1166
|
+
if (rel.startsWith('/')) {
|
|
1167
|
+
// 站内绝对路径:GitHub raw 的 base 形如 .../<owner>/<repo>/HEAD/,回到仓库根。
|
|
1168
|
+
const root = /^(https:\/\/raw\.githubusercontent\.com\/[^/]+\/[^/]+\/[^/]+)\//.exec(base);
|
|
1169
|
+
return root ? root[1] + '/' + rel.slice(1) : '';
|
|
1170
|
+
}
|
|
1171
|
+
try {
|
|
1172
|
+
const resolved = new URL(rel, base);
|
|
1173
|
+
// 关键:new URL('javascript:alert(1)', base) 解析成功且协议是 javascript:,
|
|
1174
|
+
// 只靠 base 拼相对路径会把伪协议放行成可点击 href,这里必须再校验一次协议。
|
|
1175
|
+
if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') return '';
|
|
1176
|
+
return resolved.toString();
|
|
1177
|
+
} catch {
|
|
1178
|
+
return '';
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** 解析 `` / `[text](url "title")`(url 里允许成对括号)。 */
|
|
1183
|
+
function readmeSplitLink(token) {
|
|
1184
|
+
const match = /^(!?)\[([^\]\n]*)\]\(\s*([^()\s]*(?:\([^()]*\)[^()\s]*)*)(?:\s+"([^"\n]*)")?\s*\)$/.exec(String(token || ''));
|
|
1185
|
+
if (!match) return null;
|
|
1186
|
+
return { image: match[1] === '!', label: match[2], url: match[3], title: match[4] || '' };
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/* README 插图缩图代理。
|
|
1190
|
+
* 直连 GitHub 原图有两个硬伤:①README 截图常见 1600~3000px、单张几百 KB 到几 MB,
|
|
1191
|
+
* 跨国拉取常只有几十 KB/s,浏览器自上而下逐行解码,看起来就是「图只显示了一部分」;
|
|
1192
|
+
* ②原图字节多,弹层一打开就并发十几张,把带宽抢光,整个弹层都卡。
|
|
1193
|
+
* 所以 GitHub 系图片先走 wsrv.nl 缩到 880 宽(webp q72,体积通常降到 1/20),
|
|
1194
|
+
* 代理不可用时 onError 回退原图一次,再失败才隐藏——不牺牲可用性。 */
|
|
1195
|
+
const README_IMG_PROXY = 'https://wsrv.nl/?output=webp&q=72&w=880&url=';
|
|
1196
|
+
const README_IMG_PROXY_HOSTS = [
|
|
1197
|
+
'raw.githubusercontent.com',
|
|
1198
|
+
'user-images.githubusercontent.com',
|
|
1199
|
+
'private-user-images.githubusercontent.com',
|
|
1200
|
+
'objects.githubusercontent.com',
|
|
1201
|
+
'camo.githubusercontent.com',
|
|
1202
|
+
'github.com',
|
|
1203
|
+
];
|
|
1204
|
+
|
|
1205
|
+
/** 只给 GitHub 系图源生成代理地址;其他源(如 npm registry、shields.io)原样返回 ''。 */
|
|
1206
|
+
function readmeProxiedSrc(src) {
|
|
1207
|
+
try {
|
|
1208
|
+
const host = new URL(src).hostname.toLowerCase();
|
|
1209
|
+
if (README_IMG_PROXY_HOSTS.indexOf(host) < 0) return '';
|
|
1210
|
+
return README_IMG_PROXY + encodeURIComponent(src);
|
|
1211
|
+
} catch {
|
|
1212
|
+
return '';
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** `` → img;相对路径按 base 还原成绝对 URL,还原不了就跳过(不产生坏图)。
|
|
1217
|
+
* 尺寸硬约束写在 inline style 上:不依赖 <style> 是否注入成功,也不怕被别处的 img 规则盖掉。 */
|
|
1218
|
+
function readmeImageNode(src, alt, makeKey, base, opts) {
|
|
1219
|
+
const resolved = readmeSafeUrl(src, base);
|
|
1220
|
+
if (!resolved) return null;
|
|
1221
|
+
const inline = Boolean(opts && opts.inline);
|
|
1222
|
+
const proxied = inline ? '' : readmeProxiedSrc(resolved);
|
|
1223
|
+
const props = {
|
|
1224
|
+
key: makeKey('img'),
|
|
1225
|
+
className: inline ? 'dsh-mcp-md-img-inline' : 'dsh-mcp-md-img',
|
|
1226
|
+
src: proxied || resolved,
|
|
1227
|
+
alt: String(alt || ''),
|
|
1228
|
+
loading: 'lazy',
|
|
1229
|
+
decoding: 'async',
|
|
1230
|
+
referrerPolicy: 'no-referrer',
|
|
1231
|
+
style: inline
|
|
1232
|
+
// 行内小图标:压到行高附近,最多占 320px 宽,不把整段文字顶开。
|
|
1233
|
+
? { display: 'inline-block', width: 'auto', height: 'auto', maxWidth: 'min(100%,320px)', maxHeight: '22px', objectFit: 'contain', verticalAlign: 'middle', margin: '0 2px' }
|
|
1234
|
+
// 块级插图:同时限宽限高,等比缩放不裁切;40vh 保证小窗口也能整张看到。
|
|
1235
|
+
: { display: 'block', boxSizing: 'border-box', width: 'auto', height: 'auto', maxWidth: '100%', maxHeight: 'min(40vh,420px)', objectFit: 'contain', margin: '10px 0', borderRadius: '8px', background: 'var(--dsw-alias-bg-subtle, rgba(127,127,127,.06))' },
|
|
1236
|
+
onError: (event) => {
|
|
1237
|
+
const node = event && event.currentTarget;
|
|
1238
|
+
if (!node) return;
|
|
1239
|
+
// 代理挂了退回原图一次;原图也挂了才隐藏(徽章 404 很常见,别留一排破图标)。
|
|
1240
|
+
const origin = proxied ? node.getAttribute('data-readme-origin') : '';
|
|
1241
|
+
if (origin) {
|
|
1242
|
+
node.setAttribute('src', origin);
|
|
1243
|
+
node.removeAttribute('data-readme-origin');
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
if (node.style) node.style.display = 'none';
|
|
1247
|
+
},
|
|
1248
|
+
};
|
|
1249
|
+
if (proxied) props['data-readme-origin'] = resolved;
|
|
1250
|
+
return h('img', props);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const README_INLINE_RE = /(`[^`\n]+`)|(!\[[^\]\n]*\]\([^()\s]*(?:\([^()]*\)[^()\s]*)*(?:\s+"[^"\n]*")?\s*\))|(\[[^\]\n]+\]\([^()\s]*(?:\([^()]*\)[^()\s]*)*(?:\s+"[^"\n]*")?\s*\))|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\*[^*\n]+\*)|(_[^_\n]+_)|(<https?:\/\/[^>\s]+>)|(<img\b[^>]*>)|(<br\s*\/?>)/i;
|
|
1254
|
+
|
|
1255
|
+
/** 行内语法 → React 节点数组。纯扫描,不做任何 HTML 解析。 */
|
|
1256
|
+
function renderReadmeInline(text, makeKey, base) {
|
|
1257
|
+
const out = [];
|
|
1258
|
+
const source = String(text || '');
|
|
1259
|
+
// 每次调用都用独立的正则实例:renderReadmeInline 会递归(粗体/斜体/链接文字),
|
|
1260
|
+
// 共享 /g 实例的 lastIndex 会被递归内层归零,外层游标因此回退到起点 → 死循环(曾整页 OOM)。
|
|
1261
|
+
const re = new RegExp(README_INLINE_RE.source, 'gi');
|
|
1262
|
+
let cursor = 0;
|
|
1263
|
+
let match = re.exec(source);
|
|
1264
|
+
const pushText = (value) => {
|
|
1265
|
+
if (!value) return;
|
|
1266
|
+
out.push(value);
|
|
1267
|
+
};
|
|
1268
|
+
while (match) {
|
|
1269
|
+
pushText(source.slice(cursor, match.index));
|
|
1270
|
+
const token = match[0];
|
|
1271
|
+
if (token.startsWith('`')) {
|
|
1272
|
+
out.push(h('code', { key: makeKey('code'), className: 'dsh-mcp-md-code' }, token.slice(1, -1)));
|
|
1273
|
+
} else if (token.startsWith('["']/i.exec(token);
|
|
1303
|
+
const altMatch = /\balt\s*=\s*["']([^"']*)["']/i.exec(token);
|
|
1304
|
+
const node = srcMatch ? readmeImageNode(srcMatch[1], altMatch ? altMatch[1] : '', makeKey, base, { inline: true }) : null;
|
|
1305
|
+
if (node) out.push(node);
|
|
1306
|
+
} else if (/^<br/i.test(token)) {
|
|
1307
|
+
out.push(h('br', { key: makeKey('br') }));
|
|
1308
|
+
}
|
|
1309
|
+
cursor = match.index + token.length;
|
|
1310
|
+
if (!token) break;
|
|
1311
|
+
match = re.exec(source);
|
|
1312
|
+
}
|
|
1313
|
+
pushText(source.slice(cursor));
|
|
1314
|
+
return out;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/** 从一段裸 HTML 里薅出 <img src>:README 的横幅几乎都藏在这种块里。 */
|
|
1318
|
+
function readmeHtmlImages(text) {
|
|
1319
|
+
const out = [];
|
|
1320
|
+
const re = /<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
1321
|
+
let match = re.exec(String(text || ''));
|
|
1322
|
+
while (match) {
|
|
1323
|
+
out.push(match[1]);
|
|
1324
|
+
match = re.exec(String(text || ''));
|
|
1325
|
+
}
|
|
1326
|
+
return out;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** 裸 HTML 块 → 文本(去标签、留文字),用于既没图也没链接的说明性 HTML。 */
|
|
1330
|
+
function readmeHtmlText(text) {
|
|
1331
|
+
return String(text || '')
|
|
1332
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
1333
|
+
.replace(/<\/(p|div|tr|li|h[1-6])>/gi, '\n')
|
|
1334
|
+
.replace(/<[^>]+>/g, '')
|
|
1335
|
+
.replace(/ /gi, ' ')
|
|
1336
|
+
.replace(/&/gi, '&')
|
|
1337
|
+
.replace(/</gi, '<')
|
|
1338
|
+
.replace(/>/gi, '>')
|
|
1339
|
+
.replace(/"/gi, '"')
|
|
1340
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
1341
|
+
.trim();
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* markdown → React 节点数组。`base` 用来还原相对图片/链接。
|
|
1346
|
+
* 解析器只认行首块级语法,行内交给 renderReadmeInline;认不出的行按段落处理,
|
|
1347
|
+
* 所以畸形 README 最差也只是排版朴素,不会崩、不会吞内容。
|
|
1348
|
+
*/
|
|
1349
|
+
function renderReadmeBlocks(markdown, base) {
|
|
1350
|
+
const lines = String(markdown || '').replace(/\r\n?/g, '\n').split('\n');
|
|
1351
|
+
const blocks = [];
|
|
1352
|
+
let seq = 0;
|
|
1353
|
+
const makeKey = (tag) => `md-${tag}-${seq++}`;
|
|
1354
|
+
let index = 0;
|
|
1355
|
+
let paragraph = [];
|
|
1356
|
+
|
|
1357
|
+
const flushParagraph = () => {
|
|
1358
|
+
if (!paragraph.length) return;
|
|
1359
|
+
const text = paragraph.join(' ').replace(/\s+/g, ' ').trim();
|
|
1360
|
+
paragraph = [];
|
|
1361
|
+
if (text) blocks.push(h('p', { key: makeKey('p'), className: 'dsh-mcp-md-p' }, renderReadmeInline(text, makeKey, base)));
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
while (index < lines.length) {
|
|
1365
|
+
const line = lines[index];
|
|
1366
|
+
|
|
1367
|
+
// 围栏代码块:整段原样保留,不再做行内解析。
|
|
1368
|
+
const fence = /^\s*(```+|~~~+)\s*([\w+#.-]*)\s*$/.exec(line);
|
|
1369
|
+
if (fence) {
|
|
1370
|
+
flushParagraph();
|
|
1371
|
+
const marker = fence[1][0];
|
|
1372
|
+
const body = [];
|
|
1373
|
+
index += 1;
|
|
1374
|
+
while (index < lines.length && !new RegExp('^\\s*' + marker + '{3,}\\s*$').test(lines[index])) {
|
|
1375
|
+
body.push(lines[index]);
|
|
1376
|
+
index += 1;
|
|
1377
|
+
}
|
|
1378
|
+
index += 1;
|
|
1379
|
+
blocks.push(h('pre', { key: makeKey('pre'), className: 'dsh-mcp-md-pre' }, h('code', null, body.join('\n'))));
|
|
1380
|
+
continue;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
if (!line.trim()) {
|
|
1384
|
+
flushParagraph();
|
|
1385
|
+
index += 1;
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// 分隔线(--- / *** / ___,允许 3 个以上)
|
|
1390
|
+
if (/^\s*([-*_])\s*(?:\1\s*){2,}$/.test(line)) {
|
|
1391
|
+
flushParagraph();
|
|
1392
|
+
blocks.push(h('hr', { key: makeKey('hr'), className: 'dsh-mcp-md-hr' }));
|
|
1393
|
+
index += 1;
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// 标题:h1~h6 统一渲染成带 aria-level 的 div,别和弹窗自己的 h3 打架。
|
|
1398
|
+
const heading = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
1399
|
+
if (heading) {
|
|
1400
|
+
flushParagraph();
|
|
1401
|
+
const level = heading[1].length;
|
|
1402
|
+
blocks.push(
|
|
1403
|
+
h(
|
|
1404
|
+
'div',
|
|
1405
|
+
{
|
|
1406
|
+
key: makeKey('h'),
|
|
1407
|
+
className: 'dsh-mcp-md-h',
|
|
1408
|
+
role: 'heading',
|
|
1409
|
+
'aria-level': String(Math.min(6, level + 2)),
|
|
1410
|
+
style: { fontSize: level <= 1 ? '18px' : level === 2 ? '16px' : level === 3 ? '15px' : '14px' },
|
|
1411
|
+
},
|
|
1412
|
+
renderReadmeInline(heading[2], makeKey, base),
|
|
1413
|
+
),
|
|
1414
|
+
);
|
|
1415
|
+
index += 1;
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// 表格:当前行含 | 且下一行是 |---|---| 分隔行
|
|
1420
|
+
if (line.includes('|') && index + 1 < lines.length && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[index + 1]) && lines[index + 1].includes('-')) {
|
|
1421
|
+
flushParagraph();
|
|
1422
|
+
const splitRow = (row) =>
|
|
1423
|
+
String(row)
|
|
1424
|
+
.replace(/^\s*\|/, '')
|
|
1425
|
+
.replace(/\|\s*$/, '')
|
|
1426
|
+
.split('|')
|
|
1427
|
+
.map((cell) => cell.trim());
|
|
1428
|
+
const header = splitRow(line);
|
|
1429
|
+
index += 2;
|
|
1430
|
+
const rows = [];
|
|
1431
|
+
while (index < lines.length && lines[index].includes('|') && lines[index].trim()) {
|
|
1432
|
+
rows.push(splitRow(lines[index]));
|
|
1433
|
+
index += 1;
|
|
1434
|
+
}
|
|
1435
|
+
blocks.push(
|
|
1436
|
+
h(
|
|
1437
|
+
'table',
|
|
1438
|
+
{ key: makeKey('table'), className: 'dsh-mcp-md-table' },
|
|
1439
|
+
h('thead', null, h('tr', null, header.map((cell, cellIndex) => h('th', { key: cellIndex }, renderReadmeInline(cell, makeKey, base))))),
|
|
1440
|
+
h(
|
|
1441
|
+
'tbody',
|
|
1442
|
+
null,
|
|
1443
|
+
rows.map((row, rowIndex) =>
|
|
1444
|
+
h('tr', { key: rowIndex }, header.map((_, cellIndex) => h('td', { key: cellIndex }, renderReadmeInline(row[cellIndex] || '', makeKey, base)))),
|
|
1445
|
+
),
|
|
1446
|
+
),
|
|
1447
|
+
),
|
|
1448
|
+
);
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// 引用:连续 > 行合成一段
|
|
1453
|
+
if (/^\s*>/.test(line)) {
|
|
1454
|
+
flushParagraph();
|
|
1455
|
+
const quote = [];
|
|
1456
|
+
while (index < lines.length && /^\s*>/.test(lines[index])) {
|
|
1457
|
+
quote.push(lines[index].replace(/^\s*>\s?/, ''));
|
|
1458
|
+
index += 1;
|
|
1459
|
+
}
|
|
1460
|
+
blocks.push(h('blockquote', { key: makeKey('quote'), className: 'dsh-mcp-md-quote' }, h('p', { className: 'dsh-mcp-md-p' }, renderReadmeInline(quote.join(' '), makeKey, base))));
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// 裸 HTML 块:先薅图片,再留文字。
|
|
1465
|
+
if (/^\s*</.test(line) && !/^\s*<https?:/i.test(line)) {
|
|
1466
|
+
flushParagraph();
|
|
1467
|
+
const chunk = [];
|
|
1468
|
+
while (index < lines.length && /^\s*</.test(lines[index]) && !/^\s*<https?:/i.test(lines[index])) {
|
|
1469
|
+
chunk.push(lines[index]);
|
|
1470
|
+
index += 1;
|
|
1471
|
+
}
|
|
1472
|
+
const rawHtml = chunk.join('\n');
|
|
1473
|
+
const srcs = readmeHtmlImages(rawHtml);
|
|
1474
|
+
const nodes = [];
|
|
1475
|
+
srcs.forEach((src) => {
|
|
1476
|
+
const node = readmeImageNode(src, '', makeKey, base, { inline: false });
|
|
1477
|
+
if (node) nodes.push(node);
|
|
1478
|
+
});
|
|
1479
|
+
const leftover = readmeHtmlText(rawHtml);
|
|
1480
|
+
if (leftover) {
|
|
1481
|
+
nodes.push(h('p', { key: makeKey('htmltext'), className: 'dsh-mcp-md-p' }, renderReadmeInline(leftover.replace(/\n+/g, ' '), makeKey, base)));
|
|
1482
|
+
}
|
|
1483
|
+
if (nodes.length) blocks.push(h('div', { key: makeKey('html'), className: 'dsh-mcp-md-htmlblock' }, nodes));
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// 列表项:按缩进算层级,深度用 padding 表达(README 的嵌套一般不超过 3 层)
|
|
1488
|
+
const listItem = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
1489
|
+
if (listItem) {
|
|
1490
|
+
flushParagraph();
|
|
1491
|
+
const ordered = /\d/.test(listItem[2]);
|
|
1492
|
+
const items = [];
|
|
1493
|
+
while (index < lines.length) {
|
|
1494
|
+
const current = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[index]);
|
|
1495
|
+
if (!current) break;
|
|
1496
|
+
const depth = Math.min(3, Math.floor(current[1].replace(/\t/g, ' ').length / 2));
|
|
1497
|
+
items.push({ depth, text: current[3] });
|
|
1498
|
+
index += 1;
|
|
1499
|
+
}
|
|
1500
|
+
blocks.push(
|
|
1501
|
+
h(
|
|
1502
|
+
ordered ? 'ol' : 'ul',
|
|
1503
|
+
{ key: makeKey('list'), className: ordered ? 'dsh-mcp-md-ol' : 'dsh-mcp-md-ul' },
|
|
1504
|
+
items.map((item, itemIndex) =>
|
|
1505
|
+
h('li', { key: itemIndex, className: 'dsh-mcp-md-li', style: item.depth ? { marginInlineStart: item.depth * 16 + 'px' } : undefined }, renderReadmeInline(item.text, makeKey, base)),
|
|
1506
|
+
),
|
|
1507
|
+
),
|
|
1508
|
+
);
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
paragraph.push(line.trim());
|
|
1513
|
+
index += 1;
|
|
1514
|
+
}
|
|
1515
|
+
flushParagraph();
|
|
1516
|
+
return blocks;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1086
1519
|
// ── 样式表 MCP_CSS:主题令牌别名 + 全部面板规则(一次性注入 <style>)──────────────
|
|
1087
1520
|
/* 主题令牌别名层。DSH 的 ui-theme 拥有全部颜色、几何与字体:插件只消费语义令牌,
|
|
1088
1521
|
不写死色值、不写局部圆角数值(docs/web-styling.md:「Use --dsw-alias-* semantic
|
|
@@ -5325,6 +5758,10 @@ const ExpertInstalledBoard = ({ call }) => {
|
|
|
5325
5758
|
const [presets, setPresets] = useState([]);
|
|
5326
5759
|
const [skills, setSkills] = useState([]);
|
|
5327
5760
|
const [detail, setDetail] = useState(null);
|
|
5761
|
+
// 正在拉详情的那张卡(plugin 名):加载期间只把这一个卡片的「详情」按钮标成「读取中…」,
|
|
5762
|
+
// 弹层等数据到手才挂载——中途先 setDetail 一个只有标题的矮盒子会「先闪小窗、再跳大窗」
|
|
5763
|
+
// (.dsh-mcp-modal 高度随内容自适应)。与市场页 ExpertMarket 同一套做法。
|
|
5764
|
+
const [openingPlugin, setOpeningPlugin] = useState('');
|
|
5328
5765
|
// §4.1 在场:expertActive 是「谁真的挂在会话上」的唯一真源。没有它,「已召唤」
|
|
5329
5766
|
// (预设已落盘,新会话才接管)和「在场」(人设真的在这轮对话里生效)就是同一个绿 tag,
|
|
5330
5767
|
// 而这恰恰是这一页最该说清楚的区别。
|
|
@@ -5412,14 +5849,19 @@ const ExpertInstalledBoard = ({ call }) => {
|
|
|
5412
5849
|
}
|
|
5413
5850
|
};
|
|
5414
5851
|
const openDetail = async (plugin) => {
|
|
5415
|
-
|
|
5852
|
+
// 弹层只在详情数据到手后挂载一次(理由同市场页:中途挂矮盒子会闪一下再变大)。
|
|
5853
|
+
if (openingPlugin) return; // 串行化:一条没回来就别再拉第二条。
|
|
5854
|
+
setOpeningPlugin(plugin);
|
|
5416
5855
|
setNote('');
|
|
5417
5856
|
try {
|
|
5418
5857
|
const res = await call('wbExpertDetail', { plugin });
|
|
5419
5858
|
if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
|
|
5420
|
-
setDetail({ ...res
|
|
5859
|
+
setDetail({ ...res });
|
|
5421
5860
|
} catch (e) {
|
|
5422
|
-
|
|
5861
|
+
// 失败也开弹层:里面有 detailError 红字,比「点了没反应」好排查。
|
|
5862
|
+
setDetail({ plugin, detailError: String(e?.message || e) });
|
|
5863
|
+
} finally {
|
|
5864
|
+
setOpeningPlugin('');
|
|
5423
5865
|
}
|
|
5424
5866
|
};
|
|
5425
5867
|
|
|
@@ -5518,7 +5960,7 @@ const ExpertInstalledBoard = ({ call }) => {
|
|
|
5518
5960
|
}, skill)))
|
|
5519
5961
|
: h('div', { className: 'dsh-mcp-card-summary' }, t('无捆绑技能(预设本体在 profile 补丁)')),
|
|
5520
5962
|
h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
5521
|
-
h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(row.plugin) }, t('详情')),
|
|
5963
|
+
h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: openingPlugin === row.plugin, 'aria-busy': openingPlugin === row.plugin || undefined, onClick: () => openDetail(row.plugin) }, openingPlugin === row.plugin ? t('读取中…') : t('详情')),
|
|
5522
5964
|
confirming
|
|
5523
5965
|
// 内联确认:不弹窗、不移位——原地换成「确认/取消」,误触成本从 0 提到 1 次点击。
|
|
5524
5966
|
? h(InlineConfirm, {
|
|
@@ -5565,9 +6007,7 @@ const ExpertInstalledBoard = ({ call }) => {
|
|
|
5565
6007
|
? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setDetail(null) },
|
|
5566
6008
|
h('div', { ref: summonedRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('已召唤专家详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
|
|
5567
6009
|
h('h3', null, detail.name ? t('{0}({1})', [detail.name, detail.profession || '']) : detail.plugin),
|
|
5568
|
-
|
|
5569
|
-
? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在拉取详情…'))
|
|
5570
|
-
: h('div', null,
|
|
6010
|
+
h('div', null,
|
|
5571
6011
|
detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
|
|
5572
6012
|
detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
|
|
5573
6013
|
Array.isArray(detail.skills) && detail.skills.length
|
|
@@ -5590,7 +6030,7 @@ const ExpertInstalledBoard = ({ call }) => {
|
|
|
5590
6030
|
),
|
|
5591
6031
|
h('div', { className: 'dsh-mcp-modal-actions' },
|
|
5592
6032
|
h(Btn, { onClick: () => setDetail(null), disabled: busy, children: t('关闭') }),
|
|
5593
|
-
h(Btn, { danger: true, icon: 'trash', disabled: busy
|
|
6033
|
+
h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => dismiss(detail.plugin, detail.name), children: t('送还') }),
|
|
5594
6034
|
),
|
|
5595
6035
|
),
|
|
5596
6036
|
)
|
|
@@ -5620,6 +6060,19 @@ const ExpertMarket = ({ call }) => {
|
|
|
5620
6060
|
// OPT-21:市场专家详情弹层此前没有 ref。
|
|
5621
6061
|
const expertDetailRef = useRef(null);
|
|
5622
6062
|
useFocusTrap(expertDetailRef, !!detail);
|
|
6063
|
+
// OPT-16/26 · 3.2/3.3:详情弹层里「人设全文展开」「quickPrompts 全部展开」两个局部开关。
|
|
6064
|
+
// 必须声明在组件顶层、**每次渲染都调用**:这两个 hook 原先写在
|
|
6065
|
+
// `detail ? (() => { … })() : null` 这个条件 IIFE 里,首帧 detail 为 null 时一次都不调用,
|
|
6066
|
+
// 点卡片「详情」后同一组件突然多调用两个 hook —— React #310
|
|
6067
|
+
// 「Rendered more hooks than during the previous render」直接把整块面板打进错误边界
|
|
6068
|
+
// (用户看到的就是市场专家页点详情即白屏报 #310)。开关只在打开详情时重置,
|
|
6069
|
+
// 保证换一个专家进来是折叠态,而不是沿用上一条的展开状态。
|
|
6070
|
+
const [personaExpanded, setPersonaExpanded] = useState(false);
|
|
6071
|
+
const [showAllPrompts, setShowAllPrompts] = useState(false);
|
|
6072
|
+
// 正在拉详情的那张卡片(plugin 名):加载期间只把这一个卡片的「详情」按钮标成「读取中…」,
|
|
6073
|
+
// 详情弹层等数据到手才挂载。**不要**在加载中途先 setDetail 一个只有标题的矮盒子——.dsh-mcp-modal
|
|
6074
|
+
// 高度随内容自适应,那会让用户看到「先闪一个小窗、再跳成大窗」。
|
|
6075
|
+
const [openingPlugin, setOpeningPlugin] = useState('');
|
|
5623
6076
|
|
|
5624
6077
|
const loadInstalled = useCallback(async () => {
|
|
5625
6078
|
const res = await call('wbExpertInstalled');
|
|
@@ -5646,14 +6099,24 @@ const ExpertMarket = ({ call }) => {
|
|
|
5646
6099
|
useEffect(() => { load(); loadInstalled(); }, [load, loadInstalled]);
|
|
5647
6100
|
|
|
5648
6101
|
const openDetail = async (expert) => {
|
|
5649
|
-
setDetail({
|
|
6102
|
+
// 弹层**只在详情数据到手后挂载一次**。此前这里是「先 setDetail({…, loading:true}) 挂个矮盒子,
|
|
6103
|
+
// 拿到人设与技能清单后再 setDetail 一次」:因为 .dsh-mcp-modal 高度随内容自适应,用户看到的就是
|
|
6104
|
+
// 先闪一个小窗、再跳成大窗。现在加载期间只在卡片按钮上显示「读取中…」,弹层一次到位。
|
|
6105
|
+
if (openingPlugin) return; // 串行化:一条还没回来就别再拉第二条,避免后到的覆盖先到的。
|
|
6106
|
+
setOpeningPlugin(expert.plugin);
|
|
5650
6107
|
setNote('');
|
|
6108
|
+
// 展开态跟着「这一次打开」重置,别让上一条专家留下的展开状态带进来。
|
|
6109
|
+
setPersonaExpanded(false);
|
|
6110
|
+
setShowAllPrompts(false);
|
|
5651
6111
|
try {
|
|
5652
6112
|
const res = await call('wbExpertDetail', { plugin: expert.plugin });
|
|
5653
6113
|
if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
|
|
5654
|
-
setDetail({ ...expert, ...res
|
|
6114
|
+
setDetail({ ...expert, ...res });
|
|
5655
6115
|
} catch (e) {
|
|
5656
|
-
|
|
6116
|
+
// 失败也要把弹层开出来:里面有 detailError 的红字,比「点了没反应」好排查。
|
|
6117
|
+
setDetail({ ...expert, detailError: String(e?.message || e) });
|
|
6118
|
+
} finally {
|
|
6119
|
+
setOpeningPlugin('');
|
|
5657
6120
|
}
|
|
5658
6121
|
};
|
|
5659
6122
|
const summon = async (expert) => {
|
|
@@ -5780,7 +6243,7 @@ const ExpertMarket = ({ call }) => {
|
|
|
5780
6243
|
installedSkillPlugins.has(item.plugin) ? h(Badge, null, t('含技能')) : null,
|
|
5781
6244
|
),
|
|
5782
6245
|
h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
5783
|
-
h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openDetail(item) }, t('详情')),
|
|
6246
|
+
h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: openingPlugin === item.plugin, 'aria-busy': openingPlugin === item.plugin || undefined, onClick: () => openDetail(item) }, openingPlugin === item.plugin ? t('读取中…') : t('详情')),
|
|
5784
6247
|
// 团队型已走桥接:主理人接管会话,成员人设落盘后由主理人用 subagent 调度。
|
|
5785
6248
|
// stale 团队行:「更新预设」与「送还」**并列**(后端 upsert 原地重写该行,
|
|
5786
6249
|
// 用户也可能只想删掉旧行)。1.9.4 这里曾写成三元,导致更新按钮永不渲染。
|
|
@@ -5805,9 +6268,8 @@ const ExpertMarket = ({ call }) => {
|
|
|
5805
6268
|
detail
|
|
5806
6269
|
? (() => {
|
|
5807
6270
|
// 弹层里该给哪些按钮:与卡片按钮同源(expertCardActions),只是换到详情对象上算。
|
|
5808
|
-
//
|
|
5809
|
-
|
|
5810
|
-
const [showAllPrompts, setShowAllPrompts] = useState(false);
|
|
6271
|
+
// 注意:这个 IIFE 里**不允许再出现任何 hook**(personaExpanded / showAllPrompts 已上提到
|
|
6272
|
+
// 组件顶层)——它本身就在 `detail ?` 条件分支里,一旦在此调用 hook 就是 React #310。
|
|
5811
6273
|
const detailActions = expertCardActions({
|
|
5812
6274
|
summoned: presets.has(detail.plugin),
|
|
5813
6275
|
expertType: detail.expertType,
|
|
@@ -5817,9 +6279,7 @@ const ExpertMarket = ({ call }) => {
|
|
|
5817
6279
|
h('div', { ref: expertDetailRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('专家详情'), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setDetail(null); } } },
|
|
5818
6280
|
h('h3', null, t('{0}({1})', [detail.name, detail.profession])),
|
|
5819
6281
|
h('div', { className: 'dsh-mcp-card-vendor' }, detail.categoryName || detail.categoryId || ''),
|
|
5820
|
-
|
|
5821
|
-
? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('正在拉取人设与技能清单…'))
|
|
5822
|
-
: h('div', null,
|
|
6282
|
+
h('div', null,
|
|
5823
6283
|
detail.detailError ? h('p', { className: 'dsh-mcp-wb-error' }, detail.detailError) : null,
|
|
5824
6284
|
detail.description ? h('p', { className: 'dsh-mcp-wb-modal-desc' }, detail.description) : null,
|
|
5825
6285
|
detail.quickPrompts && detail.quickPrompts.length
|
|
@@ -5891,13 +6351,13 @@ const ExpertMarket = ({ call }) => {
|
|
|
5891
6351
|
h('div', { className: 'dsh-mcp-modal-actions' },
|
|
5892
6352
|
h(Btn, { onClick: () => setDetail(null), disabled: busy, children: t('关闭') }),
|
|
5893
6353
|
detailActions.update
|
|
5894
|
-
? h(Btn, { primary: true, icon: 'refresh', disabled: busy
|
|
6354
|
+
? h(Btn, { primary: true, icon: 'refresh', disabled: busy, onClick: () => summon(detail), children: busy ? t('更新中…') : t('更新预设') })
|
|
5895
6355
|
: null,
|
|
5896
6356
|
detailActions.dismiss
|
|
5897
|
-
? h(Btn, { danger: true, icon: 'trash', disabled: busy
|
|
6357
|
+
? h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => dismiss(detail), children: t('送还') })
|
|
5898
6358
|
: null,
|
|
5899
6359
|
detailActions.summon
|
|
5900
|
-
? h(Btn, { primary: true, icon: 'plus', disabled: busy
|
|
6360
|
+
? h(Btn, { primary: true, icon: 'plus', disabled: busy, onClick: () => summon(detail), children: busy ? t('召唤中…') : detail.expertType === 'team' ? t('召唤专家团') : t('召唤') })
|
|
5901
6361
|
: null,
|
|
5902
6362
|
),
|
|
5903
6363
|
),
|
|
@@ -6980,15 +7440,59 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
6980
7440
|
setAwesomeLoading(false);
|
|
6981
7441
|
}
|
|
6982
7442
|
}, [call]);
|
|
6983
|
-
|
|
7443
|
+
// 探测节流 6 小时;force=true(用户点「重新加载」)跳过节流立即重探。
|
|
7444
|
+
// 注意:时间戳只在真的发起探测时写,否则每次挂载都会把闸门往后推、探测永不再跑。
|
|
7445
|
+
const checkAwesomeUpdate = useCallback(async (force = false) => {
|
|
7446
|
+
if (!force && Date.now() - readAwesomeProbeAt() < AWESOME_PROBE_INTERVAL_MS) return;
|
|
7447
|
+
writeAwesomeProbeAt(Date.now());
|
|
6984
7448
|
try {
|
|
6985
7449
|
const res = await call('wbAwesomeUpdateCheck');
|
|
6986
|
-
|
|
7450
|
+
// probe-failed 是环境性的瞬时失败(源不可达 / 超时 / HEAD 被拒),一律静默:
|
|
7451
|
+
// 真正「更新源不可达」由 awesomeStale 那条中性提示负责,不重复喊狼来了。
|
|
7452
|
+
setAwesomeUpdateHint(res && res.reason !== 'probe-failed' ? res : null);
|
|
6987
7453
|
} catch {
|
|
6988
7454
|
setAwesomeUpdateHint(null);
|
|
6989
7455
|
}
|
|
6990
7456
|
}, [call]);
|
|
6991
7457
|
const awesomeUpdateChecked = useRef(false);
|
|
7458
|
+
// ── README 预览弹层 ──────────────────────────────────────────────────────
|
|
7459
|
+
// 点卡片时才按需拉一次(服务端 npm registry / GitHub raw + 7 天落盘缓存);
|
|
7460
|
+
// 目录本身仍是 13 字段瘦身版,不因预览而变重。
|
|
7461
|
+
const [readmeView, setReadmeView] = useState(null);
|
|
7462
|
+
const readmeSeq = useRef(0);
|
|
7463
|
+
const readmeNodeRef = useRef(null);
|
|
7464
|
+
useFocusTrap(readmeNodeRef, !!readmeView);
|
|
7465
|
+
const closeReadme = () => {
|
|
7466
|
+
readmeSeq.current += 1; // 作废在途请求,避免关掉之后又被写回来
|
|
7467
|
+
setReadmeView(null);
|
|
7468
|
+
};
|
|
7469
|
+
const loadAwesomeReadme = useCallback(async (item, refresh = false) => {
|
|
7470
|
+
if (!item) return;
|
|
7471
|
+
// 弹层的排版样式随首次打开注入。放在事件回调里而不是组件顶层,是为了不引入
|
|
7472
|
+
// 任何新的 Hook —— 这也正是 no-conditional-hooks 测试要防的那类崩溃。
|
|
7473
|
+
ensureReadmeStyle();
|
|
7474
|
+
const seq = ++readmeSeq.current;
|
|
7475
|
+
const npm = typeof item.npm === 'string' ? item.npm : (item.npm && item.npm.name) || '';
|
|
7476
|
+
setReadmeView({ item, loading: true, ok: false, markdown: '', base: '', error: '', source: '', reason: '', truncated: false });
|
|
7477
|
+
let res = null;
|
|
7478
|
+
try {
|
|
7479
|
+
res = await call('wbAwesomeReadme', { name: item.name, npm, repo: item.repo || '', refresh: refresh === true });
|
|
7480
|
+
} catch (error) {
|
|
7481
|
+
res = { ok: false, error: error && error.message ? error.message : String(error) };
|
|
7482
|
+
}
|
|
7483
|
+
if (seq !== readmeSeq.current) return;
|
|
7484
|
+
if (!res || res.ok !== true) {
|
|
7485
|
+
const raw = (res && res.error) || '';
|
|
7486
|
+
// 宿主进程若仍是旧版(新 Remote 未注册),这里会收到 404 transport failure:
|
|
7487
|
+
// 换成一句可操作的话,别让用户以为功能坏了。
|
|
7488
|
+
const message = /404|not\s*found/i.test(raw)
|
|
7489
|
+
? t('预览服务尚未加载,请完全退出 DSH 后重新打开再试')
|
|
7490
|
+
: (raw || t('读取 README 失败:未知错误'));
|
|
7491
|
+
setReadmeView({ item, loading: false, ok: false, markdown: '', base: '', error: message, source: '', reason: (res && res.reason) || '', truncated: false });
|
|
7492
|
+
return;
|
|
7493
|
+
}
|
|
7494
|
+
setReadmeView({ item, loading: false, ok: true, markdown: res.markdown || '', base: res.base || '', error: '', source: res.source || '', reason: res.reason || '', truncated: res.truncated === true });
|
|
7495
|
+
}, [call]);
|
|
6992
7496
|
// 自动加载改成「每次挂载只发一次」:旧条件是 `awesomeLoading || awesomePlugins.length` 就跳过,
|
|
6993
7497
|
// 一旦首屏已经有快照,真实目录就永远不会再拉——快照便成了死数据,必须去掉那个门槛。
|
|
6994
7498
|
const awesomeAutoLoaded = useRef(false);
|
|
@@ -7590,14 +8094,14 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7590
8094
|
)
|
|
7591
8095
|
: null,
|
|
7592
8096
|
// ── T06/Awesome:DSH 社区(awesome-dsh-plugins)目录 ──
|
|
7593
|
-
tab === 'plugin' && pluginSource === 'awesome' && awesomeStale ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, t('目录缓存已过期(更新源暂时不可达,显示的是本地缓存)。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: () => loadAwesomeCatalog(true) }, t('重新加载'))) : null,
|
|
7594
|
-
tab === 'plugin' && pluginSource === 'awesome' && awesomeUpdateHint && (awesomeUpdateHint.reason === 'update-available' || awesomeUpdateHint.reason === 'no-cache') ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, awesomeUpdateHint.reason === 'update-available' ? t('DSH 社区目录有新版本(本地缓存 {0})。点「重新加载」可拉取最新目录。', [awesomeUpdateHint.localEtag || t('未知错误')]) : t('本地还没有 DSH 社区目录缓存,点「重新加载」可首次拉取目录。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: async () => { setAwesomeUpdateHint(null); await loadAwesomeCatalog(true); } }, t('重新加载')), h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setAwesomeUpdateHint(null) }, t('知道了'))) : null,
|
|
7595
|
-
|
|
8097
|
+
tab === 'plugin' && pluginSource === 'awesome' && awesomeStale ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, t('目录缓存已过期(更新源暂时不可达,显示的是本地缓存)。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: () => { writeAwesomeProbeAt(0); awesomeUpdateChecked.current = false; loadAwesomeCatalog(true); } }, t('重新加载'))) : null,
|
|
8098
|
+
tab === 'plugin' && pluginSource === 'awesome' && awesomeUpdateHint && (awesomeUpdateHint.reason === 'update-available' || awesomeUpdateHint.reason === 'no-cache') ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, awesomeUpdateHint.reason === 'update-available' ? t('DSH 社区目录有新版本(本地缓存 {0})。点「重新加载」可拉取最新目录。', [awesomeUpdateHint.localEtag || t('未知错误')]) : t('本地还没有 DSH 社区目录缓存,点「重新加载」可首次拉取目录。'), h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: async () => { setAwesomeUpdateHint(null); writeAwesomeProbeAt(0); awesomeUpdateChecked.current = false; await loadAwesomeCatalog(true); } }, t('重新加载')), h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => setAwesomeUpdateHint(null) }, t('知道了'))) : null,
|
|
8099
|
+
|
|
7596
8100
|
tab === 'plugin' && pluginSource === 'awesome' && awesomeError ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, awesomeError, h('button', { type: 'button', className: 'dsh-mcp-btn', disabled: awesomeLoading, onClick: () => loadAwesomeCatalog() }, t('重试'))) : null,
|
|
7597
8101
|
tab === 'plugin' && pluginSource === 'awesome' && awesomeLoading && !awesomePlugins.length ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在加载 DSH 社区目录…')) : null,
|
|
7598
8102
|
tab === 'plugin' && pluginSource === 'awesome' && !awesomeError && !awesomeLoading && !awesomePlugins.length ? h(EmptyState, { icon: 'store', title: t('DSH 社区目录暂时是空的'), hint: t('目录来自 awesome-dsh-plugins 社区清单(jsdelivr CDN 分发),拉取失败可重试。'), actions: [{ label: t('重新读取目录'), icon: 'refresh', onClick: () => loadAwesomeCatalog() }] }) : null,
|
|
7599
8103
|
tab === 'plugin' && pluginSource === 'awesome' && awesomePlugins.length && !visibleAwesome.length ? h(EmptyState, { tight: true, title: t('没有匹配的社区插件'), hint: t('换个关键词,或清空分类与标签筛选。'), actions: [{ label: t('清空筛选'), icon: 'x', onClick: () => { setAwesomeQuery(''); setAwesomeCategory('all'); setAwesomeTags([]); } }] }) : null,
|
|
7600
|
-
tab === 'plugin' && pluginSource === 'awesome' && visibleAwesome.length ? h('div', { className: 'dsh-mcp-wb-grid' }, visibleAwesome.map((item) => h('div', { key: item.name, className: 'dsh-mcp-card wb' },
|
|
8104
|
+
tab === 'plugin' && pluginSource === 'awesome' && visibleAwesome.length ? h('div', { className: 'dsh-mcp-wb-grid' }, visibleAwesome.map((item) => h('div', { key: item.name, className: 'dsh-mcp-card wb dsh-mcp-card-clickable', role: 'button', tabIndex: 0, title: t('查看 README 介绍与插图'), onClick: () => loadAwesomeReadme(item), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadAwesomeReadme(item); } } },
|
|
7601
8105
|
h('div', { className: 'dsh-mcp-card-top' },
|
|
7602
8106
|
h('div', null,
|
|
7603
8107
|
h('div', { className: 'dsh-mcp-card-name' }, item.name),
|
|
@@ -7611,9 +8115,29 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7611
8115
|
item.stars ? h(Badge, null, '★ ' + item.stars) : null,
|
|
7612
8116
|
item.category && item.category !== 'plugin' ? h(Badge, null, item.category) : null,
|
|
7613
8117
|
(item.tags || []).map((tag) => h('span', { key: tag, className: 'dsh-mcp-tag-chip on' }, tag))),
|
|
7614
|
-
h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
8118
|
+
h('div', { className: 'dsh-mcp-wb-card-actions', onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation() },
|
|
7615
8119
|
item.npm ? (String(item.npm).startsWith('@deepseek-ai/') ? h('span', { className: 'dsh-mcp-wb-blocked', title: t('DSH 宿主官方组件随宿主本体分发,无法也不需要从市场安装') }, t('宿主组件')) : h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy || isInstallRunning(item.name), onClick: () => installPlugin(item) }, t('安装'))) : h('span', { className: 'dsh-mcp-wb-blocked', title: t('该条目未提供 npm 包名,暂不支持安装') }, t('暂不支持安装')))))) : null,
|
|
7616
8120
|
tab === 'plugin' && pluginSource === 'awesome' && awesomePageCount > 1 ? h(Pager, { page: safeAwesomePage, pageCount: awesomePageCount, total: sortedAwesome.length, onPage: setAwesomePage }) : null,
|
|
8121
|
+
// ── DSH 社区插件 README 预览(点卡片打开;插图来自 README 自身的相对/绝对地址)──
|
|
8122
|
+
readmeView
|
|
8123
|
+
? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => { if (e.target === e.currentTarget) closeReadme(); } },
|
|
8124
|
+
h('div', { ref: readmeNodeRef, className: 'dsh-mcp-modal dsh-mcp-wb-modal dsh-mcp-readme-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('{0} README 预览', [readmeView.item && readmeView.item.name ? readmeView.item.name : '']), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); closeReadme(); } } },
|
|
8125
|
+
h('h3', null, readmeView.item && readmeView.item.name ? readmeView.item.name : ''),
|
|
8126
|
+
h('div', { className: 'dsh-mcp-readme-meta' },
|
|
8127
|
+
readmeView.item && readmeView.item.repo ? h(Badge, { title: readmeView.item.repo }, String(readmeView.item.repo).replace(/^https?:\/\/github\.com\//i, '')) : null,
|
|
8128
|
+
readmeView.item && readmeView.item.npm ? h(Badge, { title: readmeView.item.npm }, 'npm') : null,
|
|
8129
|
+
readmeView.source ? h(Badge, null, readmeView.source === 'npm' ? 'npm registry' : 'GitHub') : null),
|
|
8130
|
+
readmeView.loading
|
|
8131
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note', role: 'status' }, t('正在读取 README…'))
|
|
8132
|
+
: readmeView.ok
|
|
8133
|
+
? h('div', { className: 'dsh-mcp-readme-body' }, renderReadmeBlocks(readmeView.markdown, readmeView.base))
|
|
8134
|
+
: h('p', { className: 'dsh-mcp-wb-error', role: 'alert' }, readmeView.error),
|
|
8135
|
+
readmeView.ok && readmeView.truncated ? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('README 过长,已截断显示。')) : null,
|
|
8136
|
+
h('div', { className: 'dsh-mcp-modal-actions' },
|
|
8137
|
+
h(Btn, { onClick: closeReadme, children: t('关闭') }),
|
|
8138
|
+
readmeView.item && readmeView.item.npm ? h(Btn, { primary: true, icon: 'plus', disabled: busy || isInstallRunning(readmeView.item.name), onClick: () => { const target = readmeView.item; closeReadme(); installPlugin(target); } }, t('安装')) : null,
|
|
8139
|
+
h(Btn, { onClick: () => loadAwesomeReadme(readmeView.item, true), disabled: readmeView.loading, children: t('重新读取') }))))
|
|
8140
|
+
: null,
|
|
7617
8141
|
// ── T04/R7:令牌弹窗(连接器 mcp.json 里的 ${...} 占位符)──
|
|
7618
8142
|
// 设计约束:只收集用户输入并原样透传;槽位来自目录条目的 tokenSlots,
|
|
7619
8143
|
// 同一占位符可能出现多次(同 server 的 env 与 header),按 placeholder 去重。
|
|
@@ -8928,6 +9452,7 @@ const MCP_CONTRIBUTION = {
|
|
|
8928
9452
|
// T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)。与宿主 method、typert.js 三处必须同步。
|
|
8929
9453
|
{ id: 'dsh-armory#mcpManager/wbAwesomeCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeCatalog', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeCatalogResult') },
|
|
8930
9454
|
{ id: 'dsh-armory#mcpManager/wbAwesomeDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeDetailResult') },
|
|
9455
|
+
{ id: 'dsh-armory#mcpManager/wbAwesomeReadme', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeReadme', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeReadmeResult') },
|
|
8931
9456
|
{ id: 'dsh-armory#mcpManager/wbAwesomeUpdateCheck', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeUpdateCheck', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbUpdateHintResult') },
|
|
8932
9457
|
{ id: 'dsh-armory#mcpManager/wbPluginDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbPluginDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbPluginDetailResult') },
|
|
8933
9458
|
// ── WorkBuddy 专家直连:召唤预设 + 捆绑技能(同一命名空间同一信封)──
|