dsh-armory 2.4.3 → 2.4.4
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 +8 -3
- package/README.md +15 -5
- package/docs/perf-market-load.md +469 -0
- package/docs/project-mcp-lifecycle.md +2 -0
- package/lib/client.js +1407 -114
- package/lib/expert-market.js +17 -1
- package/lib/index.js +314 -62
- package/lib/mcp-community.js +353 -0
- package/lib/session-persona.js +302 -48
- package/lib/skill-disable.js +178 -0
- package/lib/system-open.js +113 -0
- package/lib/typert.js +10 -0
- package/lib/wb-auth-rules.js +47 -16
- package/lib/wb-market.js +85 -1
- package/lib/whats-new-en.js +26 -0
- package/lib/whats-new.js +29 -0
- package/lib/workspace-runtime.js +37 -3
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -3,6 +3,48 @@ window.__ModuleLoader__.load({
|
|
|
3
3
|
factory: (require) => {
|
|
4
4
|
var module = { exports: {} };
|
|
5
5
|
var exports = module.exports;
|
|
6
|
+
// ── P0-2:启动计时(默认关闭,零开销)────────────────────────────────────
|
|
7
|
+
// 排查「打开面板慢 / 市场首屏慢」时不用再翻 DevTools 手工打点。在 DevTools 里执行
|
|
8
|
+
// localStorage.setItem('dsh-armory.perf.instrument', '1')
|
|
9
|
+
// 然后 Ctrl+R,模块会把四段耗时写进 window.__ARMORY_PERF__:
|
|
10
|
+
// module.materialize —— 本 bundle 从解析到 materialize(含各组件定义)
|
|
11
|
+
// apply.start / apply.end —— 插件 apply 的起止(相对 materialize 起点)
|
|
12
|
+
// firstRpc.method / firstRpc.ms —— 第一个 business RPC 的方法名与往返耗时
|
|
13
|
+
// 关闭时只读一次 localStorage,之后所有分支短路:不建对象、不打点、不额外闭包。
|
|
14
|
+
const PERF_ON = (() => {
|
|
15
|
+
try { const v = localStorage.getItem('dsh-armory.perf.instrument'); return v === '1' || v === 'true'; } catch { return false; }
|
|
16
|
+
})();
|
|
17
|
+
const PERF_T0 = PERF_ON && typeof performance !== 'undefined' && typeof performance.now === 'function'
|
|
18
|
+
? performance.now()
|
|
19
|
+
: 0;
|
|
20
|
+
const perfBucket = PERF_ON && typeof window !== 'undefined'
|
|
21
|
+
? (window.__ARMORY_PERF__ = window.__ARMORY_PERF__ || {})
|
|
22
|
+
: null;
|
|
23
|
+
const perfMark = (group, key, value) => {
|
|
24
|
+
if (!perfBucket) return;
|
|
25
|
+
if (!perfBucket[group]) perfBucket[group] = {};
|
|
26
|
+
perfBucket[group][key] = Math.round(value * 10) / 10;
|
|
27
|
+
};
|
|
28
|
+
// ── 性能开关(docs/perf-market-load.md §7)────────────────────────────────
|
|
29
|
+
// 每一项都默认开启;在 DevTools 里写 '0'(或 'false')即可单独回落到改造前行为,
|
|
30
|
+
// 写 'legacy' 则整体回到「挂载即预取」的旧节奏。读失败一律按默认值处理。
|
|
31
|
+
const perfFlag = (key, fallback) => {
|
|
32
|
+
try {
|
|
33
|
+
const raw = localStorage.getItem(key);
|
|
34
|
+
if (raw === null) return fallback;
|
|
35
|
+
return !(raw === '0' || raw === 'false');
|
|
36
|
+
} catch { return fallback; }
|
|
37
|
+
};
|
|
38
|
+
const MARKET_PERF = {
|
|
39
|
+
// P1-1:'legacy' = 恢复「市场挂载后 400ms 就预取插件目录」的旧节奏。
|
|
40
|
+
prefetchMode: (() => {
|
|
41
|
+
try { return localStorage.getItem('dsh-armory.marketPrefetch') === 'legacy' ? 'legacy' : 'idle'; } catch { return 'idle'; }
|
|
42
|
+
})(),
|
|
43
|
+
connectorSnapshot: perfFlag('dsh-armory.connectorSnapshot', true), // P1-4
|
|
44
|
+
awesomeSnapshotLazy: perfFlag('dsh-armory.awesomeSnapshotLazy', true), // P1-3
|
|
45
|
+
skillSwr: perfFlag('dsh-armory.skillSwr', true), // P1-5
|
|
46
|
+
hoverPrefetch: perfFlag('dsh-armory.hoverPrefetch', true), // P2-3
|
|
47
|
+
};
|
|
6
48
|
// MCP 市场 UI browser bundle:右下角浮动按钮打开响应式管理面板。
|
|
7
49
|
var React = require('react');
|
|
8
50
|
var __injectCss = function(text) {
|
|
@@ -265,6 +307,7 @@ const EN_US_MESSAGES = {
|
|
|
265
307
|
"刷新": "Refresh",
|
|
266
308
|
"刷新 MCP 状态": "Refresh MCP status",
|
|
267
309
|
"刷新中…": "Refreshing…",
|
|
310
|
+
"刷新连接器目录失败,当前显示的是本机快照:{0}": "Failed to refresh the connector catalog; showing the local snapshot: {0}",
|
|
268
311
|
"加载失败": "Load failed",
|
|
269
312
|
"动作": "Actions",
|
|
270
313
|
"升级": "Upgrade",
|
|
@@ -443,6 +486,8 @@ const EN_US_MESSAGES = {
|
|
|
443
486
|
"已禁用": "Disabled",
|
|
444
487
|
"已禁用 ": "Disabled ",
|
|
445
488
|
"已禁用 {0}": "Disabled {0}",
|
|
489
|
+
"已禁用技能 {0}": "Skill {0} disabled",
|
|
490
|
+
"已释放技能 {0}": "Skill {0} re-enabled",
|
|
446
491
|
"已移除 ": "Removed ",
|
|
447
492
|
"已脱敏": "Masked",
|
|
448
493
|
"已请求重连 ": "Reconnect requested ",
|
|
@@ -453,6 +498,9 @@ const EN_US_MESSAGES = {
|
|
|
453
498
|
"已隐藏 {0} 个不兼容条目": "Hidden {0} incompatible entries",
|
|
454
499
|
"市场": "Market",
|
|
455
500
|
"市场原名": "Market name",
|
|
501
|
+
"市场原名:{0}": "Market name: {0}",
|
|
502
|
+
"安装目录:{0}": "Install directory: {0}",
|
|
503
|
+
"这个 skill 的安装目录名,namespace__slug 形态即它在市场里的标识。": "This skill's install directory name; the namespace__slug form is its identity in the market.",
|
|
456
504
|
"市场数据加载失败;其余分类仍可正常使用。": "Failed to load market data; other categories keep working.",
|
|
457
505
|
"市场:连接器、SkillHub 技能与专家召唤。": "Market: connectors, SkillHub skills and expert summoning.",
|
|
458
506
|
"归属存疑": "Ownership uncertain",
|
|
@@ -504,6 +552,8 @@ const EN_US_MESSAGES = {
|
|
|
504
552
|
"挂到当前会话(可随时换或取消);再点别的专家 = 直接替换": "Attach to the current session (swap or detach anytime); clicking another expert replaces it",
|
|
505
553
|
"挂载失败": "Attach failed",
|
|
506
554
|
"挂载失败:": "Attach failed: ",
|
|
555
|
+
"人设已生效,但该会话残留旧注册,取消需重启 DSH 后生效。": "The persona is active, but a stale registration remains in this session; restart DSH before it can be detached.",
|
|
556
|
+
"人设正文是按专家介绍生成的替身,不是原作者设定;原文不可用。": "The persona text is a stand-in generated from the expert profile, not the author's original; the original is unavailable.",
|
|
507
557
|
"挂载异常:": "Attach error: ",
|
|
508
558
|
"指令内容(": "Instruction body (",
|
|
509
559
|
"按传输过滤": "Filter by transport",
|
|
@@ -578,7 +628,6 @@ const EN_US_MESSAGES = {
|
|
|
578
628
|
"本页没有已安装的技能": "No installed skills on this page",
|
|
579
629
|
"来自 mcp-client 最近日志": "From recent mcp-client logs",
|
|
580
630
|
"来自其他配置层,只读": "From another config layer, read-only",
|
|
581
|
-
"来自市场 {0}": "From market: {0}",
|
|
582
631
|
"构建授权已过期,未升级;请重试。": "Build authorization expired; not upgraded. Please retry.",
|
|
583
632
|
"查看": "View",
|
|
584
633
|
"查看原始 JSON": "View raw JSON",
|
|
@@ -649,7 +698,9 @@ const EN_US_MESSAGES = {
|
|
|
649
698
|
"确认导入": "Confirm import",
|
|
650
699
|
"确认移除 MCP「": "Confirm removal of MCP \"",
|
|
651
700
|
"确认送还": "Confirm return",
|
|
701
|
+
"恢复模型调用该技能": "Let the model invoke this skill again",
|
|
652
702
|
"禁用": "Disable",
|
|
703
|
+
"禁用后模型不再加载该技能,不再缴它的上下文税;手动 /引用 不受影响": "Disabling keeps the model from loading this skill, so its schema stops costing context tax; manual /references are unaffected",
|
|
653
704
|
"禁用服务器 {0}": "Disable server {0}",
|
|
654
705
|
"移除": "Remove",
|
|
655
706
|
"移除:": "Remove: ",
|
|
@@ -847,6 +898,7 @@ const EN_US_MESSAGES = {
|
|
|
847
898
|
"已就绪": "Ready",
|
|
848
899
|
"建连中": "Connecting",
|
|
849
900
|
"释放中": "Disposing",
|
|
901
|
+
"释放": "Enable",
|
|
850
902
|
"上次回收 {0} ms": "Last teardown {0} ms",
|
|
851
903
|
"上次回收 {0} ms(超时)": "Last teardown {0} ms (timed out)",
|
|
852
904
|
"回收中 {0} ms": "Tearing down {0} ms",
|
|
@@ -935,8 +987,10 @@ const EN_US_MESSAGES = {
|
|
|
935
987
|
"在全量 {0} 条插件里筛出已安装的(跨页生效)": "Filter the full {0} plugins to installed ones (across pages)",
|
|
936
988
|
"已安装 {0} / {1}": "Installed {0} / {1}",
|
|
937
989
|
|
|
938
|
-
// T04/R7:连接器令牌弹窗(占位符凭据引导)。「环境变量
|
|
990
|
+
// T04/R7:连接器令牌弹窗(占位符凭据引导)。「环境变量 」上表已有,这里只补新增的条目。
|
|
939
991
|
"请求头 ": "Header ",
|
|
992
|
+
// url 占位符(https://…?token=${X}):凭据写在连接地址里,标签不能写成「环境变量 url」。
|
|
993
|
+
"URL 参数": "URL parameter",
|
|
940
994
|
"填写连接器凭据": "Enter connector credentials",
|
|
941
995
|
"填写 {0} 的凭据": "Enter credentials for {0}",
|
|
942
996
|
"该连接器的配置里有 {0} 个占位符,需要你提供对应凭据才能生效。没填的会保持原样,安装后仍可在 MCP 配置里手动补全。": "This connector's config contains {0} placeholder(s) that need your credentials to take effect. Anything left blank keeps its original value and can be filled in later from the MCP config.",
|
|
@@ -963,6 +1017,7 @@ const EN_US_MESSAGES = {
|
|
|
963
1017
|
"正在校验回调…": "Verifying callback…",
|
|
964
1018
|
"回调地址无效,请重新授权。": "That callback address is not valid. Please authorize again.",
|
|
965
1019
|
"授权失败": "Authorization failed",
|
|
1020
|
+
"该连接器需要通过浏览器完成一次授权;如果你已经从服务方拿到访问令牌,也可以在这里直接填写。": "This connector needs a one-time browser authorization; if you already have an access token from the provider, you can enter it here instead.",
|
|
966
1021
|
"也可以直接粘贴访问令牌": "You can also paste an access token directly",
|
|
967
1022
|
"粘贴访问令牌": "Paste an access token",
|
|
968
1023
|
"用令牌安装": "Install with token",
|
|
@@ -1055,6 +1110,15 @@ const EN_US_MESSAGES = {
|
|
|
1055
1110
|
"核验日期来自目录批次核验,不是插件自身的更新时间": "The verification date is when the catalog batch was checked, not when this plugin was updated",
|
|
1056
1111
|
"查看 {0} 的 README": "View the README for {0}",
|
|
1057
1112
|
"来源:WorkBuddy 官方分发": "Source: official WorkBuddy distribution",
|
|
1113
|
+
"说明:{0}": "About: {0}",
|
|
1114
|
+
"本机依赖": "Local dependency",
|
|
1115
|
+
"已找到:{0}": "Found: {0}",
|
|
1116
|
+
"未找到:{0}": "Not found: {0}",
|
|
1117
|
+
"当前系统不支持:{0}": "Not supported on this platform: {0}",
|
|
1118
|
+
"本地客户端缺失时安装会失败;请先安装对应客户端,并开启它的 MCP / 集成访问后再重试。": "Installation will fail while the local client is missing. Install the client first and enable its MCP / integration access, then retry.",
|
|
1119
|
+
"本机没有找到该连接器依赖的程序:{0}。请先安装对应客户端并开启它的 MCP / 集成访问后重试。": "The program this connector depends on is not installed: {0}. Install the client and enable its MCP / integration access, then retry.",
|
|
1120
|
+
"该连接器依赖本机程序,但当前系统不支持:{0}": "This connector depends on a local program that this platform doesn't support: {0}",
|
|
1121
|
+
"本机依赖已找到;若连接失败,请确认对应客户端正在运行并已开启 MCP / 集成访问。": "Local dependency found. If the connection still fails, make sure the client is running and its MCP / integration access is enabled.",
|
|
1058
1122
|
|
|
1059
1123
|
"装备清单": "Loadout",
|
|
1060
1124
|
"导出": "Export",
|
|
@@ -1106,7 +1170,80 @@ const EN_US_MESSAGES = {
|
|
|
1106
1170
|
"已定位到插件市场:{0}": "Opened the plugin market: {0}",
|
|
1107
1171
|
"导出本机配置,或导入朋友的清单做差异对比": "Export your local setup, or import a friend's loadout to see the differences",
|
|
1108
1172
|
|
|
1173
|
+
// ── P0:连接器真实连接态 / 授权状态(2026-10-29)────────────────────────
|
|
1174
|
+
"服务器已连接并注册了工具": "Server is connected and has registered tools",
|
|
1175
|
+
"已安装 · 已连接": "Installed · connected",
|
|
1176
|
+
"服务器返回 401/403 类鉴权失败,重连不会恢复,需要重新授权": "The server rejected the credentials (401/403). Reconnecting won't help; re-authorize instead",
|
|
1177
|
+
"已安装 · 需授权": "Installed · needs authorization",
|
|
1178
|
+
"配置已写入,但服务器当前没有连上(点「重连」再试)": "Config was written, but the server isn't connected right now (try Reconnect)",
|
|
1179
|
+
"已安装 · 未连接": "Installed · not connected",
|
|
1180
|
+
"重新授权": "Re-authorize",
|
|
1181
|
+
"读取授权状态失败": "Couldn't read authorization status",
|
|
1182
|
+
"正在重连…": "Reconnecting…",
|
|
1183
|
+
"已请求重连": "Reconnect requested",
|
|
1184
|
+
"正在刷新授权…": "Refreshing authorization…",
|
|
1185
|
+
"{0}:授权已刷新,正在把新凭据写回配置…": "{0}: authorization refreshed, writing the new credential back to the config…",
|
|
1186
|
+
"刷新授权失败": "Couldn't refresh authorization",
|
|
1187
|
+
"服务器拒绝当前凭据:重新走一次授权": "The server rejected the credential; run authorization again",
|
|
1188
|
+
"拆掉这条连接并按当前配置重建": "Tear down this connection and rebuild it from the current config",
|
|
1189
|
+
"授权状态": "Authorization status",
|
|
1190
|
+
"正在读取授权状态…": "Reading authorization status…",
|
|
1191
|
+
"授权已过期({0}),点「刷新授权」重新获取;刷不动会自动回到浏览器授权。": "Authorization expired ({0}). Use Refresh authorization; if that fails you'll be taken back to browser authorization.",
|
|
1192
|
+
"无有效期记录": "no expiry recorded",
|
|
1193
|
+
"已授权{0}": "Authorized{0}",
|
|
1194
|
+
",有效期至 {0}": ", valid until {0}",
|
|
1195
|
+
"尚未授权:安装时会引导你完成浏览器授权。": "Not authorized yet: installing will walk you through browser authorization.",
|
|
1196
|
+
"没有可用的授权记录。": "No usable authorization record.",
|
|
1197
|
+
"服务器提供了 refresh_token:过期后可以在这里直接刷新,无需重走浏览器。": "The server issued a refresh_token: once it expires you can refresh here without repeating browser authorization.",
|
|
1198
|
+
"刷新授权": "Refresh authorization",
|
|
1199
|
+
"去授权": "Authorize",
|
|
1200
|
+
"凭据来源": "Where the credential comes from",
|
|
1201
|
+
"我已有令牌": "I already have a token",
|
|
1202
|
+
"服务器拒绝当前凭据(401/403 类):重连不会恢复,需要重新授权": "The server rejected the credential (401/403): reconnecting won't help, re-authorize instead",
|
|
1203
|
+
"需授权": "Needs authorization",
|
|
1204
|
+
"这台服务器拒绝当前凭据(401/403 类),重连不会恢复:请到「市场 → 连接器」打开对应连接器点「重新授权」。由 WorkBuddy 账号代注入凭据的连接器(如 ima)DSH 无法代办。": "This server rejected the credential (401/403); reconnecting won't help. Open the matching connector under Market → Connectors and use Re-authorize. Connectors whose credential is injected by your WorkBuddy account (ima, for example) can't be set up by DSH.",
|
|
1205
|
+
|
|
1109
1206
|
":": ": ",
|
|
1207
|
+
|
|
1208
|
+
// ── 社区 MCP(连接器页第二个来源:官方 MCP Registry)──────────────────────
|
|
1209
|
+
// 新增文案统一放这一节,方便与常量 / 面板代码就近对照;键仍进双向差集护栏。
|
|
1210
|
+
"连接器来源": "Connector source",
|
|
1211
|
+
"WorkBuddy 连接器": "WorkBuddy connectors",
|
|
1212
|
+
"WorkBuddy 官方分发的连接器;多数需要 WorkBuddy 账号授权": "Connectors distributed by WorkBuddy; most need WorkBuddy account authorization",
|
|
1213
|
+
"社区 MCP": "Community MCP",
|
|
1214
|
+
"官方 MCP Registry 的社区条目,不依赖 WorkBuddy 账号": "Community entries from the official MCP Registry; no WorkBuddy account needed",
|
|
1215
|
+
"社区 MCP 来自官方 MCP Registry,由社区提交;军械库未审核其代码,加入前请自行确认命令与环境变量。": "Community MCPs come from the official MCP Registry and are submitted by the community. The armory has not reviewed their code; please verify the command and environment variables before adding one.",
|
|
1216
|
+
"搜索社区 MCP(名称 / 说明)": "Search community MCPs (name / description)",
|
|
1217
|
+
"只看可直接运行(npx / uvx)": "Only directly runnable (npx / uvx)",
|
|
1218
|
+
"只显示带 npx / uvx 启动包的条目;远程 HTTP 条目需要自备令牌": "Only show entries with an npx / uvx launch package; remote HTTP entries need your own token",
|
|
1219
|
+
"已读取 {0} 条": "Read {0} entries",
|
|
1220
|
+
" · 缓存结果": " · cached result",
|
|
1221
|
+
"这些条目只有远程地址,需要自备令牌": "These entries only have a remote URL and need your own token",
|
|
1222
|
+
"已隐藏 {0} 个远程条目": "Hidden {0} remote entries",
|
|
1223
|
+
"正在读取社区 MCP…": "Loading community MCPs…",
|
|
1224
|
+
"读取社区 MCP 失败": "Failed to read community MCPs",
|
|
1225
|
+
"社区列表加载失败:{0}": "Failed to load the community list: {0}",
|
|
1226
|
+
"没有匹配的社区条目": "No matching community entries",
|
|
1227
|
+
"有 {0} 条只有远程地址的条目被「只看可直接运行」挡住了。": "{0} entries with only a remote URL are hidden by \"Only directly runnable\".",
|
|
1228
|
+
"换一个更短的关键词,或取消「只看可直接运行」。": "Try a shorter keyword, or turn off \"Only directly runnable\".",
|
|
1229
|
+
"正在加载…": "Loading…",
|
|
1230
|
+
"已经到底了": "No more results",
|
|
1231
|
+
"第 {0} 页": "Page {0}",
|
|
1232
|
+
"加入 MCP": "Add MCP",
|
|
1233
|
+
"已加入": "Added",
|
|
1234
|
+
"已加入 ": "Added ",
|
|
1235
|
+
"预填一条 MCP 配置,确认后写入全局配置": "Prefill an MCP config and write it to the global config after you confirm",
|
|
1236
|
+
"当前 MCP 列表里已有同名服务器": "A server with the same name already exists in your MCP list",
|
|
1237
|
+
"远程(需自备令牌)": "Remote (bring your own token)",
|
|
1238
|
+
"需 {0} 个环境变量": "Needs {0} env vars",
|
|
1239
|
+
"含密钥字段": "Has secret fields",
|
|
1240
|
+
"有 {0} 个必填环境变量为空,服务器可能启动失败:{1}": "{0} required env vars are empty; the server may fail to start: {1}",
|
|
1241
|
+
"查看 {0} 的详情": "View details for {0}",
|
|
1242
|
+
"社区 MCP 详情": "Community MCP details",
|
|
1243
|
+
"启动命令": "Launch command",
|
|
1244
|
+
"远程地址": "Remote URL",
|
|
1245
|
+
"密钥": "Secret",
|
|
1246
|
+
"仓库": "Repository",
|
|
1110
1247
|
};
|
|
1111
1248
|
// 模块加载时先用 ①/③ 判定的 locale 装载一次;apply() 拿到宿主 locale 后会再按
|
|
1112
1249
|
// ② 重算(见 applyI18nLocale)。zh-CN(源语言)保持空字典恒等直出,其他语言
|
|
@@ -1223,9 +1360,54 @@ function writeAwesomeSnapshot(payload) {
|
|
|
1223
1360
|
});
|
|
1224
1361
|
// 超出配额安全线就不写:留着旧快照比写坏一次更划算。
|
|
1225
1362
|
if (text.length > AWESOME_SNAPSHOT_MAX_BYTES) return;
|
|
1363
|
+
// 与连接器快照共用 4.5MB 预算(对称):两边都看对方的现存字节数,谁后写都不会
|
|
1364
|
+
// 把合计顶过线,否则先写的那份会被下一次写入挤爆 localStorage。
|
|
1365
|
+
let otherBytes = 0;
|
|
1366
|
+
try { otherBytes = (localStorage.getItem(CONNECTOR_SNAPSHOT_KEY) || '').length; } catch { otherBytes = 0; }
|
|
1367
|
+
if (otherBytes + text.length > SNAPSHOT_BUDGET_BYTES) return;
|
|
1226
1368
|
localStorage.setItem(AWESOME_SNAPSHOT_KEY, text);
|
|
1227
1369
|
} catch { /* 配额溢出 / 隐私模式:快照只是加速,失败不致命 */ }
|
|
1228
1370
|
}
|
|
1371
|
+
// ── P1-4:连接器目录快照(浏览器侧镜像)─────────────────────────────────
|
|
1372
|
+
// 连接器目录要读 48MB 的 ZIP 再解出 769KB 索引,宿主侧约 350ms,再加 IPC 与 JSON
|
|
1373
|
+
// 反序列化;而用户重开市场的第一眼永远是这一屏。这里把上次成功渲染的目录留一份
|
|
1374
|
+
// (≤1.5MB / 24h),重开时先铺快照、再静默刷新。
|
|
1375
|
+
// 与插件快照共用 4.5MB 预算:两份合起来超线就不写这一份——宁可少一层加速,也不要把
|
|
1376
|
+
// localStorage 写满(写满会让本插件和同源其它插件一起失效)。
|
|
1377
|
+
const CONNECTOR_SNAPSHOT_KEY = KEY_PREFIX + '.connectorCatalog.v1';
|
|
1378
|
+
const CONNECTOR_SNAPSHOT_MAX_AGE = 24 * 60 * 60 * 1000;
|
|
1379
|
+
const CONNECTOR_SNAPSHOT_MAX_BYTES = 1.5 * 1024 * 1024;
|
|
1380
|
+
const SNAPSHOT_BUDGET_BYTES = 4.5 * 1024 * 1024;
|
|
1381
|
+
// 本次会话内的内存镜像:面板关掉再打开不必重新 parse localStorage(也躲开 24h 过期判定)。
|
|
1382
|
+
let connectorCatalogMemo = null;
|
|
1383
|
+
function readConnectorSnapshot() {
|
|
1384
|
+
if (!MARKET_PERF.connectorSnapshot) return null;
|
|
1385
|
+
try {
|
|
1386
|
+
const raw = localStorage.getItem(CONNECTOR_SNAPSHOT_KEY);
|
|
1387
|
+
if (!raw) return null;
|
|
1388
|
+
const parsed = JSON.parse(raw);
|
|
1389
|
+
if (!parsed || !Array.isArray(parsed.connectors) || !parsed.connectors.length) return null;
|
|
1390
|
+
const savedAt = Number(parsed.savedAt || 0);
|
|
1391
|
+
if (!savedAt || Date.now() - savedAt > CONNECTOR_SNAPSHOT_MAX_AGE) return null;
|
|
1392
|
+
return parsed;
|
|
1393
|
+
} catch { return null; }
|
|
1394
|
+
}
|
|
1395
|
+
function writeConnectorSnapshot(payload) {
|
|
1396
|
+
if (!MARKET_PERF.connectorSnapshot) return;
|
|
1397
|
+
try {
|
|
1398
|
+
if (!payload || !Array.isArray(payload.connectors) || !payload.connectors.length) return;
|
|
1399
|
+
const text = JSON.stringify({
|
|
1400
|
+
savedAt: Date.now(),
|
|
1401
|
+
source: payload.source || '',
|
|
1402
|
+
connectors: payload.connectors,
|
|
1403
|
+
});
|
|
1404
|
+
if (text.length > CONNECTOR_SNAPSHOT_MAX_BYTES) return;
|
|
1405
|
+
let otherBytes = 0;
|
|
1406
|
+
try { otherBytes = (localStorage.getItem(AWESOME_SNAPSHOT_KEY) || '').length; } catch { otherBytes = 0; }
|
|
1407
|
+
if (otherBytes + text.length > SNAPSHOT_BUDGET_BYTES) return;
|
|
1408
|
+
localStorage.setItem(CONNECTOR_SNAPSHOT_KEY, text);
|
|
1409
|
+
} catch { /* 配额溢出 / 隐私模式:快照只是加速,失败不致命 */ }
|
|
1410
|
+
}
|
|
1229
1411
|
// ── 更新探测节流:把「每次进面板都 HEAD 一发」压到 6 小时一次 ────────────────
|
|
1230
1412
|
// 目录本身已有服务端 stale-while-revalidate 在后台自动换新,这个 ETag 探测只是
|
|
1231
1413
|
// 锦上添花;没必要每开一次面板就多打一次网络、多一次误报机会。
|
|
@@ -1845,7 +2027,7 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
1845
2027
|
.dsh-mcp-item-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
1846
2028
|
.dsh-mcp-item-name{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:.1px}
|
|
1847
2029
|
.dsh-mcp-item-sub{font-size:10.5px;color:var(--mcp-mut);display:flex;gap:6px;align-items:center}
|
|
1848
|
-
.dsh-mcp-badge{display:inline-flex;align-items:center;padding:1.5px 7px;border-radius:999px;font-size:10.5px;font-weight:500;line-height:16px;border:1px solid var(--mcp-line);color:var(--mcp-mut);background:var(--mcp-bg-1);corner-shape:round}
|
|
2030
|
+
.dsh-mcp-badge{display:inline-flex;align-items:center;padding:1.5px 7px;border-radius:999px;font-size:10.5px;font-weight:500;line-height:16px;border:1px solid var(--mcp-line);color:var(--mcp-mut);background:var(--mcp-bg-1);corner-shape:round;white-space:nowrap}
|
|
1849
2031
|
.dsh-mcp-badge.dot::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor;margin-right:5px;opacity:.85;corner-shape:round}
|
|
1850
2032
|
/* §1.3 色语:橙色的名额只留给「需注意」(不兼容 / 缺参数 / 有死工具)。
|
|
1851
2033
|
传输类型是描述值、不是状态,此前 stdio 独占橙底会让人误读成「这台有问题」。
|
|
@@ -1883,6 +2065,9 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
1883
2065
|
.dsh-mcp-tool-desc{font-size:11.5px;color:var(--mcp-mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1884
2066
|
.dsh-mcp-empty-list{padding:20px;text-align:center;color:var(--mcp-mut);font-size:12px}
|
|
1885
2067
|
.dsh-mcp-overlay{position:fixed;inset:0;background:var(--dsw-alias-bg-overlay);display:flex;align-items:center;justify-content:center;z-index:200;padding:12px;animation:mcpFade .14s ease-out}
|
|
2068
|
+
/* 令牌 / 授权弹层可能从详情弹层里打开:DOM 上排在详情之后仍会被同 z-index 的兄弟盖住,
|
|
2069
|
+
单独抬到 210,保证用户点「我已有令牌」后弹窗真的在最前面(命令面板 320、toast 300 不动)。 */
|
|
2070
|
+
.dsh-mcp-overlay.dsh-mcp-front{z-index:210}
|
|
1886
2071
|
.dsh-mcp-whatsnew{box-sizing:border-box;width:min(460px,92vw);background:var(--mcp-bg-2);border:0;border-radius:var(--mcp-r-panel);padding:18px;box-shadow:var(--dsw-elevation-prominent);animation:mcpPop .18s cubic-bezier(.2,.9,.3,1.15)}
|
|
1887
2072
|
.dsh-mcp-whatsnew-head{display:flex;flex-direction:column;gap:2px;margin-bottom:12px}
|
|
1888
2073
|
.dsh-mcp-whatsnew-head b{font-size:15px;font-weight:650}
|
|
@@ -1924,6 +2109,13 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
1924
2109
|
.dsh-mcp-wb-tab:hover{border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
|
|
1925
2110
|
.dsh-mcp-wb-tab.sel{border-color:var(--mcp-accent);color:var(--dsw-alias-label-primary);font-weight:600}
|
|
1926
2111
|
.dsh-mcp-wb-disclaim{font-size:11px;line-height:1.55;color:var(--mcp-mut)}
|
|
2112
|
+
/* 连接器来源切换行:左边分段控件,右侧留白(社区未审核声明另起一行常驻) */
|
|
2113
|
+
.dsh-mcp-wb-feedbar{justify-content:space-between;gap:8px}
|
|
2114
|
+
/* 社区未审核声明:warn 配色,常驻不可折叠 */
|
|
2115
|
+
.dsh-mcp-wb-note.warn{color:var(--mcp-warn);border-color:var(--mcp-warn);background:var(--dsw-alias-state-warn-tertiary)}
|
|
2116
|
+
/* 社区游标翻页:居中一行「上一页 / 第 N 页 / 下一页」,列表不被按钮撑开 */
|
|
2117
|
+
.dsh-mcp-wb-more{display:flex;justify-content:center;align-items:center;padding:12px 0 4px;flex:none}
|
|
2118
|
+
.dsh-mcp-wb-card-actions a.dsh-mcp-btn{text-decoration:none}
|
|
1927
2119
|
.dsh-mcp-wb-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
1928
2120
|
.dsh-mcp-wb-toolbar .dsh-mcp-check{margin:0!important;font-size:12px}
|
|
1929
2121
|
.dsh-mcp-wb-toolbar input[type='search']{flex:1 1 220px;min-width:160px;box-sizing:border-box}
|
|
@@ -1960,6 +2152,10 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
1960
2152
|
.dsh-mcp-card.wb{display:flex;flex-direction:column;gap:7px;padding:11px 12px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);min-width:0}
|
|
1961
2153
|
.dsh-mcp-card.wb.off{opacity:.82}
|
|
1962
2154
|
.dsh-mcp-card.wb .dsh-mcp-card-summary{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:11.5px;color:var(--mcp-mut);line-height:1.5;min-height:2.9em}
|
|
2155
|
+
/* 社区卡片整卡可点开详情:只有带 .clickable 的卡获得手型与焦点环,WB / 技能卡保持原样。 */
|
|
2156
|
+
.dsh-mcp-card.wb.clickable{cursor:pointer;transition:border-color .15s,box-shadow .15s,background .15s}
|
|
2157
|
+
.dsh-mcp-card.wb.clickable:hover{border-color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover)}
|
|
2158
|
+
.dsh-mcp-card.wb.clickable:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:2px}
|
|
1963
2159
|
.dsh-mcp-wb-card-actions{display:flex;align-items:center;gap:8px;margin-top:auto;flex-wrap:wrap}
|
|
1964
2160
|
/* ── DSH 社区插件卡片:五条固定水平线(头部 34 / 摘要 38 / 标签 18 / 页脚 38)──
|
|
1965
2161
|
只作用于 .dsh-mcp-pcard,连接器 / 技能 / 专家卡片不受影响。
|
|
@@ -2030,12 +2226,15 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2030
2226
|
.dsh-mcp-wb-tokenfield input:focus{border-color:var(--mcp-accent)}
|
|
2031
2227
|
.dsh-mcp-wb-tokenfield input::placeholder{color:var(--mcp-mut)}
|
|
2032
2228
|
.dsh-mcp-wb-examples ul{margin:4px 0 0;padding-left:18px}
|
|
2229
|
+
/* 详情里的 command / args / 远程地址:等宽、可断行,绝不横向撑破弹层。 */
|
|
2230
|
+
.dsh-mcp-cmdline{margin:5px 0 0;font-family:var(--mcp-mono);font-size:11.5px;line-height:1.6;color:var(--dsw-alias-label-primary);white-space:pre-wrap;overflow-wrap:anywhere}
|
|
2033
2231
|
.dsh-mcp-wb-roster{list-style:none;margin:6px 0 0;padding:0;display:flex;flex-direction:column;gap:5px}
|
|
2034
2232
|
.dsh-mcp-wb-roster li{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;font-size:12px;line-height:1.5;padding:5px 9px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-sm);background:var(--mcp-bg-2)}
|
|
2035
2233
|
.dsh-mcp-wb-roster b{font-weight:650;min-width:0;overflow-wrap:anywhere}
|
|
2036
2234
|
.dsh-mcp-wb-roster-role{font-size:11px;color:var(--dsw-alias-brand-text);flex:none}
|
|
2037
2235
|
.dsh-mcp-wb-roster-prof{font-size:11px;color:var(--mcp-mut);flex:none}
|
|
2038
2236
|
.dsh-mcp-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;flex-wrap:wrap}
|
|
2237
|
+
.dsh-mcp-modal-actions a.dsh-mcp-btn{text-decoration:none}
|
|
2039
2238
|
.dsh-mcp-advanced{margin-top:14px}.dsh-mcp-check{display:flex!important;align-items:center;gap:8px;margin:9px 0!important;color:var(--dsw-alias-label-primary)!important}.dsh-mcp-check input{width:auto!important}.dsh-mcp-number-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dsh-mcp-number-grid .dsh-mcp-field{margin-bottom:0}.dsh-mcp-import-preview{font-size:12px;line-height:1.6;color:var(--mcp-mut);white-space:pre-wrap}.dsh-mcp-import-json{min-height:220px;resize:vertical;font-family:var(--mcp-mono)!important}
|
|
2040
2239
|
.dsh-mcp-builtin-count{font-size:11.5px;color:var(--mcp-mut)}
|
|
2041
2240
|
.dsh-mcp-source-box{margin:0 0 14px;flex:none}.dsh-mcp-source-list{display:flex;flex-direction:column;gap:6px;max-height:186px;overflow-y:auto;margin-top:7px}.dsh-mcp-source-row{box-sizing:border-box;display:flex;flex-direction:column;gap:3px;width:100%;padding:8px 10px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;line-height:1.45;text-align:left;cursor:pointer;transition:border-color .12s,background .12s}.dsh-mcp-source-row:hover:not(:disabled){border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}.dsh-mcp-source-row.sel{border-color:var(--mcp-accent);background:var(--mcp-bg-2)}.dsh-mcp-source-row:disabled{cursor:default;opacity:.7}.dsh-mcp-source-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}.dsh-mcp-source-name{font-weight:600}.dsh-mcp-source-path{font-family:var(--mcp-mono);font-size:10.5px;color:var(--dsw-alias-brand-text);overflow-wrap:anywhere}.dsh-mcp-source-detail{font-size:11px;color:var(--mcp-mut);overflow-wrap:anywhere}.dsh-mcp-source-note{font-size:10.5px;color:var(--mcp-mut)}.dsh-mcp-source-warn{font-size:10.5px;color:var(--mcp-warn)}
|
|
@@ -2295,7 +2494,7 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2295
2494
|
.dsh-mcp-skill-name{font-weight:500;color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;word-break:break-word}
|
|
2296
2495
|
.dsh-mcp-skill-sub{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:3px}
|
|
2297
2496
|
.dsh-mcp-skill-src{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;font-family:var(--mcp-mono)}
|
|
2298
|
-
.dsh-mcp-skill-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:17px;margin-top:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
2497
|
+
.dsh-mcp-skill-desc{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:17px;margin-top:4px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.dsh-mcp-skill-note{color:var(--mcp-mut);font-size:11px;line-height:15px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
|
2299
2498
|
.dsh-mcp-skill-detail{flex:1 1 auto;min-width:0;min-height:0;border:.5px solid var(--mcp-line);border-radius:var(--mcp-r-lg);corner-shape:round;background:var(--mcp-bg-1);display:flex;flex-direction:column;overflow:hidden}
|
|
2300
2499
|
.dsh-mcp-skill-d-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:14px 16px 10px}
|
|
2301
2500
|
.dsh-mcp-skill-d-title{margin:0;font-size:15px;font-weight:500;color:var(--dsw-alias-label-primary);line-height:20px;word-break:break-word}
|
|
@@ -2381,7 +2580,7 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2381
2580
|
.dsh-mcp-skill-d-flash.err{background:var(--dsw-alias-state-danger-bg,var(--mcp-bg-2));color:var(--dsw-alias-state-danger-fg,var(--mcp-err))}
|
|
2382
2581
|
/* 主列画布:替代旧 340px 侧栏(旧 .dsh-mcp-skill-side 仍被 760px 媒体查询引用,保留无害)。 */
|
|
2383
2582
|
.dsh-mcp-skill-canvas{flex:1 1 auto;min-width:0;min-height:0;display:flex;flex-direction:column;gap:10px}
|
|
2384
|
-
.dsh-mcp-skill-card-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0}
|
|
2583
|
+
.dsh-mcp-skill-card-foot{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;min-width:0}.dsh-mcp-skill-card-foot .dsh-mcp-skill-sub{margin-left:auto}
|
|
2385
2584
|
.dsh-mcp-skill-card-foot .dsh-mcp-skill-src{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
2386
2585
|
/* 详情栏 sticky(决策 3):宿主页面是滚动容器,sticky 生效;被 760px 以下布局覆盖时自动失效。 */
|
|
2387
2586
|
.dsh-mcp-skill-detail{align-self:flex-start;position:sticky;top:16px;max-height:calc(100vh - 48px)}
|
|
@@ -2787,6 +2986,18 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2787
2986
|
.dsh-mcp-tailor-row.head .t-name{background:var(--mcp-bg-2)}
|
|
2788
2987
|
.dsh-mcp-tailor-row:not(.head):hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
2789
2988
|
.dsh-mcp-tailor-row.dead:not(.head):hover{background:color-mix(in srgb,var(--mcp-warn) 12%,transparent)}
|
|
2989
|
+
/* ── §7.1 技能行行内禁用/释放按钮 ─────────────────────────────────────────── */
|
|
2990
|
+
/* 技能行尾列放的是文字按钮(禁用/释放),比工具行的 24px ⋯ 菜单宽:只对技能表放宽
|
|
2991
|
+
尾列(.skills 修饰类),工具表保持 30px 不动。 */
|
|
2992
|
+
.dsh-mcp-tailor-table.skills .dsh-mcp-tailor-row{grid-template-columns:minmax(180px,1.6fr) 88px 118px 96px minmax(96px,.8fr) 52px}
|
|
2993
|
+
.dsh-mcp-tailor-op{box-sizing:border-box;white-space:nowrap;padding:3px 9px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-sm);background:var(--mcp-bg-2);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;line-height:16px;cursor:pointer;transition:color .12s,border-color .12s,background .12s}
|
|
2994
|
+
.dsh-mcp-tailor-op:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}
|
|
2995
|
+
.dsh-mcp-tailor-op:disabled{opacity:.55;cursor:default}
|
|
2996
|
+
.dsh-mcp-tailor-op.release{color:var(--dsw-alias-state-success-primary)}
|
|
2997
|
+
.dsh-mcp-tailor-op.release:hover:not(:disabled){border-color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-interactive-bg-hover)}
|
|
2998
|
+
.dsh-mcp-tailor-op:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
|
|
2999
|
+
/* 禁用行:名字弱化,与 .dead 的「零调用」底色语义分开(禁用是主动态,不是数据缺失)。 */
|
|
3000
|
+
.dsh-mcp-tailor-row.off:not(.head) .t-name{color:var(--dsw-alias-label-tertiary)}
|
|
2790
3001
|
/* sparkline:7 段柱状,高度按窗口内分布归一,纯装饰不可交互 */
|
|
2791
3002
|
.dsh-mcp-spark{display:inline-flex;align-items:flex-end;gap:1.5px;height:14px;flex:none;vertical-align:-2px}
|
|
2792
3003
|
.dsh-mcp-spark>i{display:block;width:2.5px;min-height:1.5px;border-radius:.5px;background:var(--dsw-alias-label-tertiary);opacity:.55}
|
|
@@ -2858,8 +3069,10 @@ button.dsh-mcp-chip-s.on .dsh-mcp-chip-s-count{color:var(--mcp-accent)}
|
|
|
2858
3069
|
.dsh-mcp-mk-crumb-root{color:var(--dsw-alias-label-secondary)}
|
|
2859
3070
|
.dsh-mcp-mk-crumb-sep{opacity:.55}
|
|
2860
3071
|
.dsh-mcp-mk-crumb>b{font-weight:600;color:var(--dsw-alias-label-primary);font-size:11.5px}
|
|
2861
|
-
.dsh-mcp-mk-topright{display:flex;align-items:center;gap:10px;flex:
|
|
3072
|
+
.dsh-mcp-mk-topright{display:flex;align-items:center;gap:10px;flex:0 1 auto;min-width:0;margin-left:auto}
|
|
2862
3073
|
.dsh-mcp-mk-topright .dsh-mcp-mk-crumb{margin-left:0;padding-left:0}
|
|
3074
|
+
/* 子页签行右侧的免责声明:单行、可截断(完整口径在 title 里),窄屏先让位给页签与入口 */
|
|
3075
|
+
.dsh-mcp-mk-note{flex:0 1 auto;min-width:0;max-width:520px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary);cursor:help}
|
|
2863
3076
|
.dsh-mcp-loadout-open{border:1px solid var(--mcp-line);background:transparent;color:var(--mcp-mut);border-radius:999px;padding:4px 10px;font-size:11px;line-height:17px;cursor:pointer;white-space:nowrap}
|
|
2864
3077
|
.dsh-mcp-loadout-open:hover{border-color:var(--mcp-accent);color:var(--dsw-alias-label-primary)}
|
|
2865
3078
|
.dsh-mcp-loadout-modal{width:min(780px,94vw);height:min(720px,88vh);max-height:88vh;display:flex;flex-direction:column;padding:18px 20px;overflow:hidden}
|
|
@@ -2889,6 +3102,9 @@ button.dsh-mcp-chip-s.on .dsh-mcp-chip-s-count{color:var(--mcp-accent)}
|
|
|
2889
3102
|
@media (max-width:480px){.dsh-mcp-loadout-modal{width:100%;height:calc(100vh - 24px);max-height:none;padding:14px}.dsh-mcp-loadout-row{flex-direction:column}.dsh-mcp-loadout-row-actions{width:100%;justify-content:flex-start}}
|
|
2890
3103
|
/* 窄屏页签行放不下 5 个页签 + 面包屑:页签优先,面包屑让位(页签本身已标明当前位置)。 */
|
|
2891
3104
|
@media (max-width:480px){.dsh-mcp-mk-crumb{display:none}}
|
|
3105
|
+
/* 免责声明在窄屏让位:先缩到一句被截断也读不出信息,再窄干脆收起(要点已在 title 里)。 */
|
|
3106
|
+
@media (max-width:900px){.dsh-mcp-mk-note{max-width:220px}}
|
|
3107
|
+
@media (max-width:760px){.dsh-mcp-mk-note{display:none}}
|
|
2892
3108
|
.dsh-mcp-mk-subtabs-idle{flex:none;padding:7px 10px 8px;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;line-height:19px;white-space:nowrap;cursor:pointer;opacity:.8;transition:color .13s,opacity .13s}
|
|
2893
3109
|
.dsh-mcp-mk-subtabs-idle:hover{opacity:1;color:var(--dsw-alias-label-secondary);text-decoration:underline;text-underline-offset:2px}
|
|
2894
3110
|
.dsh-mcp-mk-subtabs-idle:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-2px}
|
|
@@ -2916,13 +3132,6 @@ button.dsh-mcp-chip-s.on .dsh-mcp-chip-s-count{color:var(--mcp-accent)}
|
|
|
2916
3132
|
.dsh-mcp-card.wb.blocked:hover{opacity:1}
|
|
2917
3133
|
.dsh-mcp-mk-corner{position:absolute;top:-1px;right:-1px;z-index:2;padding:1px 7px;border-radius:0 var(--mcp-r-md) 0 var(--mcp-r-md);background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary);font-size:10px;font-weight:600;line-height:16px;white-space:nowrap}
|
|
2918
3134
|
.dsh-mcp-card.wb{position:relative}
|
|
2919
|
-
/* 免责声明:从占屏一段话收成一行可展开 */
|
|
2920
|
-
.dsh-mcp-mk-disclaim{flex:none;font-size:11px;line-height:1.55;color:var(--dsw-alias-label-tertiary)}
|
|
2921
|
-
.dsh-mcp-mk-disclaim summary{cursor:pointer;font-size:11px;color:var(--dsw-alias-label-tertiary);list-style:none;display:inline-flex;align-items:center;gap:5px}
|
|
2922
|
-
.dsh-mcp-mk-disclaim summary::-webkit-details-marker{display:none}
|
|
2923
|
-
.dsh-mcp-mk-disclaim summary::before{content:"▸";font-size:9px;transition:transform .13s}
|
|
2924
|
-
.dsh-mcp-mk-disclaim[open] summary::before{transform:rotate(90deg)}
|
|
2925
|
-
.dsh-mcp-mk-disclaim p{margin:5px 0 0;color:var(--dsw-alias-label-tertiary)}
|
|
2926
3135
|
|
|
2927
3136
|
/* ── 骨架屏(OPT-25 · 6.6 三处并行初始加载的占位)──────────────────────
|
|
2928
3137
|
容器与条只用既有主题令牌 + 中性 rgba,暗/亮主题都不需要额外分支。
|
|
@@ -3724,7 +3933,11 @@ const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onTogg
|
|
|
3724
3933
|
h('span', null, s.scope === 'workspace' ? wsSub(s) : s.toolCount + t(' 工具')),
|
|
3725
3934
|
heavy ? h('span', { className: 'dsh-mcp-tag-heavy', title: t('工具数 ≥ {0}:进详情默认折叠工具清单', [HEAVY_TOOL_COUNT]) }, t('大体量')) : null,
|
|
3726
3935
|
// 失败原因就地给一个词,不逼用户点进去看日志才知道哪里坏了。
|
|
3727
|
-
|
|
3936
|
+
// 401/403 类失败(host 侧 authRequired)单独说「需授权」:重连连不回来,
|
|
3937
|
+
// 该做的是重新授权,这条区别以前被并进「连接失败」里了。
|
|
3938
|
+
failed && !mountFailed(s) && !scopeFailed(s)
|
|
3939
|
+
? h(Badge, { cls: 's-err', title: s.authRequired ? t('服务器拒绝当前凭据(401/403 类):重连不会恢复,需要重新授权') : undefined }, s.authRequired ? t('需授权') : t('连接失败'))
|
|
3940
|
+
: null,
|
|
3728
3941
|
mountFailed(s) ? h(Badge, { cls: 's-err' }, t('挂载失败')) : null,
|
|
3729
3942
|
scopeFailed(s) ? h(Badge, { cls: 's-err' }, t('作用域故障')) : null,
|
|
3730
3943
|
),
|
|
@@ -3915,8 +4128,8 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
|
|
|
3915
4128
|
// 这里只说**连接**那一半事实("在不在");"几个会话在用"由详情页的连接信息与列表行的
|
|
3916
4129
|
// 副文案给出——两者刻意不合并,见 wsSub 的注释。
|
|
3917
4130
|
// stopped 在项目语境里意为「这条共享连接尚未建立」(新会话挂载时才建)。
|
|
3918
|
-
? (status === 'connected' ? t('已连接') : status === 'failed' ? t('连接失败') : status === 'unknown' ? t('状态未知') : status === 'loading' ? t('连接中…') : t('尚未建立连接(新会话自动挂载)'))
|
|
3919
|
-
: status === 'connected' ? t('已连接') : status === 'failed' ? t('连接失败') : status === 'unknown' ? t('状态未知') : status === 'loading' || status === 'waiting' ? t('连接中…') : t('已停止');
|
|
4131
|
+
? (status === 'connected' ? t('已连接') : status === 'failed' ? (server.authRequired ? t('需授权') : t('连接失败')) : status === 'unknown' ? t('状态未知') : status === 'loading' ? t('连接中…') : t('尚未建立连接(新会话自动挂载)'))
|
|
4132
|
+
: status === 'connected' ? t('已连接') : status === 'failed' ? (server.authRequired ? t('需授权') : t('连接失败')) : status === 'unknown' ? t('状态未知') : status === 'loading' || status === 'waiting' ? t('连接中…') : t('已停止');
|
|
3920
4133
|
const mut = (v) => {
|
|
3921
4134
|
const visible = displayValue(v);
|
|
3922
4135
|
return typeof visible === 'string' && visible.length > 90 ? visible.slice(0, 88) + '…' : visible;
|
|
@@ -4048,6 +4261,10 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
|
|
|
4048
4261
|
// 把它摆成原因;成功时不刷屏,更不把一条好连接说成冲突。
|
|
4049
4262
|
isWorkspace && server.duplicateOwners?.length && status === 'failed' ? h('div', { className: 'dsh-mcp-log warn' }, t('以下项目也定义了同名 serverName:') + server.duplicateOwners.join(t('、')) + t('。DSH 0.1.5 起各注册作用域可独立使用同名;更早的版本按全进程唯一判定,同名会导致其中一份启动失败。')) : null,
|
|
4050
4263
|
server.lastError ? h('div', { className: 'dsh-mcp-log', title: t('来自 mcp-client 最近日志') }, server.lastError) : null,
|
|
4264
|
+
// 401/403 类的失败重连多少次都没用:直接把用户指到授权入口,别让他反复点「重连」。
|
|
4265
|
+
server.authRequired
|
|
4266
|
+
? h('div', { className: 'dsh-mcp-log warn' }, t('这台服务器拒绝当前凭据(401/403 类),重连不会恢复:请到「市场 → 连接器」打开对应连接器点「重新授权」。由 WorkBuddy 账号代注入凭据的连接器(如 ima)DSH 无法代办。'))
|
|
4267
|
+
: null,
|
|
4051
4268
|
server.toolCountAmbiguous ? h('div', { className: 'dsh-mcp-log warn' }, t('工具命名与其他 serverName 前缀重叠,无法可靠判断归属;请避免在 serverName 中使用双下划线。')) : null,
|
|
4052
4269
|
// §2.4 连接健康条:原来「连没连上、几个工具、几个会话」埋在两行徽章里,
|
|
4053
4270
|
// 而且要滚到「连接信息」才知道传输方式。这里把四个最常被问到的结论提到最上层:
|
|
@@ -4126,7 +4343,7 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
|
|
|
4126
4343
|
};
|
|
4127
4344
|
|
|
4128
4345
|
// ── 新增 / 编辑服务器弹窗:表单校验与 secret 字段遮蔽 ────────────────────────────
|
|
4129
|
-
const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, revealEpoch, scopeName }) => {
|
|
4346
|
+
const AddEditModal = ({ open, onClose, onSubmit, busy, initial, draft, onRevealValue, revealEpoch, scopeName }) => {
|
|
4130
4347
|
const dialogRef = useRef(null);
|
|
4131
4348
|
const [name, setName] = useState('');
|
|
4132
4349
|
const [transport, setTransport] = useState('http');
|
|
@@ -4146,7 +4363,9 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
|
|
|
4146
4363
|
const [revealBusy, setRevealBusy] = useState('');
|
|
4147
4364
|
const [editRevealed, setEditRevealed] = useState({});
|
|
4148
4365
|
const editRevealGeneration = useRef(0);
|
|
4149
|
-
|
|
4366
|
+
// draft:预填草稿(市场社区条目)。表单吃 initial 的预填,但提交必须走「新增」,
|
|
4367
|
+
// 名称也可改——否则 AddEditModal 会把它当成一次 update,去改一条并不存在的服务器。
|
|
4368
|
+
const editing = !!initial && !draft;
|
|
4150
4369
|
const applyPreset = (id) => {
|
|
4151
4370
|
const preset = MCP_PRESETS.find((p) => p.id === id);
|
|
4152
4371
|
if (!preset) return;
|
|
@@ -4336,6 +4555,16 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
|
|
|
4336
4555
|
}),
|
|
4337
4556
|
h('div', { className: 'dsh-mcp-kv-add' }, h(Btn, { onClick: () => addRow(kvRows, setKvRows), children: t('+ 添加') })),
|
|
4338
4557
|
),
|
|
4558
|
+
// 草稿里的必填环境变量为空:只提示、不阻塞——有些值允许运行时兜底,删不删由用户决定。
|
|
4559
|
+
draft && initial && Array.isArray(initial.requiredEnv) && initial.requiredEnv.length
|
|
4560
|
+
? (() => {
|
|
4561
|
+
const missing = initial.requiredEnv.filter((key) => !envs.some((row) => row.k === key && String(row.v || '').trim()));
|
|
4562
|
+
return missing.length
|
|
4563
|
+
? h('div', { className: 'dsh-mcp-wb-note warn', role: 'note' },
|
|
4564
|
+
t('有 {0} 个必填环境变量为空,服务器可能启动失败:{1}', [missing.length, missing.join(' / ')]))
|
|
4565
|
+
: null;
|
|
4566
|
+
})()
|
|
4567
|
+
: null,
|
|
4339
4568
|
h('div', { className: 'dsh-mcp-card dsh-mcp-advanced' },
|
|
4340
4569
|
h('h4', null, t('运行策略')),
|
|
4341
4570
|
h('div', { className: 'dsh-mcp-field' }, h('label', null, t('工具调用超时(毫秒)')), h('input', { type: 'number', min: 1, value: toolCallTimeoutMs, onChange: (e) => setToolCallTimeoutMs(e.target.value) })),
|
|
@@ -4590,6 +4819,19 @@ const fallbackTimer = {
|
|
|
4590
4819
|
return () => clearInterval(id);
|
|
4591
4820
|
},
|
|
4592
4821
|
};
|
|
4822
|
+
// P0-3 / P1-1:把「不着急的后台工作」挪到浏览器空闲帧。会话头 / 首屏渲染期间主线程
|
|
4823
|
+
// 本来就在忙,空闲回调能把这类 RPC 与其后的 setState 推到渲染之后。
|
|
4824
|
+
// requestIdleCallback 不可用时退回 setTimeout(0);两者都返回取消函数,调用方统一在
|
|
4825
|
+
// effect 清理时调用(组件卸载后不再发请求)。
|
|
4826
|
+
function scheduleIdle(fn, timeoutMs = 2000) {
|
|
4827
|
+
const win = typeof window !== 'undefined' ? window : null;
|
|
4828
|
+
if (win && typeof win.requestIdleCallback === 'function') {
|
|
4829
|
+
const id = win.requestIdleCallback(() => fn(), { timeout: timeoutMs });
|
|
4830
|
+
return () => { try { win.cancelIdleCallback(id); } catch { /* 宿主已卸载 */ } };
|
|
4831
|
+
}
|
|
4832
|
+
const id = setTimeout(fn, 0);
|
|
4833
|
+
return () => clearTimeout(id);
|
|
4834
|
+
}
|
|
4593
4835
|
function startVisibilityAwarePolling(doc, timer, intervalMs, run) {
|
|
4594
4836
|
const visible = () => !doc || doc.visibilityState !== 'hidden';
|
|
4595
4837
|
const host = timer && typeof timer.interval === 'function' ? timer : fallbackTimer;
|
|
@@ -5422,6 +5664,13 @@ const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || t
|
|
|
5422
5664
|
const SKILL_REMOVABLE_SOURCES = new Set(['user-agents', 'user-dsh', 'project-agents', 'project-dsh']);
|
|
5423
5665
|
const skillRemovable = (source) => SKILL_REMOVABLE_SOURCES.has(String(source || ''));
|
|
5424
5666
|
|
|
5667
|
+
// 可禁用来源白名单:与宿主 lib/skill-disable.js 同集(两边的安全闸门同一套理由——
|
|
5668
|
+
// 没有可信根目录做包含性校验的来源不给写入口)。工具裁缝技能行的「禁用 / 释放」按钮
|
|
5669
|
+
// 只对这里的来源出现;宿主侧 setSkillDisabled 会拿着注册表解析出的真实路径二次校验。
|
|
5670
|
+
const SKILL_DISABLE_SOURCES = SKILL_REMOVABLE_SOURCES;
|
|
5671
|
+
const skillDisableActionable = (row) =>
|
|
5672
|
+
row.registered === true && SKILL_DISABLE_SOURCES.has(String(row.source || ''));
|
|
5673
|
+
|
|
5425
5674
|
// ── Skill 分类升级(决策 6/10):6 类 + 未分类兜底,两段式强/弱特征词匹配 ─────────
|
|
5426
5675
|
// 为什么两段式:中英混排描述里 code/image/frontend 这类词跨类污染严重,单层表会把
|
|
5427
5676
|
// imagegen-frontend-* 误判成「编程开发」。先按 SKILL_CAT_MATCH_ORDER 整体扫强特征词
|
|
@@ -5539,11 +5788,13 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5539
5788
|
// 只有用户切窗口(7/90 天)或点「重扫」时才真的重拉。
|
|
5540
5789
|
const firstResolveRef = useRef(false);
|
|
5541
5790
|
|
|
5542
|
-
const load = useCallback(async () => {
|
|
5791
|
+
const load = useCallback(async (force = false) => {
|
|
5543
5792
|
setBusy(true);
|
|
5544
5793
|
setError('');
|
|
5545
5794
|
try {
|
|
5546
|
-
|
|
5795
|
+
// P2-1:宿主侧 5 分钟缓存。普通挂载 / 切窗口吃缓存;「重扫」与动作后的刷新
|
|
5796
|
+
// 必须带 refresh:true —— 禁用/启用会改变注册表,吃缓存等于看旧账。
|
|
5797
|
+
const res = await call('toolTailor', { days, ...(force ? { refresh: true } : {}) });
|
|
5547
5798
|
if (res && res.ok) {
|
|
5548
5799
|
commitReport(res);
|
|
5549
5800
|
setActionError('');
|
|
@@ -5594,7 +5845,7 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5594
5845
|
flash(String((e && e.message) || e), 'err');
|
|
5595
5846
|
} finally {
|
|
5596
5847
|
setBusy(false);
|
|
5597
|
-
timer.timeout(load, 300);
|
|
5848
|
+
timer.timeout(() => load(true), 300);
|
|
5598
5849
|
}
|
|
5599
5850
|
};
|
|
5600
5851
|
|
|
@@ -5634,23 +5885,31 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5634
5885
|
: t('技能账本:窗口内共 {0} 次 skill 调用;注册表不可用,只列出日志里出现过的 {1} 个', [fmtInt(counted), fmtInt(skillRows.length)]);
|
|
5635
5886
|
return h('div', null,
|
|
5636
5887
|
h('div', { className: 'dsh-mcp-tailor-note' }, h('b', null, t('技能(skill 工具)')), summary),
|
|
5637
|
-
h('div', { className: 'dsh-mcp-tailor-table' },
|
|
5888
|
+
h('div', { className: 'dsh-mcp-tailor-table skills' },
|
|
5638
5889
|
h('div', { className: 'dsh-mcp-tailor-row head' },
|
|
5639
5890
|
h('span', { className: 't-name' }, t('技能')),
|
|
5640
5891
|
h('span', { className: 't-num' }, t('调用')),
|
|
5641
5892
|
h('span', { className: 't-num' }, t('错误 (率)')),
|
|
5642
5893
|
h('span', { className: 't-tax' }),
|
|
5643
5894
|
h('span', null, t('最后使用')),
|
|
5644
|
-
h('span', { className: 't-op' }),
|
|
5895
|
+
h('span', { className: 't-op', 'aria-label': t('操作') }),
|
|
5645
5896
|
),
|
|
5646
5897
|
skillRows.length === 0
|
|
5647
5898
|
? h('div', { className: 'dsh-mcp-tailor-empty' }, t('暂无技能调用'))
|
|
5648
5899
|
: skillRows.map((skill) => {
|
|
5649
5900
|
const isDead = skill.calls === 0;
|
|
5650
5901
|
const errRate = skill.errorRate || 0;
|
|
5651
|
-
|
|
5902
|
+
// §7.1 行内禁用/释放:写 SKILL.md frontmatter 的 disable-model-invocation
|
|
5903
|
+
// 标志(宿主官方键,监听器自动生效,无需重启)。禁用 = 模型不再加载该技能,
|
|
5904
|
+
// 不再缴它的 Schema 税;手动 /引用 不受影响。按钮只给「注册表在、来源在
|
|
5905
|
+
// 白名单」的行——bundled / runtime / custom 与已卸载技能的历史调用没有
|
|
5906
|
+
// 可信写入口,按「不伪造控件」的口径不出按钮。
|
|
5907
|
+
const actionable = skillDisableActionable(skill);
|
|
5908
|
+
const isOff = skill.disabled === true;
|
|
5909
|
+
return h('div', { key: skill.name, className: 'dsh-mcp-tailor-row' + (isDead ? ' dead' : '') + (isOff ? ' off' : '') },
|
|
5652
5910
|
h('span', { className: 't-name', title: skill.name },
|
|
5653
5911
|
skill.name,
|
|
5912
|
+
isOff ? h('span', { className: 't-badge warn' }, t('已禁用')) : null,
|
|
5654
5913
|
isDead ? h('span', { className: 't-badge' }, t('一次没被调用')) : null,
|
|
5655
5914
|
errRate >= 0.5 ? h('span', { className: 't-badge err' }, t('高错误率')) : null,
|
|
5656
5915
|
),
|
|
@@ -5659,7 +5918,17 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5659
5918
|
skill.errors > 0 ? fmtInt(skill.errors) + ' (' + fmtPct(errRate) + ')' : fmtInt(skill.errors)),
|
|
5660
5919
|
h('span', { className: 't-tax' }),
|
|
5661
5920
|
h('span', { className: 't-last' }, fmtLast(skill.lastUsedMs, report.generatedAt)),
|
|
5662
|
-
h('span', { className: 't-op' }
|
|
5921
|
+
h('span', { className: 't-op' },
|
|
5922
|
+
actionable
|
|
5923
|
+
? h('button', {
|
|
5924
|
+
type: 'button',
|
|
5925
|
+
className: 'dsh-mcp-tailor-op' + (isOff ? ' release' : ''),
|
|
5926
|
+
disabled: busy,
|
|
5927
|
+
title: isOff ? t('恢复模型调用该技能') : t('禁用后模型不再加载该技能,不再缴它的上下文税;手动 /引用 不受影响'),
|
|
5928
|
+
onClick: () => tailorAct(isOff ? 'enableSkill' : 'disableSkill', { name: skill.name }, isOff ? t('已释放技能 {0}', [skill.name]) : t('已禁用技能 {0}', [skill.name])),
|
|
5929
|
+
}, isOff ? t('释放') : t('禁用'))
|
|
5930
|
+
: null,
|
|
5931
|
+
),
|
|
5663
5932
|
);
|
|
5664
5933
|
}),
|
|
5665
5934
|
),
|
|
@@ -5810,7 +6079,7 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5810
6079
|
h('button', { key: w.key, type: 'button', className: 'dsh-mcp-btn' + (windowKey === w.key ? ' primary' : ''), onClick: () => setWindowKey(w.key) }, w.label),
|
|
5811
6080
|
),
|
|
5812
6081
|
h('button', { type: 'button', className: 'dsh-mcp-btn' + (onlyDead ? ' primary' : ''), onClick: () => setOnlyDead((v) => !v), title: t('只看窗口内零调用的工具(默认关闭:默认显示全部工具与真实调用次数)') }, report ? t('只看零调用({0})', [tailorNum(deadToolCount, report)]) : t('只看零调用')),
|
|
5813
|
-
h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: load, disabled: busy }, t('重扫')),
|
|
6082
|
+
h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => load(true), disabled: busy }, t('重扫')),
|
|
5814
6083
|
h('span', { className: 'dsh-mcp-tailor-meta' }, metaText),
|
|
5815
6084
|
),
|
|
5816
6085
|
// 账本缓存读数(只读,动作已删;见 cacheLine 上方注释)。
|
|
@@ -6893,16 +7162,29 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
6893
7162
|
const label = days <= 30 ? t('装于 {0}', [fmtLast(at)]) : t('装于 {0}', [wbFormatDate(at)]);
|
|
6894
7163
|
return h(Badge, { cls: days <= 7 ? 's-on' : '', title: t('安装于 {0}', [wbFormatDate(at)]) }, label);
|
|
6895
7164
|
};
|
|
6896
|
-
//
|
|
6897
|
-
//
|
|
6898
|
-
|
|
7165
|
+
// 卡片备注行(描述下方小字,不改动 skill 实际名称):市场装的显示「市场原名」,
|
|
7166
|
+
// 其余磁盘技能显示安装目录名(namespace__slug 形态即它在市场里的标识)——
|
|
7167
|
+
// 目录名/原名与看板名一致时是冗余,不显示。原徽标方案挤在 foot 行且对无账本
|
|
7168
|
+
// 记录的技能(clawhub 等外部工具装的)没有数据,改为目录名兜底后覆盖所有技能。
|
|
7169
|
+
const skillNote = (s) => {
|
|
6899
7170
|
const info = installInfo.get(s.name);
|
|
6900
|
-
if (!info
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
+ (label ? ' ' +
|
|
6905
|
-
|
|
7171
|
+
if (!info) return null;
|
|
7172
|
+
if (info.marketName && info.marketName !== s.name) {
|
|
7173
|
+
const label = info.marketplace === 'workbuddy-skills' ? t('SkillHub') : (info.marketplace || '');
|
|
7174
|
+
return {
|
|
7175
|
+
text: t('市场原名:{0}', [info.marketName]) + (label ? ' · ' + label : ''),
|
|
7176
|
+
title: t('它在市场里叫「{0}」,安装后看板显示的是 SKILL.md 的 name 字段。', [info.marketName])
|
|
7177
|
+
+ (label ? ' ' + t('来源:{0}', [label]) : ''),
|
|
7178
|
+
};
|
|
7179
|
+
}
|
|
7180
|
+
const dirName = String(info.dir || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '';
|
|
7181
|
+
if (dirName && dirName !== s.name) {
|
|
7182
|
+
return {
|
|
7183
|
+
text: t('安装目录:{0}', [dirName]),
|
|
7184
|
+
title: t('这个 skill 的安装目录名,namespace__slug 形态即它在市场里的标识。'),
|
|
7185
|
+
};
|
|
7186
|
+
}
|
|
7187
|
+
return null;
|
|
6906
7188
|
};
|
|
6907
7189
|
// 「最近安装」视图行:只留拿得到安装时间的(bundled / runtime 没有落盘时间,不在此列),
|
|
6908
7190
|
// 按时间倒序。其余筛选(搜索 / 来源 / 权限 / 分类)照常叠加。
|
|
@@ -7286,6 +7568,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7286
7568
|
h('div', { className: 'dsh-mcp-skill-grid' },
|
|
7287
7569
|
cluster.rows.map((s) => {
|
|
7288
7570
|
const sCat = catOf.get(s.name) || SKILL_UNCAT;
|
|
7571
|
+
const note = skillNote(s);
|
|
7289
7572
|
return h('button', {
|
|
7290
7573
|
key: s.name,
|
|
7291
7574
|
type: 'button',
|
|
@@ -7306,10 +7589,10 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7306
7589
|
s.description
|
|
7307
7590
|
? h('div', { className: 'dsh-mcp-skill-desc' }, s.description)
|
|
7308
7591
|
: h('div', { className: 'dsh-mcp-skill-desc dsh-mcp-skill-desc-none' }, t('(无描述)')),
|
|
7592
|
+
note ? h('div', { className: 'dsh-mcp-skill-note', title: note.title }, note.text) : null,
|
|
7309
7593
|
h('div', { className: 'dsh-mcp-skill-card-foot' },
|
|
7310
7594
|
h('span', { className: 'dsh-mcp-skill-cat-tag' }, sCat.name),
|
|
7311
7595
|
skillTimeBadge(s),
|
|
7312
|
-
skillMarketBadge(s),
|
|
7313
7596
|
h('div', { className: 'dsh-mcp-skill-sub' },
|
|
7314
7597
|
s.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
|
|
7315
7598
|
s.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
|
|
@@ -7377,7 +7660,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7377
7660
|
type: 'button',
|
|
7378
7661
|
className: 'dsh-mcp-skill-d-path',
|
|
7379
7662
|
title: t('把 skill 所在目录交给宿主在资源管理器中打开(只读定位)'),
|
|
7380
|
-
onClick: () => { call('revealSkill', { path: summary.path }); },
|
|
7663
|
+
onClick: () => { call('revealSkill', { ...skillScopeArg(scope), name: summary.name, path: summary.path }); },
|
|
7381
7664
|
}, h(Icon, { d: ICONS.folder }), t('打开目录')) : null,
|
|
7382
7665
|
) : null,
|
|
7383
7666
|
summary ? h('div', { className: 'dsh-mcp-skill-d-body' },
|
|
@@ -7474,6 +7757,10 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7474
7757
|
// wb* RPC(浏览器不直连腾讯/SkillHub),失败文案保留 Host 给出的原因。
|
|
7475
7758
|
// 已安装标记:技能按 wbSkillInstalled 的目录记录——只在进入面板时取一次,装/卸后局部刷新。
|
|
7476
7759
|
const WB_MARKET_CONNECTOR_PAGE_SIZE = 60;
|
|
7760
|
+
// 社区 MCP(官方 Registry):单页 100 是服务端上限,也是翻页步长——页内容互斥,
|
|
7761
|
+
// 内存里始终只有一页,不再需要「累计上限」。与 lib/mcp-community.js 的
|
|
7762
|
+
// COMMUNITY_PAGE_LIMIT 同值(浏览器侧是独立产物,改一处记得另一处)。
|
|
7763
|
+
const COMMUNITY_BATCH_LIMIT = 100;
|
|
7477
7764
|
// T02/R2:visible_in 标签筛选语义与 lib/wb-market.js 的 matchesVisibleIn 一致——
|
|
7478
7765
|
// selected 为空 = 全过;行未标 = 不受该维度过滤;命中为「任一命中」(OR,UI 直观)。
|
|
7479
7766
|
// 放这里而不是组件内:纯数据判断,避免每次渲染重建闭包。
|
|
@@ -7851,6 +8138,9 @@ const ExpertInstalledBoard = ({ call }) => {
|
|
|
7851
8138
|
? h('div', { className: 'dsh-mcp-card-tags' },
|
|
7852
8139
|
detail.skills.map((skill) => h(Badge, { key: skill.name, cls: skill.local ? 's-on' : '' }, skill.name)))
|
|
7853
8140
|
: null,
|
|
8141
|
+
detail.personaSource === 'generated'
|
|
8142
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('人设正文是按专家介绍生成的替身,不是原作者设定;原文不可用。'))
|
|
8143
|
+
: null,
|
|
7854
8144
|
detail.persona
|
|
7855
8145
|
? h('div', { className: 'dsh-mcp-skill-d-sec' },
|
|
7856
8146
|
h('b', null, t('人设正文({0} 字符)', [detail.personaChars || detail.persona.length])),
|
|
@@ -8196,6 +8486,9 @@ const ExpertMarket = ({ call }) => {
|
|
|
8196
8486
|
: h(Badge, null, t('无捆绑技能')),
|
|
8197
8487
|
)
|
|
8198
8488
|
: null,
|
|
8489
|
+
detail.personaSource === 'generated'
|
|
8490
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('人设正文是按专家介绍生成的替身,不是原作者设定;原文不可用。'))
|
|
8491
|
+
: null,
|
|
8199
8492
|
detail.persona
|
|
8200
8493
|
? h('div', { className: 'dsh-mcp-skill-d-sec' },
|
|
8201
8494
|
h('b', null, t('人设正文({0} 字符,召唤后成为会话系统提示)', [detail.personaChars || detail.persona.length])),
|
|
@@ -8592,8 +8885,11 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
8592
8885
|
// systemPrompt 的「本会话作用域 section」注入(与 dsh-persona / dsh-subagent 同款
|
|
8593
8886
|
// deployment:persona-prefix 遮蔽机制),因此:
|
|
8594
8887
|
// - 不受 agent-preset/locked 限制 —— 会话已开过口也能挂、能换、能摘;
|
|
8595
|
-
// -
|
|
8888
|
+
// - 只影响这一个会话,不动工具目录、不动 profile 预设;
|
|
8596
8889
|
// - 反复点不同专家 = 直接替换(Host 先摘旧的再装新的),不再是一次性。
|
|
8890
|
+
// 选择由 Host 落盘(session-persona.json),切会话/重启/压缩导致 agent 重建时会自动
|
|
8891
|
+
// 补回人设;chip 从 expertActive 同步的是「用户选择」而不是「当前是否恰好生效」,
|
|
8892
|
+
// 所以恢复过程中也不会闪没。
|
|
8597
8893
|
// 挂载只走人设通道(预设通道 agentPresets.select 会在会话投影里留下永不更新的
|
|
8598
8894
|
// 专家名,造成「顶栏显示 A、实际生效 B」的分叉——见 SessionExpertBadge 注释)。
|
|
8599
8895
|
// sessionId 来源:conversation.input.right 是 session 座位,slot 渲染时把 sessionId
|
|
@@ -8620,7 +8916,10 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
8620
8916
|
// 团队型说明只在 trigger 的 title(悬停)里出现,不进菜单气泡:
|
|
8621
8917
|
// 这句 60+ 字,菜单 min-width 只有 128px,塞进气泡会折成多行把菜单撑高。
|
|
8622
8918
|
// 成功态本来就有两处可见反馈——专家 chip(名字 + ×)与菜单项的「已挂」徽章。
|
|
8623
|
-
|
|
8919
|
+
// adopted:人设生效但拿不到撤销句柄,必须如实告诉用户取消要重启。
|
|
8920
|
+
setAttachNote(res.needsRestart
|
|
8921
|
+
? t('人设已生效,但该会话残留旧注册,取消需重启 DSH 后生效。')
|
|
8922
|
+
: (res.personaSource === 'generated' ? t('人设正文是按专家介绍生成的替身,不是原作者设定;原文不可用。') : ''));
|
|
8624
8923
|
setTeamHint(res.expertType === 'team'
|
|
8625
8924
|
? t('成员由主理人用 subagent 调度;若本会话没有 subagent 工具,请新建会话并选择「WorkBuddy 专家 · {0}」预设', [res.name || item.name || item.plugin])
|
|
8626
8925
|
: '');
|
|
@@ -8646,7 +8945,9 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
8646
8945
|
const res = await call('expertDetach', { sessionId }).catch(() => null);
|
|
8647
8946
|
setAttached(null);
|
|
8648
8947
|
setTeamHint('');
|
|
8649
|
-
setAttachNote(res
|
|
8948
|
+
setAttachNote(res?.needsRestart
|
|
8949
|
+
? t('人设已生效,但该会话残留旧注册,取消需重启 DSH 后生效。')
|
|
8950
|
+
: (res && res.ok && res.removed ? t('✓ 已取消本会话的专家') : t('本会话没有挂着专家')));
|
|
8650
8951
|
// 摘掉也要广播:会话头徽章退场、被隐身的预设标签复原。
|
|
8651
8952
|
emitExpertChange();
|
|
8652
8953
|
} catch (e) {
|
|
@@ -8869,11 +9170,15 @@ const SessionExpertBadge = ({ call, sessionId }) => {
|
|
|
8869
9170
|
}).catch(() => { if (!cancelled) setReady(true); });
|
|
8870
9171
|
};
|
|
8871
9172
|
setReady(false);
|
|
8872
|
-
|
|
9173
|
+
// P0-3:首拉推迟到空闲帧。会话头挂载时宿主正在铺消息列表,这一发 RPC + 回包后的
|
|
9174
|
+
// setState 会跟首屏抢主线程;徽章本来就要等真源回来才有内容,晚一个空闲帧无感。
|
|
9175
|
+
// 专家变更总线(onExpertChange)仍然立即同步——那是用户动作,等不得。
|
|
9176
|
+
const cancelIdleSync = scheduleIdle(() => { if (!cancelled) sync(); });
|
|
8873
9177
|
const off = onExpertChange(sync);
|
|
8874
|
-
// OPT-05:同上,徽章这一处也改走可见性感知轮询。
|
|
8875
|
-
|
|
8876
|
-
|
|
9178
|
+
// OPT-05:同上,徽章这一处也改走可见性感知轮询。P0-3:5s → 15s——挂/换/摘都由总线
|
|
9179
|
+
// 立即触发,轮询只兜底「别的进程改了人设」这类外部变化,15s 足够且省下 2/3 的调用。
|
|
9180
|
+
const stopPoll = startVisibilityAwarePolling(typeof document === 'undefined' ? null : document, null, 15000, sync);
|
|
9181
|
+
return () => { cancelled = true; cancelIdleSync(); off(); stopPoll(); };
|
|
8877
9182
|
}, [sessionId, call]);
|
|
8878
9183
|
// 挂上专家时,把同容器里那条**陈旧的预设标签**藏掉:它读的是会话创建时的
|
|
8879
9184
|
// agentPreset 投影(宿主只读、换专家不动它),所以换到第二个专家后它仍显示第一个,
|
|
@@ -8963,6 +9268,20 @@ const wbFormatDate = (ms) => {
|
|
|
8963
9268
|
if (!ms) return '';
|
|
8964
9269
|
try { return new Date(Number(ms)).toISOString().slice(0, 10); } catch { return ''; }
|
|
8965
9270
|
};
|
|
9271
|
+
// OAuth 记录里的 expiresAt 是 ISO 串(旧版本可能写成数字毫秒):两种都认,认不出就原样透出,
|
|
9272
|
+
// 不编一个「永久有效」——用户要的是「什么时候会失效」这个事实。
|
|
9273
|
+
const authExpiryText = (value) => {
|
|
9274
|
+
if (value === undefined || value === null || value === '') return '';
|
|
9275
|
+
const raw = typeof value === 'number' ? value : Date.parse(String(value));
|
|
9276
|
+
if (!Number.isFinite(raw)) return String(value);
|
|
9277
|
+
try {
|
|
9278
|
+
const date = new Date(raw);
|
|
9279
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
9280
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
9281
|
+
} catch {
|
|
9282
|
+
return String(value);
|
|
9283
|
+
}
|
|
9284
|
+
};
|
|
8966
9285
|
|
|
8967
9286
|
// ── 社区「人味」信号:真实的人、真实的维护节奏、真实的反馈出口 ────────────────
|
|
8968
9287
|
// 市场目录里的条目不该只是「一个可安装的对象」:谁做的、最后一次动是什么时候、
|
|
@@ -9475,6 +9794,17 @@ const browsableHomepageUrl = (value) => {
|
|
|
9475
9794
|
if (/^https?:\/\/api\.skillhub\.cn(?=[/?#]|$)/i.test(url)) return '';
|
|
9476
9795
|
return url;
|
|
9477
9796
|
};
|
|
9797
|
+
// 外部链接只接受 https。社区 MCP 的 repository.url 由上游提交,javascript: / file:
|
|
9798
|
+
// 直接进 href 会在点击时执行;数据层已过滤,渲染层再兜一层(旧缓存 / 上游变化)。
|
|
9799
|
+
const externalHref = (value) => {
|
|
9800
|
+
const url = String(value || '').trim();
|
|
9801
|
+
if (!/^https:\/\//i.test(url)) return '';
|
|
9802
|
+
try {
|
|
9803
|
+
return new URL(url).toString();
|
|
9804
|
+
} catch {
|
|
9805
|
+
return '';
|
|
9806
|
+
}
|
|
9807
|
+
};
|
|
9478
9808
|
// 详情回包只覆盖「有值」的字段:上游对缺失字段返回空串 / 空数组时,不能把列表项里
|
|
9479
9809
|
// 已有的中文名、介绍、上架时间冲掉(issue #4)。0 与 false 是合法值,照常覆盖。
|
|
9480
9810
|
const mergeSkillDetail = (listItem, detail) => {
|
|
@@ -10031,10 +10361,119 @@ const WbSearchBox = ({ value, onChange, placeholder, busy, onCommit }) =>
|
|
|
10031
10361
|
}, h(Icon, { d: ICONS.x, size: 12 }))
|
|
10032
10362
|
: null,
|
|
10033
10363
|
);
|
|
10364
|
+
// ── P1-5:技能列表 SWR(模块级,跨面板开关存活)────────────────────────────
|
|
10365
|
+
// SkillHub 是远程分页接口(本机实测 187–229ms/次),而用户在「技能 ↔ 连接器」之间
|
|
10366
|
+
// 来回切时看到的往往还是同一页。按 page|sort|keyword 缓存 60 秒:命中直接渲染,
|
|
10367
|
+
// 不再发那一次远程请求;过期后照常请求。搜索词 / 排序 / 页码都是缓存键的一部分,
|
|
10368
|
+
// 不会把 A 的结果显示到 B 上。条数封顶 30 条,避免长会话里无限涨。
|
|
10369
|
+
const SKILL_SWR_TTL_MS = 60 * 1000;
|
|
10370
|
+
const SKILL_SWR_MAX_ENTRIES = 30;
|
|
10371
|
+
const skillListCache = new Map(); // key -> { at, skills, total }
|
|
10372
|
+
function readSkillCache(key) {
|
|
10373
|
+
if (!MARKET_PERF.skillSwr) return null;
|
|
10374
|
+
const hit = skillListCache.get(key);
|
|
10375
|
+
if (!hit) return null;
|
|
10376
|
+
if (Date.now() - hit.at > SKILL_SWR_TTL_MS) { skillListCache.delete(key); return null; }
|
|
10377
|
+
return hit;
|
|
10378
|
+
}
|
|
10379
|
+
function writeSkillCache(key, value) {
|
|
10380
|
+
if (!MARKET_PERF.skillSwr) return;
|
|
10381
|
+
skillListCache.set(key, { at: Date.now(), ...value });
|
|
10382
|
+
while (skillListCache.size > SKILL_SWR_MAX_ENTRIES) {
|
|
10383
|
+
const oldest = skillListCache.keys().next();
|
|
10384
|
+
if (oldest.done) break;
|
|
10385
|
+
skillListCache.delete(oldest.value);
|
|
10386
|
+
}
|
|
10387
|
+
}
|
|
10388
|
+
// ── P2-3:卡片 hover 预取详情(模块级,单条缓存)──────────────────────────────
|
|
10389
|
+
// 鼠标扫过一屏卡片会连发几十次 mouseenter,所以在「停在同一张卡上 200ms」后才发一条;
|
|
10390
|
+
// 离开卡片立即取消,进入下一张会把上一条挂起的取消掉(同时最多一条待发、一条在飞)。
|
|
10391
|
+
// 三类详情各留**最近一条**结果:用户真点开时直接用手上的回包,二次请求为 0;
|
|
10392
|
+
// 换一张卡就覆盖,不做无界字典。60 秒 TTL 只为了不把「很久前的授权态」摆到眼前
|
|
10393
|
+
// (授权 / 重连之后状态会变);技能详情与 README 属内容型数据,超时重拉也不吃亏。
|
|
10394
|
+
const HOVER_PREFETCH_DELAY_MS = 200;
|
|
10395
|
+
const DETAIL_MEMO_TTL_MS = 60 * 1000;
|
|
10396
|
+
let hoverPrefetchTimer = null;
|
|
10397
|
+
const hoverMemo = {
|
|
10398
|
+
skillDetail: { key: '', at: 0, value: null },
|
|
10399
|
+
connectorAuth: { key: '', at: 0, value: null },
|
|
10400
|
+
readme: { key: '', at: 0, value: null },
|
|
10401
|
+
};
|
|
10402
|
+
function cancelHoverPrefetch() {
|
|
10403
|
+
if (hoverPrefetchTimer) { clearTimeout(hoverPrefetchTimer); hoverPrefetchTimer = null; }
|
|
10404
|
+
}
|
|
10405
|
+
function scheduleHoverPrefetch(run) {
|
|
10406
|
+
if (!MARKET_PERF.hoverPrefetch) return;
|
|
10407
|
+
cancelHoverPrefetch();
|
|
10408
|
+
hoverPrefetchTimer = setTimeout(() => {
|
|
10409
|
+
hoverPrefetchTimer = null;
|
|
10410
|
+
try { run(); } catch { /* 预取失败无感:点开时照常请求 */ }
|
|
10411
|
+
}, HOVER_PREFETCH_DELAY_MS);
|
|
10412
|
+
}
|
|
10413
|
+
function readDetailMemo(slot, key) {
|
|
10414
|
+
if (!MARKET_PERF.hoverPrefetch) return null;
|
|
10415
|
+
if (!key || slot.key !== key || !slot.value) return null;
|
|
10416
|
+
if (Date.now() - slot.at > DETAIL_MEMO_TTL_MS) return null;
|
|
10417
|
+
return slot.value;
|
|
10418
|
+
}
|
|
10419
|
+
function writeDetailMemo(slot, key, value) {
|
|
10420
|
+
if (!MARKET_PERF.hoverPrefetch || !key || !value) return;
|
|
10421
|
+
slot.key = key; slot.at = Date.now(); slot.value = value;
|
|
10422
|
+
}
|
|
10423
|
+
const skillDetailMemoKey = (skill) => String(skill?.namespace || '') + '/' + String(skill?.slug || '');
|
|
10424
|
+
const readmeMemoKey = (item) => {
|
|
10425
|
+
const npm = typeof item?.npm === 'string' ? item.npm : (item?.npm && item.npm.name) || '';
|
|
10426
|
+
return String(item?.name || '') + '|' + npm + '|' + String(item?.repo || '');
|
|
10427
|
+
};
|
|
10428
|
+
// ── 「最近动静」跳转口径:直达该来源的**全量市场** ────────────────────────────
|
|
10429
|
+
// 「新增 3 条 / 更新 2 条」说的是整个目录的变化,落到一半的过滤条件里就看不见它了:
|
|
10430
|
+
// 搜索词、只看已安装、分类、排序、页码任意一项生效,用户还得自己挨个清掉才能核对。
|
|
10431
|
+
// 所以点条目时把目标页签重置回全量:搜索清空、筛选取消、排序回默认、页码回 1。
|
|
10432
|
+
// 口径写成纯函数(不碰 state),行为可被测试直接跑;组件只负责把值写回 setter。
|
|
10433
|
+
// 连接器:回到 WorkBuddy 源(动静只记录这份目录),并取消「只看可直接安装 / 已安装」;
|
|
10434
|
+
// 技能:清空搜索与排序、回第 1 页(记录口径本身就是默认页 / 无关键词);
|
|
10435
|
+
// 插件:清空搜索、分类、标签与「只看已安装」,排序回 stars;
|
|
10436
|
+
// 专家:状态全在 ExpertMarket 内部,交回「重挂」信号,由组件递增 key 回到全量首屏。
|
|
10437
|
+
const marketFullScope = (tabKey) => {
|
|
10438
|
+
if (tabKey === 'connector') {
|
|
10439
|
+
return {
|
|
10440
|
+
connectorFeed: 'wb',
|
|
10441
|
+
connectorQuery: '',
|
|
10442
|
+
connectorOnlyReady: false,
|
|
10443
|
+
connectorOnlyInstalled: false,
|
|
10444
|
+
connectorSort: 'default',
|
|
10445
|
+
connectorPage: 1,
|
|
10446
|
+
};
|
|
10447
|
+
}
|
|
10448
|
+
if (tabKey === 'skill') {
|
|
10449
|
+
return {
|
|
10450
|
+
skillKeyword: '',
|
|
10451
|
+
skillKeywordInput: '',
|
|
10452
|
+
skillSort: 'downloads',
|
|
10453
|
+
skillOnlyInstalled: false,
|
|
10454
|
+
skillPage: 1,
|
|
10455
|
+
};
|
|
10456
|
+
}
|
|
10457
|
+
if (tabKey === 'plugin') {
|
|
10458
|
+
return {
|
|
10459
|
+
awesomeQuery: '',
|
|
10460
|
+
awesomeCategory: 'all',
|
|
10461
|
+
awesomeTags: [],
|
|
10462
|
+
awesomeSort: 'stars',
|
|
10463
|
+
awesomeOnlyInstalled: false,
|
|
10464
|
+
awesomePage: 1,
|
|
10465
|
+
};
|
|
10466
|
+
}
|
|
10467
|
+
if (tabKey === 'expert') return { expertRemount: true };
|
|
10468
|
+
return null;
|
|
10469
|
+
};
|
|
10034
10470
|
const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
10035
10471
|
// ── 共享状态 ──
|
|
10036
10472
|
const [tab, setTab] = useState('connector'); // connector | skill
|
|
10037
10473
|
const [loading, setLoading] = useState(true);
|
|
10474
|
+
// P1-1:连接器首屏(含快照)是否已落地。插件 / 专家目录预取都等它,避免三个市场
|
|
10475
|
+
// 同时抢 IPC 与主线程——首屏那 300ms 是用户唯一能感知到的部分。
|
|
10476
|
+
const [connectorSettled, setConnectorSettled] = useState(false);
|
|
10038
10477
|
const [error, setError] = useState('');
|
|
10039
10478
|
const [busy, setBusy] = useState(false);
|
|
10040
10479
|
const [busyLabel, setBusyLabel] = useState('');
|
|
@@ -10057,12 +10496,42 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10057
10496
|
const [connectors, setConnectors] = useState([]);
|
|
10058
10497
|
const [connectorQuery, setConnectorQuery] = useState('');
|
|
10059
10498
|
const [connectorOnlyReady, setConnectorOnlyReady] = useState(true);
|
|
10499
|
+
// ── 社区 MCP(连接器页的第二个来源)──
|
|
10500
|
+
// 与上面的 connectorSource(目录来自 network/disk)不是一回事:这里是「当前看哪个来源」,
|
|
10501
|
+
// 名字必须区分。默认停在 wb,行为与改造前完全一致。
|
|
10502
|
+
const [connectorFeed, setConnectorFeed] = useState('wb');
|
|
10503
|
+
const [communityRows, setCommunityRows] = useState([]);
|
|
10504
|
+
const [communityQueryInput, setCommunityQueryInput] = useState('');
|
|
10505
|
+
const [communityQuery, setCommunityQuery] = useState('');
|
|
10506
|
+
const [communityNextCursor, setCommunityNextCursor] = useState(null);
|
|
10507
|
+
// 翻页:Registry 是游标分页且不返回总数,前端只记「第几页」与本次查询已读取的累计条数。
|
|
10508
|
+
// 页内容互斥(第 2 页替换第 1 页),不像「加载更多」那样越堆越长。
|
|
10509
|
+
const [communityPage, setCommunityPage] = useState(1);
|
|
10510
|
+
const [communityReadTotal, setCommunityReadTotal] = useState(0);
|
|
10511
|
+
const [communityLoading, setCommunityLoading] = useState(false);
|
|
10512
|
+
const [communityError, setCommunityError] = useState('');
|
|
10513
|
+
// 「缓存结果」:断网 / 上游变慢时保留上次内存结果并如实标注,而不是清空列表。
|
|
10514
|
+
const [communityStale, setCommunityStale] = useState(false);
|
|
10515
|
+
const [communityOnlyRunnable, setCommunityOnlyRunnable] = useState(true);
|
|
10516
|
+
// 社区条目的「加入 MCP」草稿:预填 AddEditModal,用户确认后才写盘。
|
|
10517
|
+
const [communityDraft, setCommunityDraft] = useState(null);
|
|
10518
|
+
// 社区卡片详情:点整卡打开,交互与插件 README 弹层一致(遮罩 / Esc 关闭、焦点陷阱)。
|
|
10519
|
+
// 内容全部来自当前列表行(Registry 一次响应已带 command / args / env),不再发请求。
|
|
10520
|
+
const [communityDetail, setCommunityDetail] = useState(null);
|
|
10060
10521
|
// T04/R8:已安装筛选(全量本地筛选,不受分页影响——与技能侧「仅本页」不同)。
|
|
10061
10522
|
const [connectorOnlyInstalled, setConnectorOnlyInstalled] = useState(false);
|
|
10062
10523
|
// T05/R10:连接器排序键(全量本地排序)。
|
|
10063
10524
|
const [connectorSort, setConnectorSort] = useState('default');
|
|
10064
10525
|
const [connectorPage, setConnectorPage] = useState(1);
|
|
10065
10526
|
const [connectorDetail, setConnectorDetail] = useState(null);
|
|
10527
|
+
// P0:连接器「真实连接态」。市场卡片此前只认安装账本——「写进过配置」不等于「连得上」,
|
|
10528
|
+
// 用户看到「已安装」却用不了(ima / 腾讯自选股就是这么被误判的)。这里读 MCP 面板同一份
|
|
10529
|
+
// list() 投影:连接态的唯一证据是工具数(lib/mcp-observability.js deriveMcpPhase),不猜日志。
|
|
10530
|
+
const [mcpServerRows, setMcpServerRows] = useState([]);
|
|
10531
|
+
// 详情弹层里的授权概况(wbOAuthOverview:只读落盘记录,不联网)。此前这个 RPC 声明了
|
|
10532
|
+
// 却从未被调用,用户看不到授权是否还有效、什么时候过期。
|
|
10533
|
+
const [connectorAuth, setConnectorAuth] = useState(null);
|
|
10534
|
+
const connectorDetailIdRef = useRef('');
|
|
10066
10535
|
// ── 技能 ──
|
|
10067
10536
|
const [skillPage, setSkillPage] = useState(1);
|
|
10068
10537
|
const [skillSort, setSkillSort] = useState('downloads');
|
|
@@ -10108,8 +10577,19 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10108
10577
|
// OPT-21:两个详情弹层此前都没有 ref(焦点陷阱需要容器节点)。
|
|
10109
10578
|
const connectorDetailRef = useRef(null);
|
|
10110
10579
|
const skillDetailRef = useRef(null);
|
|
10111
|
-
|
|
10580
|
+
// 技能详情请求序号:关掉弹层或改点另一条后,在途回包必须作废,
|
|
10581
|
+
// 否则会把已关闭的弹层重新打开,或把 A 的详情盖到 B 上(issue #4 同类竞态)。
|
|
10582
|
+
const skillDetailSeq = useRef(0);
|
|
10583
|
+
const closeSkillDetail = () => {
|
|
10584
|
+
skillDetailSeq.current += 1;
|
|
10585
|
+
setSkillDetail(null);
|
|
10586
|
+
};
|
|
10587
|
+
// 社区详情若从弹层里点「加入 MCP」会先关掉自己再开 AddEditModal,两层不会叠加。
|
|
10588
|
+
const communityDetailRef = useRef(null);
|
|
10589
|
+
// 令牌 / 授权弹层可能叠在详情之上:此时只让上层陷阱接管 Tab,避免两层焦点陷阱互抢。
|
|
10590
|
+
useFocusTrap(connectorDetailRef, !!connectorDetail && !tokenDialog && !authDialog);
|
|
10112
10591
|
useFocusTrap(skillDetailRef, !!skillDetail);
|
|
10592
|
+
useFocusTrap(communityDetailRef, !!communityDetail);
|
|
10113
10593
|
const WB_PAGE_SIZE = 20;
|
|
10114
10594
|
|
|
10115
10595
|
const loadInstalled = useCallback(async () => {
|
|
@@ -10133,22 +10613,54 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10133
10613
|
installRecords.filter((row) => row && row.kind === kind).map((row) => String(row.key || '')),
|
|
10134
10614
|
);
|
|
10135
10615
|
|
|
10616
|
+
// P0:MCP 连接态。失败只清空(不显示健康徽标),绝不打断市场主流程——连接态是辅助信息。
|
|
10617
|
+
const loadMcpServerRows = useCallback(async () => {
|
|
10618
|
+
try {
|
|
10619
|
+
const res = await call('list');
|
|
10620
|
+
setMcpServerRows(Array.isArray(res?.servers) ? res.servers : []);
|
|
10621
|
+
} catch {
|
|
10622
|
+
setMcpServerRows([]);
|
|
10623
|
+
}
|
|
10624
|
+
}, [call]);
|
|
10625
|
+
|
|
10136
10626
|
const loadConnectors = useCallback(async () => {
|
|
10137
|
-
|
|
10627
|
+
// P1-4 / P2-2:先铺快照(本次会话的内存镜像优先,其次 24h 内的 localStorage 镜像),
|
|
10628
|
+
// 再后台刷新。快照命中时不再进入骨架屏——用户第一眼就是列表。
|
|
10629
|
+
const cached = connectorCatalogMemo || readConnectorSnapshot();
|
|
10630
|
+
if (cached) {
|
|
10631
|
+
setConnectors(cached.connectors);
|
|
10632
|
+
setConnectorSource(cached.source || '');
|
|
10633
|
+
setLoading(false);
|
|
10634
|
+
setConnectorSettled(true);
|
|
10635
|
+
} else {
|
|
10636
|
+
setLoading(true);
|
|
10637
|
+
}
|
|
10638
|
+
setError(''); setNote('');
|
|
10138
10639
|
try {
|
|
10139
10640
|
const res = await call('wbConnectorCatalog');
|
|
10140
10641
|
if (!res?.ok) throw new Error(res?.error || t('读取连接器目录失败'));
|
|
10141
10642
|
const rows = res.connectors || [];
|
|
10142
10643
|
setConnectors(rows);
|
|
10143
10644
|
setConnectorSource(res.loadedFrom || '');
|
|
10645
|
+
connectorCatalogMemo = { connectors: rows, source: res.loadedFrom || '', at: Date.now() };
|
|
10646
|
+
const snapshotSig = rows.length + '|' + (res.loadedFrom || '') + '|'
|
|
10647
|
+
+ rows.map((row) => row.id + ':' + (row.version || '') + ':' + (row.installable ? '1' : '0')).join(',');
|
|
10648
|
+
if (snapshotSig !== connectorSnapshotSigRef.current) {
|
|
10649
|
+
connectorSnapshotSigRef.current = snapshotSig;
|
|
10650
|
+
scheduleIdle(() => writeConnectorSnapshot({ connectors: rows, source: res.loadedFrom }), 5000);
|
|
10651
|
+
}
|
|
10144
10652
|
// 目录快照只记「版本 + 可装性」这种稳定签名:星数 / 下载量波动不是「动静」。
|
|
10145
10653
|
if (recordMarketSnapshot('connector', rows.map((row) => ({
|
|
10146
10654
|
key: row.id, name: row.name, sig: String(row.version || '') + '|' + (row.installable ? '1' : '0'),
|
|
10147
10655
|
})))) refreshPulse();
|
|
10148
10656
|
} catch (e) {
|
|
10149
|
-
|
|
10657
|
+
// 有快照时不拿错误条盖掉列表:列表还能用(只是可能过期),错误降级成一行提示。
|
|
10658
|
+
if (cached) setNote(t('刷新连接器目录失败,当前显示的是本机快照:{0}', [String(e?.message || e)]));
|
|
10659
|
+
else setError(String(e?.message || e));
|
|
10150
10660
|
} finally {
|
|
10151
10661
|
setLoading(false);
|
|
10662
|
+
// P1-1:连接器首屏已落地(成功或失败都算),后台预取可以排队了。
|
|
10663
|
+
setConnectorSettled(true);
|
|
10152
10664
|
}
|
|
10153
10665
|
}, [call, refreshPulse]);
|
|
10154
10666
|
|
|
@@ -10156,6 +10668,17 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10156
10668
|
const skillSeq = useRef(0);
|
|
10157
10669
|
const loadSkills = useCallback(async (page, sort, keyword) => {
|
|
10158
10670
|
const seq = ++skillSeq.current;
|
|
10671
|
+
// P1-5:60 秒内的同参查询直接吃缓存(不占 SkillHub 配额、不闪 loading)。
|
|
10672
|
+
const cacheKey = page + '|' + sort + '|' + String(keyword || '');
|
|
10673
|
+
const cached = readSkillCache(cacheKey);
|
|
10674
|
+
if (cached) {
|
|
10675
|
+
setSkills(cached.skills);
|
|
10676
|
+
setSkillTotal(cached.total);
|
|
10677
|
+
if (!String(keyword || '').trim()) setSkillMarketTotal(cached.total);
|
|
10678
|
+
setSkillLoading(false);
|
|
10679
|
+
setSkillError('');
|
|
10680
|
+
return;
|
|
10681
|
+
}
|
|
10159
10682
|
setSkillLoading(true); setSkillError('');
|
|
10160
10683
|
try {
|
|
10161
10684
|
const res = await call('wbSkillCatalog', { page, sort, keyword, pageSize: WB_PAGE_SIZE });
|
|
@@ -10164,6 +10687,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10164
10687
|
const rows = res.skills || [];
|
|
10165
10688
|
setSkills(rows);
|
|
10166
10689
|
setSkillTotal(res.total || 0);
|
|
10690
|
+
writeSkillCache(cacheKey, { skills: rows, total: res.total || 0 });
|
|
10167
10691
|
if (!String(keyword || '').trim()) setSkillMarketTotal(res.total || 0);
|
|
10168
10692
|
// 「最近动静」:技能是远程分页 + 服务端搜索,任何一次响应都只是 19 万库里的一个切片,
|
|
10169
10693
|
// 切片之间互比等于自欺。只有**服务端全局排序**(最多安装 / 最多星标 / 综合评分)的
|
|
@@ -10189,7 +10713,8 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10189
10713
|
loadConnectors();
|
|
10190
10714
|
loadInstalled();
|
|
10191
10715
|
loadInstallState();
|
|
10192
|
-
|
|
10716
|
+
loadMcpServerRows();
|
|
10717
|
+
}, [loadConnectors, loadInstalled, loadInstallState, loadMcpServerRows]);
|
|
10193
10718
|
useEffect(() => {
|
|
10194
10719
|
loadSkills(skillPage, skillSort, skillKeyword);
|
|
10195
10720
|
}, [loadSkills, skillPage, skillSort, skillKeyword]);
|
|
@@ -10243,9 +10768,233 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10243
10768
|
const safeConnectorPage = Math.min(connectorPage, connectorPageCount);
|
|
10244
10769
|
const visibleConnectors = sortedConnectors.slice((safeConnectorPage - 1) * WB_MARKET_CONNECTOR_PAGE_SIZE, safeConnectorPage * WB_MARKET_CONNECTOR_PAGE_SIZE);
|
|
10245
10770
|
|
|
10771
|
+
// ── 社区 MCP:取数 / 游标翻页 ──
|
|
10772
|
+
// Registry 是游标分页、不返回总数,所以不用按总页数算的 Pager:页面只保留当前一页,
|
|
10773
|
+
// 「上一页 / 下一页」按游标栈跳转(第 N 页的游标 = 第 N-1 页返回的 nextCursor)。
|
|
10774
|
+
// 顶部数量显示的是本次查询「已读取的总数」(跨页累计去重),不是当前页条数。
|
|
10775
|
+
// 请求序号照 skillSeq,过期响应直接丢弃。
|
|
10776
|
+
// 页面卸载时清掉「加入后补刷」的定时器,避免对已卸载组件 setState。
|
|
10777
|
+
const communityDraftRefreshRef = useRef(null);
|
|
10778
|
+
// 连接器快照的签名:目录没变(条数 / 来源 / 每条的 id|version|installable)就不重写,
|
|
10779
|
+
// 也不在返回渲染的同一帧里做一次 769KB 序列化——写盘推到空闲帧(与插件快照同款)。
|
|
10780
|
+
const connectorSnapshotSigRef = useRef('');
|
|
10781
|
+
useEffect(() => () => { if (communityDraftRefreshRef.current) clearTimeout(communityDraftRefreshRef.current); }, []);
|
|
10782
|
+
// 离场时取消还在防抖窗口里的 hover 预取:晚到的预取只会白烧一次远程请求
|
|
10783
|
+
// (结果写进模块级 memo,但用户已经不在这个面板上了)。
|
|
10784
|
+
useEffect(() => () => cancelHoverPrefetch(), []);
|
|
10785
|
+
const communitySeq = useRef(0);
|
|
10786
|
+
const communityRowsRef = useRef([]);
|
|
10787
|
+
const communityQueryRef = useRef('');
|
|
10788
|
+
const communityLoadedQueryRef = useRef(null);
|
|
10789
|
+
const communityNamesRef = useRef([]);
|
|
10790
|
+
// 游标栈:cursors[0] 恒为 ''(第一页),cursors[n] = 第 n+1 页的游标。
|
|
10791
|
+
// 已读 key 集合与游标栈都按查询词隔离——换搜索词就从头开始。
|
|
10792
|
+
const communityCursorsRef = useRef([]);
|
|
10793
|
+
const communityReadKeysRef = useRef(new Set());
|
|
10794
|
+
const communityReadQueryRef = useRef(null);
|
|
10795
|
+
// 每次渲染同步最新值:loadCommunity 不把 rows/query/已装列表塞进依赖——
|
|
10796
|
+
// 否则 MCP 列表一刷新就重建回调,让防抖 / 懒加载 effect 误触发第二次请求。
|
|
10797
|
+
communityRowsRef.current = communityRows;
|
|
10798
|
+
communityQueryRef.current = communityQuery;
|
|
10799
|
+
communityNamesRef.current = mcpServerRows.map((row) => String(row?.serverName || '')).filter(Boolean);
|
|
10800
|
+
|
|
10801
|
+
const loadCommunity = useCallback(async ({ cursor = '', page = 1, query } = {}) => {
|
|
10802
|
+
const search = query === undefined ? communityQueryRef.current : String(query || '');
|
|
10803
|
+
const seq = ++communitySeq.current;
|
|
10804
|
+
setCommunityLoading(true);
|
|
10805
|
+
setCommunityError('');
|
|
10806
|
+
try {
|
|
10807
|
+
const res = await call('wbMcpCommunityList', {
|
|
10808
|
+
search,
|
|
10809
|
+
cursor,
|
|
10810
|
+
limit: COMMUNITY_BATCH_LIMIT,
|
|
10811
|
+
takenNames: communityNamesRef.current,
|
|
10812
|
+
});
|
|
10813
|
+
if (seq !== communitySeq.current) return; // 过期响应,直接丢弃
|
|
10814
|
+
if (!res?.ok) throw new Error(res?.error || t('读取社区 MCP 失败'));
|
|
10815
|
+
// 新查询:游标栈与已读计数一起归零(同一个词的来回翻页则保留累计)。
|
|
10816
|
+
if (communityReadQueryRef.current !== search) {
|
|
10817
|
+
communityReadQueryRef.current = search;
|
|
10818
|
+
communityReadKeysRef.current = new Set();
|
|
10819
|
+
communityCursorsRef.current = [];
|
|
10820
|
+
}
|
|
10821
|
+
const incoming = Array.isArray(res.rows) ? res.rows.filter(Boolean) : [];
|
|
10822
|
+
const unique = [];
|
|
10823
|
+
const seen = new Set();
|
|
10824
|
+
for (const row of incoming) {
|
|
10825
|
+
if (!row?.key || seen.has(row.key)) continue;
|
|
10826
|
+
seen.add(row.key);
|
|
10827
|
+
unique.push(row);
|
|
10828
|
+
}
|
|
10829
|
+
setCommunityRows(unique);
|
|
10830
|
+
setCommunityPage(page);
|
|
10831
|
+
setCommunityNextCursor(res.nextCursor || null);
|
|
10832
|
+
setCommunityStale(res.cached === true);
|
|
10833
|
+
communityCursorsRef.current[page - 1] = String(cursor || '');
|
|
10834
|
+
communityCursorsRef.current[page] = res.nextCursor ? String(res.nextCursor) : '';
|
|
10835
|
+
for (const row of unique) communityReadKeysRef.current.add(row.key);
|
|
10836
|
+
setCommunityReadTotal(communityReadKeysRef.current.size);
|
|
10837
|
+
communityLoadedQueryRef.current = search;
|
|
10838
|
+
} catch (e) {
|
|
10839
|
+
if (seq !== communitySeq.current) return;
|
|
10840
|
+
setCommunityError(String(e?.message || e));
|
|
10841
|
+
// 上次结果还在内存里就保留并标「缓存结果」;否则列表保持为空、只显示错误行。
|
|
10842
|
+
if (communityRowsRef.current.length) setCommunityStale(true);
|
|
10843
|
+
// 失败不算「这个词已经加载过」:切回来 / 重试时应重新请求。
|
|
10844
|
+
communityLoadedQueryRef.current = null;
|
|
10845
|
+
} finally {
|
|
10846
|
+
if (seq === communitySeq.current) setCommunityLoading(false);
|
|
10847
|
+
}
|
|
10848
|
+
}, [call]);
|
|
10849
|
+
|
|
10850
|
+
// 社区搜索:输入即搜(服务端 search),500ms 防抖合并连击;回车立即搜。
|
|
10851
|
+
useEffect(() => {
|
|
10852
|
+
const timer = setTimeout(() => setCommunityQuery(communityQueryInput.trim()), 500);
|
|
10853
|
+
return () => clearTimeout(timer);
|
|
10854
|
+
}, [communityQueryInput]);
|
|
10855
|
+
|
|
10856
|
+
// 懒加载:首次切到社区、或换搜索词才发请求;切回 WB 不卸载任何状态(列表/搜索词原样保留)。
|
|
10857
|
+
useEffect(() => {
|
|
10858
|
+
if (connectorFeed !== 'community') return;
|
|
10859
|
+
// 只看「这个词是否成功加载过」:空结果是合法结果,不能因为 length=0 每次切回来都重发。
|
|
10860
|
+
if (communityLoadedQueryRef.current === communityQuery) return;
|
|
10861
|
+
loadCommunity({ cursor: '' });
|
|
10862
|
+
}, [connectorFeed, communityQuery, loadCommunity]);
|
|
10863
|
+
|
|
10864
|
+
const communityVisibleRows = communityOnlyRunnable ? communityRows.filter((row) => row.runnable) : communityRows;
|
|
10865
|
+
const communityHiddenCount = communityRows.length - communityVisibleRows.length;
|
|
10866
|
+
// 上一页的游标来自游标栈(第 N-1 页的入口游标);第 1 页没有上一页。
|
|
10867
|
+
const communityPrevCursor = communityPage > 1 ? (communityCursorsRef.current[communityPage - 2] || '') : null;
|
|
10868
|
+
// 「已加入」只按 serverName 命中现有 MCP 列表:社区条目没有 WorkBuddy 代注入,
|
|
10869
|
+
// 不存在「已安装 · 需授权」这种工作流。
|
|
10870
|
+
const communityJoined = (row) => mcpServerRows.some((server) => String(server?.serverName || '') === row.serverName);
|
|
10871
|
+
|
|
10872
|
+
// 「加入 MCP」= 预填草稿,用户在弹层里确认后才由宿主写盘(与连接器一键安装不同语义)。
|
|
10873
|
+
const openCommunityDraft = (row) => {
|
|
10874
|
+
const env = {};
|
|
10875
|
+
for (const item of row.env || []) env[item.name] = item.hasDefault ? item.default : '';
|
|
10876
|
+
setCommunityDraft({
|
|
10877
|
+
serverName: row.serverName,
|
|
10878
|
+
transport: row.transport === 'http' ? 'http' : 'stdio',
|
|
10879
|
+
url: row.remoteUrl || '',
|
|
10880
|
+
command: row.command || '',
|
|
10881
|
+
args: Array.isArray(row.args) ? row.args : [],
|
|
10882
|
+
env,
|
|
10883
|
+
// 必填环境变量只用于弹层内的温和提示(允许提交:有些值允许运行时兜底)。
|
|
10884
|
+
requiredEnv: (row.env || []).filter((item) => item.required).map((item) => item.name),
|
|
10885
|
+
});
|
|
10886
|
+
};
|
|
10887
|
+
const submitCommunityDraft = async (spec, setFormError) => {
|
|
10888
|
+
setBusy(true);
|
|
10889
|
+
try {
|
|
10890
|
+
const res = await call('add', spec);
|
|
10891
|
+
if (res && res.ok) {
|
|
10892
|
+
setCommunityDraft(null);
|
|
10893
|
+
setNote(t('已加入 ') + spec.name);
|
|
10894
|
+
// 宿主写盘后列表刷新有一拍延迟:先立即刷一次,再补一次让「已加入」稳定亮起。
|
|
10895
|
+
loadMcpServerRows();
|
|
10896
|
+
communityDraftRefreshRef.current = setTimeout(() => { loadMcpServerRows(); }, 1200);
|
|
10897
|
+
} else {
|
|
10898
|
+
setFormError((res && res.error) || t('添加失败'));
|
|
10899
|
+
}
|
|
10900
|
+
} catch (e) {
|
|
10901
|
+
setFormError(String(e?.message || e));
|
|
10902
|
+
} finally {
|
|
10903
|
+
setBusy(false);
|
|
10904
|
+
}
|
|
10905
|
+
};
|
|
10906
|
+
|
|
10907
|
+
// P0:连接器卡的真实健康度。只认 list() 里的同名 server(serverNames 来自 mcp.json):
|
|
10908
|
+
// connected 至少一条已注册工具;auth 有 server 报 401/403 类失败;failed 其余失败。
|
|
10909
|
+
// 找不到同名行一律 unknown——连接器可能装在别的 profile / 别的会话里,不能拿「没搜到」冒充「没连上」。
|
|
10910
|
+
const mcpRowByName = useMemo(
|
|
10911
|
+
() => new Map(mcpServerRows.map((row) => [String(row?.serverName || ''), row])),
|
|
10912
|
+
[mcpServerRows],
|
|
10913
|
+
);
|
|
10914
|
+
const connectorServerRows = (item) => (Array.isArray(item?.serverNames) ? item.serverNames : [])
|
|
10915
|
+
.map((name) => mcpRowByName.get(String(name)))
|
|
10916
|
+
.filter(Boolean);
|
|
10917
|
+
const connectorHealth = (item) => {
|
|
10918
|
+
const rows = connectorServerRows(item);
|
|
10919
|
+
if (!rows.length) return 'unknown';
|
|
10920
|
+
if (rows.some((row) => row.status === 'connected')) return 'connected';
|
|
10921
|
+
if (rows.some((row) => row.authRequired)) return 'auth';
|
|
10922
|
+
return 'failed';
|
|
10923
|
+
};
|
|
10924
|
+
// 卡片右上角徽标:安装账本只说明「写进过配置」,健康度说明「现在到底能不能用」。
|
|
10925
|
+
const connectorBadge = (item) => {
|
|
10926
|
+
if (installedConnectorKeys.has(String(item.id || ''))) {
|
|
10927
|
+
const health = connectorHealth(item);
|
|
10928
|
+
if (health === 'connected') return h(Badge, { cls: 's-on', title: t('服务器已连接并注册了工具') }, t('已安装 · 已连接'));
|
|
10929
|
+
if (health === 'auth') return h(Badge, { cls: 's-warn', title: t('服务器返回 401/403 类鉴权失败,重连不会恢复,需要重新授权') }, t('已安装 · 需授权'));
|
|
10930
|
+
if (health === 'failed') return h(Badge, { cls: 's-err', title: t('配置已写入,但服务器当前没有连上(点「重连」再试)') }, t('已安装 · 未连接'));
|
|
10931
|
+
return h(Badge, { cls: 's-on', title: t('已写入 MCP 配置') }, t('已安装'));
|
|
10932
|
+
}
|
|
10933
|
+
return h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? t('可安装') : t('仅展示'));
|
|
10934
|
+
};
|
|
10935
|
+
|
|
10936
|
+
// P2-3:hover 预取。连接器详情真正要显示的是授权概览(openConnectorDetail 只调
|
|
10937
|
+
// wbOAuthOverview),未安装的连接器没有这项数据,预取只会白跑一次 IPC —— 直接跳过。
|
|
10938
|
+
const prefetchConnectorDetail = (item) => {
|
|
10939
|
+
const key = String(item?.id || '');
|
|
10940
|
+
if (!key || !installedConnectorKeys.has(key)) return;
|
|
10941
|
+
if (readDetailMemo(hoverMemo.connectorAuth, key)) return; // 已有可用的回包,不再发
|
|
10942
|
+
scheduleHoverPrefetch(() => {
|
|
10943
|
+
call('wbOAuthOverview', { connectorId: item.id })
|
|
10944
|
+
.then((res) => { if (res) writeDetailMemo(hoverMemo.connectorAuth, key, res); })
|
|
10945
|
+
.catch(() => { /* 预取失败留给点开时的正式请求报错 */ });
|
|
10946
|
+
});
|
|
10947
|
+
};
|
|
10948
|
+
|
|
10246
10949
|
const openConnectorDetail = async (item) => {
|
|
10247
10950
|
setConnectorDetail(item);
|
|
10951
|
+
connectorDetailIdRef.current = String(item?.id || '');
|
|
10248
10952
|
setNote('');
|
|
10953
|
+
setConnectorAuth(null);
|
|
10954
|
+
if (!installedConnectorKeys.has(String(item?.id || ''))) return;
|
|
10955
|
+
const memoKey = String(item.id);
|
|
10956
|
+
const precached = readDetailMemo(hoverMemo.connectorAuth, memoKey);
|
|
10957
|
+
if (precached) { setConnectorAuth({ loading: false, ...precached }); return; }
|
|
10958
|
+
setConnectorAuth({ loading: true });
|
|
10959
|
+
try {
|
|
10960
|
+
const res = await call('wbOAuthOverview', { connectorId: item.id });
|
|
10961
|
+
// 用户可能已经关了弹层 / 换了另一条:迟到的回包不写回界面。
|
|
10962
|
+
if (connectorDetailIdRef.current !== String(item.id)) return;
|
|
10963
|
+
if (res) writeDetailMemo(hoverMemo.connectorAuth, memoKey, res);
|
|
10964
|
+
setConnectorAuth(res ? { loading: false, ...res } : { loading: false });
|
|
10965
|
+
} catch {
|
|
10966
|
+
if (connectorDetailIdRef.current === String(item.id)) setConnectorAuth({ loading: false, error: t('读取授权状态失败') });
|
|
10967
|
+
}
|
|
10968
|
+
};
|
|
10969
|
+
// P0:已安装但连不上的入口。鉴权失败走「重新授权」,其余走「重连」——两条路都不该让用户
|
|
10970
|
+
// 滚回 MCP 面板自己找。
|
|
10971
|
+
const reconnectConnector = async (item) => {
|
|
10972
|
+
if (!item) return;
|
|
10973
|
+
const rows = connectorServerRows(item);
|
|
10974
|
+
const authRow = rows.find((row) => row.authRequired);
|
|
10975
|
+
if (authRow) { await beginConnectorAuth(item, authRow.serverName); return; }
|
|
10976
|
+
setBusy(true); setBusyLabel(t('正在重连…')); setError('');
|
|
10977
|
+
try {
|
|
10978
|
+
let lastNote = '';
|
|
10979
|
+
const failed = [];
|
|
10980
|
+
for (const name of (Array.isArray(item.serverNames) ? item.serverNames : [])) {
|
|
10981
|
+
const res = await call('reconnect', String(name));
|
|
10982
|
+
// call() 把宿主失败收敛成 {ok:false, error},这里必须看 ok,否则一条都没连上也会
|
|
10983
|
+
// 显示「已请求重连」。
|
|
10984
|
+
if (res && res.ok === false) {
|
|
10985
|
+
failed.push(name + ': ' + (res.error || t('调用失败')));
|
|
10986
|
+
continue;
|
|
10987
|
+
}
|
|
10988
|
+
lastNote = res?.note || lastNote;
|
|
10989
|
+
}
|
|
10990
|
+
if (failed.length) setError(t('{0}:{1}', [item.name, failed.join('; ')]));
|
|
10991
|
+
else setNote(t('{0}:{1}', [item.name, lastNote || t('已请求重连')]));
|
|
10992
|
+
await loadMcpServerRows();
|
|
10993
|
+
} catch (e) {
|
|
10994
|
+
setError(String(e?.message || e));
|
|
10995
|
+
} finally {
|
|
10996
|
+
setBusy(false); setBusyLabel('');
|
|
10997
|
+
}
|
|
10249
10998
|
};
|
|
10250
10999
|
// ── R8:连接器 OAuth 授权 ────────────────────────────────────────────────
|
|
10251
11000
|
// 宿主探测到 401 会回传 needsAuth,这里把它变成「打开浏览器 → 等回调 → 自动重装」。
|
|
@@ -10304,6 +11053,9 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10304
11053
|
// 授权页这条路走不通(服务器不做 OAuth 发现、动态注册被拒、要求客户端认证)时,
|
|
10305
11054
|
// 不能只甩一行红字就把用户关在门外:手填令牌那条兜底路还在,把弹层开出来摊给他。
|
|
10306
11055
|
if (res?.manualTokenOnly) {
|
|
11056
|
+
// 用户要的是「点安装就给我一个能填凭据的窗口」。无论有没有 ${...} 槽位,都在这里
|
|
11057
|
+
// 直接开手填令牌弹层——有令牌的人能立刻装,没有的人也能看清需要什么,不再把卡片
|
|
11058
|
+
// 降级成「需 WorkBuddy 账号授权」把入口收走。
|
|
10307
11059
|
setAuthDialog({
|
|
10308
11060
|
item,
|
|
10309
11061
|
serverName: res.serverName || serverName || item.name,
|
|
@@ -10391,6 +11143,48 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10391
11143
|
}
|
|
10392
11144
|
};
|
|
10393
11145
|
useEffect(() => () => { stopAuthPoll(); }, []);
|
|
11146
|
+
// 「我有令牌」入口:详情里给需要授权的连接器开一扇门,已持有服务方令牌的人
|
|
11147
|
+
// 不必等浏览器授权发现失败,直接粘贴即可走同一条落盘 + 安装链路。
|
|
11148
|
+
const openManualTokenDialog = (item) => {
|
|
11149
|
+
if (!item) return;
|
|
11150
|
+
stopAuthPoll();
|
|
11151
|
+
setError('');
|
|
11152
|
+
setAuthDialog({
|
|
11153
|
+
item,
|
|
11154
|
+
serverName: (Array.isArray(item.serverNames) ? item.serverNames[0] : '') || item.name,
|
|
11155
|
+
sessionId: '',
|
|
11156
|
+
authorizeUrl: '',
|
|
11157
|
+
mode: 'manual-token',
|
|
11158
|
+
redirectUri: '',
|
|
11159
|
+
hint: '',
|
|
11160
|
+
status: 'manual',
|
|
11161
|
+
error: '',
|
|
11162
|
+
callbackUrl: '',
|
|
11163
|
+
manualToken: '',
|
|
11164
|
+
});
|
|
11165
|
+
};
|
|
11166
|
+
// P0:强制刷新已存令牌 → 再装一次把新凭据写回 headers(mcp-client 只在建连时快照 headers)。
|
|
11167
|
+
// 刷不动就回到浏览器授权,不留「刷新失败」的死胡同。
|
|
11168
|
+
const refreshConnectorAuth = async (item) => {
|
|
11169
|
+
if (!item) return;
|
|
11170
|
+
setBusy(true); setBusyLabel(t('正在刷新授权…')); setError('');
|
|
11171
|
+
try {
|
|
11172
|
+
const res = await call('wbOAuthRefresh', { connectorId: item.id });
|
|
11173
|
+
if (!res?.ok) {
|
|
11174
|
+
if (res?.needsAuth) {
|
|
11175
|
+
await beginConnectorAuth(item);
|
|
11176
|
+
return;
|
|
11177
|
+
}
|
|
11178
|
+
throw new Error(res?.error || t('刷新授权失败'));
|
|
11179
|
+
}
|
|
11180
|
+
setNote(t('{0}:授权已刷新,正在把新凭据写回配置…', [item.name]));
|
|
11181
|
+
await installConnector(item);
|
|
11182
|
+
} catch (e) {
|
|
11183
|
+
setError(String(e?.message || e));
|
|
11184
|
+
} finally {
|
|
11185
|
+
setBusy(false); setBusyLabel('');
|
|
11186
|
+
}
|
|
11187
|
+
};
|
|
10394
11188
|
const installConnector = async (item, tokens = null) => {
|
|
10395
11189
|
setBusy(true); setBusyLabel(t('正在安装 {0}…', [item.name])); setError('');
|
|
10396
11190
|
try {
|
|
@@ -10409,10 +11203,24 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10409
11203
|
await beginConnectorAuth(item, needsAuth[0]?.serverName);
|
|
10410
11204
|
return;
|
|
10411
11205
|
}
|
|
11206
|
+
// issue #3:本地客户端型连接器(如 NVIDIA App)依赖固定路径的本机程序。缺程序时
|
|
11207
|
+
// 宿主连 10 次才放弃,用户只看得到「mcp 连接失败」;这里在安装前就把缺失路径说清楚。
|
|
11208
|
+
const localMissing = Array.isArray(res?.localMissing) ? res.localMissing : [];
|
|
11209
|
+
const localUnsupported = Array.isArray(res?.localUnsupported) ? res.localUnsupported : [];
|
|
11210
|
+
if (localMissing.length) {
|
|
11211
|
+
throw new Error(t('本机没有找到该连接器依赖的程序:{0}。请先安装对应客户端并开启它的 MCP / 集成访问后重试。', [localMissing.join(t('、'))]));
|
|
11212
|
+
}
|
|
11213
|
+
if (localUnsupported.length) {
|
|
11214
|
+
throw new Error(t('该连接器依赖本机程序,但当前系统不支持:{0}', [localUnsupported.join(t('、'))]));
|
|
11215
|
+
}
|
|
10412
11216
|
throw new Error(res?.error || t('安装失败'));
|
|
10413
11217
|
}
|
|
10414
11218
|
const missing = Array.isArray(res?.missingSlots) ? res.missingSlots : [];
|
|
10415
|
-
const
|
|
11219
|
+
const localDeps = Array.isArray(res?.localDeps) ? res.localDeps : [];
|
|
11220
|
+
const baseNote = t('{0}:{1}', [item.name, res.note || t('已写入 MCP 配置')])
|
|
11221
|
+
+ (localDeps.some((dep) => dep.status === 'found')
|
|
11222
|
+
? ' ' + t('本机依赖已找到;若连接失败,请确认对应客户端正在运行并已开启 MCP / 集成访问。')
|
|
11223
|
+
: '');
|
|
10416
11224
|
setNote(missing.length
|
|
10417
11225
|
? baseNote + ' ' + t('另有 {0} 个占位符未填写,配置里保留原样,可用前请补全。', [missing.length])
|
|
10418
11226
|
: baseNote);
|
|
@@ -10420,6 +11228,8 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10420
11228
|
setConnectorDetail(null);
|
|
10421
11229
|
await loadConnectors();
|
|
10422
11230
|
await loadInstallState();
|
|
11231
|
+
// 新写入的 server 要等宿主建连才有工具:这里刷新一次投影,卡片不会停在「未连接」。
|
|
11232
|
+
await loadMcpServerRows();
|
|
10423
11233
|
} catch (e) {
|
|
10424
11234
|
setError(String(e?.message || e));
|
|
10425
11235
|
} finally {
|
|
@@ -10453,7 +11263,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10453
11263
|
const res = await call('wbSkillInstall', { slug: skill.slug, namespace: skill.namespace, name: skill.name });
|
|
10454
11264
|
if (!res?.ok) throw new Error(res?.error || t('安装失败'));
|
|
10455
11265
|
setNote(t('{0}:{1}', [skill.name, res.note || t('已安装')]));
|
|
10456
|
-
|
|
11266
|
+
closeSkillDetail();
|
|
10457
11267
|
// 磁盘上的 skill 集合已经变了:广播出去,顶部分类计数与(切回时的)Skill 看板
|
|
10458
11268
|
// 立即按新集合重算,不再等用户离开看板/重挂载。市场自己的列表照旧局部刷新。
|
|
10459
11269
|
emitSkillChange('market-install');
|
|
@@ -10472,7 +11282,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10472
11282
|
const res = await call('wbSkillUninstall', { slug: skill.slug, namespace: skill.namespace });
|
|
10473
11283
|
if (!res?.ok) throw new Error(res?.error || t('卸载失败'));
|
|
10474
11284
|
setNote(t('{0}:{1}', [skill.name, res.note || t('已卸载')]));
|
|
10475
|
-
|
|
11285
|
+
closeSkillDetail();
|
|
10476
11286
|
emitSkillChange('market-uninstall');
|
|
10477
11287
|
await loadInstalled();
|
|
10478
11288
|
await loadInstallState();
|
|
@@ -10482,15 +11292,37 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10482
11292
|
setBusy(false); setBusyLabel('');
|
|
10483
11293
|
}
|
|
10484
11294
|
};
|
|
11295
|
+
// P2-3:hover 预取技能详情。命中缓存就什么都不发;同一条结果点开时直接复用。
|
|
11296
|
+
const prefetchSkillDetail = (skill) => {
|
|
11297
|
+
const key = skillDetailMemoKey(skill);
|
|
11298
|
+
if (!skill?.slug || !skill?.namespace) return;
|
|
11299
|
+
if (readDetailMemo(hoverMemo.skillDetail, key)) return;
|
|
11300
|
+
scheduleHoverPrefetch(() => {
|
|
11301
|
+
call('wbSkillDetail', { slug: skill.slug, namespace: skill.namespace })
|
|
11302
|
+
.then((res) => { if (res?.ok) writeDetailMemo(hoverMemo.skillDetail, key, res); })
|
|
11303
|
+
.catch(() => { /* 预取失败留给点开时的正式请求报错 */ });
|
|
11304
|
+
});
|
|
11305
|
+
};
|
|
10485
11306
|
const openSkillDetail = async (skill) => {
|
|
10486
|
-
|
|
11307
|
+
const seq = ++skillDetailSeq.current;
|
|
11308
|
+
const memoKey = skillDetailMemoKey(skill);
|
|
11309
|
+
const precached = readDetailMemo(hoverMemo.skillDetail, memoKey);
|
|
10487
11310
|
setNote('');
|
|
11311
|
+
if (precached) {
|
|
11312
|
+
// 预取的就是这一条:省掉 loading 闪烁与一次往返。
|
|
11313
|
+
setSkillDetail({ ...mergeSkillDetail(skill, precached), loading: false });
|
|
11314
|
+
return;
|
|
11315
|
+
}
|
|
11316
|
+
setSkillDetail({ ...skill, loading: true });
|
|
10488
11317
|
try {
|
|
10489
11318
|
const res = await call('wbSkillDetail', { slug: skill.slug, namespace: skill.namespace });
|
|
11319
|
+
if (seq !== skillDetailSeq.current) return; // 已关闭 / 已改点另一条
|
|
10490
11320
|
if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
|
|
11321
|
+
writeDetailMemo(hoverMemo.skillDetail, memoKey, res);
|
|
10491
11322
|
// 回包里的空串不能覆盖列表项已有的中文名 / 介绍(issue #4)。
|
|
10492
11323
|
setSkillDetail({ ...mergeSkillDetail(skill, res), loading: false });
|
|
10493
11324
|
} catch (e) {
|
|
11325
|
+
if (seq !== skillDetailSeq.current) return;
|
|
10494
11326
|
setSkillDetail({ ...skill, loading: false, detailError: String(e?.message || e) });
|
|
10495
11327
|
}
|
|
10496
11328
|
};
|
|
@@ -10501,10 +11333,14 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10501
11333
|
const [restartNotice, setRestartNotice] = useState('');
|
|
10502
11334
|
// T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)状态机与数据接入。
|
|
10503
11335
|
// 插件页只有这一个来源;首屏快照见下方 awesomeSnapshotRef。
|
|
10504
|
-
//
|
|
10505
|
-
//
|
|
11336
|
+
// P1-3:快照懒读。旧实现在市场每次挂载时就把上次的快照 JSON.parse 一遍(2.98MB 目录
|
|
11337
|
+
// 约 100ms 主线程),而默认落地页是「连接器」——绝大多数会话根本不会看插件页。
|
|
11338
|
+
// 现在首帧给空数组,用户真切到插件 tab 时才 hydrate(见 switchTab / hydrateAwesomeFromSnapshot)。
|
|
11339
|
+
// 写侧也改成「签名变了才写 + idle 写」:目录没变就不必再序列化 3MB。
|
|
10506
11340
|
const awesomeSnapshotRef = useRef(undefined);
|
|
10507
|
-
if (awesomeSnapshotRef.current === undefined
|
|
11341
|
+
if (awesomeSnapshotRef.current === undefined && !MARKET_PERF.awesomeSnapshotLazy) {
|
|
11342
|
+
awesomeSnapshotRef.current = readAwesomeSnapshot();
|
|
11343
|
+
}
|
|
10508
11344
|
const [awesomePlugins, setAwesomePlugins] = useState(() => {
|
|
10509
11345
|
const snapshot = awesomeSnapshotRef.current;
|
|
10510
11346
|
return snapshot ? snapshot.plugins.map((item) => ({ ...item, marketplace: snapshot.marketplace || 'awesome-dsh-plugins' })) : [];
|
|
@@ -10522,6 +11358,60 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10522
11358
|
// 静默预取失败时用来判断「有没有快照可留」:有快照就不弹错误条。
|
|
10523
11359
|
const awesomeLenRef = useRef(awesomePlugins.length);
|
|
10524
11360
|
useEffect(() => { awesomeLenRef.current = awesomePlugins.length; }, [awesomePlugins.length]);
|
|
11361
|
+
// P1-3:切到插件页时才读快照(0 网络、0 IPC,只是把 JSON.parse 推到真正需要它的时刻)。
|
|
11362
|
+
// 只 hydrate 一次:真实目录回来后再切回来不该被旧快照盖掉(setAwesomePlugins 前判空)。
|
|
11363
|
+
const hydrateAwesomeFromSnapshot = useCallback(() => {
|
|
11364
|
+
if (!MARKET_PERF.awesomeSnapshotLazy) return;
|
|
11365
|
+
if (awesomeSnapshotRef.current !== undefined) return;
|
|
11366
|
+
const snapshot = readAwesomeSnapshot();
|
|
11367
|
+
awesomeSnapshotRef.current = snapshot || null;
|
|
11368
|
+
if (!snapshot) return;
|
|
11369
|
+
setAwesomePlugins((prev) => (prev.length ? prev : snapshot.plugins.map((item) => ({
|
|
11370
|
+
...item,
|
|
11371
|
+
marketplace: snapshot.marketplace || 'awesome-dsh-plugins',
|
|
11372
|
+
}))));
|
|
11373
|
+
}, []);
|
|
11374
|
+
// P1-3:上次写进 localStorage 的目录签名。签名没变就不重写(3MB 序列化 + 落盘)。
|
|
11375
|
+
const awesomeSnapshotSigRef = useRef('');
|
|
11376
|
+
// 所有「切到插件 tab」的入口都走这里:先补快照,再切页(React 同一批更新里渲染)。
|
|
11377
|
+
const switchTab = useCallback((next) => {
|
|
11378
|
+
if (next === 'plugin') hydrateAwesomeFromSnapshot();
|
|
11379
|
+
setTab(next);
|
|
11380
|
+
}, [hydrateAwesomeFromSnapshot]);
|
|
11381
|
+
// 「最近动静」的跳转动作:先按 marketFullScope 的口径把目标页签重置回全量,再切页签。
|
|
11382
|
+
// 只在用户点条目时执行,不影响正常的页签切换(后者保留用户的搜索 / 筛选 / 页码)。
|
|
11383
|
+
const applyFullMarket = (next) => {
|
|
11384
|
+
const patch = marketFullScope(next);
|
|
11385
|
+
if (!patch) return;
|
|
11386
|
+
if (patch.expertRemount) {
|
|
11387
|
+
// 专家页内部状态(搜索 / 分类 / 页码)只能靠重挂回到默认;不动其它三页签。
|
|
11388
|
+
setExpertEpoch((value) => value + 1);
|
|
11389
|
+
return;
|
|
11390
|
+
}
|
|
11391
|
+
if ('connectorFeed' in patch) {
|
|
11392
|
+
setConnectorFeed(patch.connectorFeed);
|
|
11393
|
+
setConnectorQuery(patch.connectorQuery);
|
|
11394
|
+
setConnectorOnlyReady(patch.connectorOnlyReady);
|
|
11395
|
+
setConnectorOnlyInstalled(patch.connectorOnlyInstalled);
|
|
11396
|
+
setConnectorSort(patch.connectorSort);
|
|
11397
|
+
setConnectorPage(patch.connectorPage);
|
|
11398
|
+
}
|
|
11399
|
+
if ('skillKeyword' in patch) {
|
|
11400
|
+
setSkillKeyword(patch.skillKeyword);
|
|
11401
|
+
setSkillKeywordInput(patch.skillKeywordInput);
|
|
11402
|
+
setSkillSort(patch.skillSort);
|
|
11403
|
+
setSkillOnlyInstalled(patch.skillOnlyInstalled);
|
|
11404
|
+
setSkillPage(patch.skillPage);
|
|
11405
|
+
}
|
|
11406
|
+
if ('awesomeQuery' in patch) {
|
|
11407
|
+
setAwesomeQuery(patch.awesomeQuery);
|
|
11408
|
+
setAwesomeCategory(patch.awesomeCategory);
|
|
11409
|
+
setAwesomeTags(patch.awesomeTags);
|
|
11410
|
+
setAwesomeSort(patch.awesomeSort);
|
|
11411
|
+
setAwesomeOnlyInstalled(patch.awesomeOnlyInstalled);
|
|
11412
|
+
setAwesomePage(patch.awesomePage);
|
|
11413
|
+
}
|
|
11414
|
+
};
|
|
10525
11415
|
const loadAwesomeCatalog = useCallback(async (refresh = false, silent = false) => {
|
|
10526
11416
|
setAwesomeLoading(true); setAwesomeError('');
|
|
10527
11417
|
try {
|
|
@@ -10538,8 +11428,13 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10538
11428
|
name: item.name,
|
|
10539
11429
|
sig: String(item.version || '') + '|' + String(item.verifiedAgainst || '') + '|' + String(item.status || ''),
|
|
10540
11430
|
})))) refreshPulse();
|
|
10541
|
-
//
|
|
10542
|
-
|
|
11431
|
+
// 顺手把这次的目录留成下次的首屏快照:签名没变就不重写(省一次 3MB 序列化 + 落盘),
|
|
11432
|
+
// 写盘也推到空闲帧,不与这次渲染抢主线程(写失败静默,不影响本次渲染)。
|
|
11433
|
+
const snapshotSig = catalog.length + '|' + (res.fetchedAt || '') + '|' + marketplace;
|
|
11434
|
+
if (snapshotSig !== awesomeSnapshotSigRef.current) {
|
|
11435
|
+
awesomeSnapshotSigRef.current = snapshotSig;
|
|
11436
|
+
scheduleIdle(() => writeAwesomeSnapshot({ plugins: catalog, marketplace, fetchedAt: res.fetchedAt }), 5000);
|
|
11437
|
+
}
|
|
10543
11438
|
} catch (e) {
|
|
10544
11439
|
// 静默预取失败且已有快照 → 保留旧目录;没有快照才让错误条接管。
|
|
10545
11440
|
if (!silent || awesomeLenRef.current === 0) setAwesomeError(String(e?.message || e));
|
|
@@ -10573,12 +11468,35 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10573
11468
|
readmeSeq.current += 1; // 作废在途请求,避免关掉之后又被写回来
|
|
10574
11469
|
setReadmeView(null);
|
|
10575
11470
|
};
|
|
11471
|
+
// P2-3:hover 预取 README。宿主侧本来就有 7 天落盘缓存,这里只负责把「点开那一拍」
|
|
11472
|
+
// 的往返提前到鼠标停留的 200ms 里;重读(refresh)永远走真实请求。
|
|
11473
|
+
const prefetchReadme = (item) => {
|
|
11474
|
+
const key = readmeMemoKey(item);
|
|
11475
|
+
if (!item?.name) return;
|
|
11476
|
+
if (readDetailMemo(hoverMemo.readme, key)) return;
|
|
11477
|
+
scheduleHoverPrefetch(() => {
|
|
11478
|
+
const npm = typeof item.npm === 'string' ? item.npm : (item.npm && item.npm.name) || '';
|
|
11479
|
+
call('wbAwesomeReadme', { name: item.name, npm, repo: item.repo || '' })
|
|
11480
|
+
.then((res) => { if (res?.ok) writeDetailMemo(hoverMemo.readme, key, res); })
|
|
11481
|
+
.catch(() => { /* 预取失败留给点开时的正式请求报错 */ });
|
|
11482
|
+
});
|
|
11483
|
+
};
|
|
10576
11484
|
const loadAwesomeReadme = useCallback(async (item, refresh = false) => {
|
|
10577
11485
|
if (!item) return;
|
|
10578
11486
|
// 弹层的排版样式随首次打开注入。放在事件回调里而不是组件顶层,是为了不引入
|
|
10579
11487
|
// 任何新的 Hook —— 这也正是 no-conditional-hooks 测试要防的那类崩溃。
|
|
10580
11488
|
ensureReadmeStyle();
|
|
10581
11489
|
const seq = ++readmeSeq.current;
|
|
11490
|
+
const memoKey = readmeMemoKey(item);
|
|
11491
|
+
const precached = refresh === true ? null : readDetailMemo(hoverMemo.readme, memoKey);
|
|
11492
|
+
if (precached) {
|
|
11493
|
+
setReadmeView({
|
|
11494
|
+
item, loading: false, ok: true,
|
|
11495
|
+
markdown: precached.markdown || '', base: precached.base || '', error: '',
|
|
11496
|
+
source: precached.source || '', reason: precached.reason || '', truncated: precached.truncated === true,
|
|
11497
|
+
});
|
|
11498
|
+
return;
|
|
11499
|
+
}
|
|
10582
11500
|
const npm = typeof item.npm === 'string' ? item.npm : (item.npm && item.npm.name) || '';
|
|
10583
11501
|
setReadmeView({ item, loading: true, ok: false, markdown: '', base: '', error: '', source: '', reason: '', truncated: false });
|
|
10584
11502
|
let res = null;
|
|
@@ -10598,40 +11516,52 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10598
11516
|
setReadmeView({ item, loading: false, ok: false, markdown: '', base: '', error: message, source: '', reason: (res && res.reason) || '', truncated: false });
|
|
10599
11517
|
return;
|
|
10600
11518
|
}
|
|
11519
|
+
writeDetailMemo(hoverMemo.readme, memoKey, res);
|
|
10601
11520
|
setReadmeView({ item, loading: false, ok: true, markdown: res.markdown || '', base: res.base || '', error: '', source: res.source || '', reason: res.reason || '', truncated: res.truncated === true });
|
|
10602
11521
|
}, [call]);
|
|
10603
|
-
//
|
|
10604
|
-
//
|
|
10605
|
-
//
|
|
10606
|
-
//
|
|
11522
|
+
// 目录预取:以前市场一挂载就在 400ms 后把 2.98MB 的 DSH 社区目录拉下来,与连接器
|
|
11523
|
+
// 首屏抢 IPC 与主线程。P1-1:等连接器首屏落地(connectorSettled),再空等 1200ms,
|
|
11524
|
+
// 然后在空闲帧里才发;「已切到插件页」的路径仍是立即发(用户要看,就不该等)。
|
|
11525
|
+
// 只跑一次,失败由列表里的错误条 / 空态提供手动重试。
|
|
10607
11526
|
const awesomeAutoLoaded = useRef(false);
|
|
10608
11527
|
useEffect(() => {
|
|
10609
|
-
if (awesomeAutoLoaded.current) return;
|
|
10610
|
-
const
|
|
11528
|
+
if (awesomeAutoLoaded.current || !connectorSettled) return undefined;
|
|
11529
|
+
const fire = () => {
|
|
10611
11530
|
if (awesomeAutoLoaded.current) return;
|
|
10612
11531
|
awesomeAutoLoaded.current = true;
|
|
10613
11532
|
loadAwesomeCatalog(false, true);
|
|
10614
|
-
}
|
|
10615
|
-
|
|
10616
|
-
|
|
11533
|
+
};
|
|
11534
|
+
if (MARKET_PERF.prefetchMode === 'legacy') {
|
|
11535
|
+
const timer = setTimeout(fire, 400);
|
|
11536
|
+
return () => clearTimeout(timer);
|
|
11537
|
+
}
|
|
11538
|
+
let cancelIdle = null;
|
|
11539
|
+
const timer = setTimeout(() => { cancelIdle = scheduleIdle(fire, 3000); }, 1200);
|
|
11540
|
+
return () => { clearTimeout(timer); cancelIdle?.(); };
|
|
11541
|
+
}, [connectorSettled, loadAwesomeCatalog]);
|
|
10617
11542
|
// 用户已经在插件页时不等预取延迟:立即发(旧行为保留,预取只负责「还没切过来」)。
|
|
10618
11543
|
useEffect(() => {
|
|
10619
11544
|
if (tab !== 'plugin' || awesomeAutoLoaded.current) return;
|
|
10620
11545
|
awesomeAutoLoaded.current = true;
|
|
10621
11546
|
loadAwesomeCatalog(false, true);
|
|
10622
11547
|
}, [tab, loadAwesomeCatalog]);
|
|
10623
|
-
//
|
|
10624
|
-
//
|
|
10625
|
-
//
|
|
11548
|
+
// 专家目录预取(只为子页签徽标取条数):P1-2 同款节奏——连接器首屏后 idle 起算
|
|
11549
|
+
// 2500ms 才拉;用户切到专家页时 ExpertMarket 自己会立即拉,不等这条预取。
|
|
11550
|
+
// loadExpertCatalogShared 带模块级 TTL 缓存,专家页挂载时复用同一份,不会二连网络;
|
|
11551
|
+
// 组件卸载后回包只作废,不写状态。
|
|
10626
11552
|
useEffect(() => {
|
|
11553
|
+
if (!connectorSettled || tab === 'expert') return undefined;
|
|
10627
11554
|
let alive = true;
|
|
11555
|
+
let cancelIdle = null;
|
|
10628
11556
|
const timer = setTimeout(() => {
|
|
10629
|
-
|
|
10630
|
-
|
|
10631
|
-
|
|
10632
|
-
|
|
10633
|
-
|
|
10634
|
-
|
|
11557
|
+
cancelIdle = scheduleIdle(() => {
|
|
11558
|
+
loadExpertCatalogShared(call).then((data) => {
|
|
11559
|
+
if (alive) setExpertTotal((data.experts || []).length || null);
|
|
11560
|
+
}).catch(() => { /* 徽标读不到就先不显示 */ });
|
|
11561
|
+
}, 3000);
|
|
11562
|
+
}, MARKET_PERF.prefetchMode === 'legacy' ? 400 : 2500);
|
|
11563
|
+
return () => { alive = false; clearTimeout(timer); cancelIdle?.(); };
|
|
11564
|
+
}, [call, connectorSettled, tab]);
|
|
10635
11565
|
useEffect(() => {
|
|
10636
11566
|
// 目录加载完成后探一次;awesomeUpdateChecked 保证组件存续期内不重复;已有提示不再探。
|
|
10637
11567
|
if (tab !== 'plugin' || awesomeLoading || !awesomePlugins.length || awesomeError || awesomeUpdateHint) return;
|
|
@@ -10720,7 +11650,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10720
11650
|
setLoadoutOpen(false);
|
|
10721
11651
|
if (row?.kind === 'mcp') {
|
|
10722
11652
|
if (source.startsWith('connector:')) {
|
|
10723
|
-
|
|
11653
|
+
switchTab('connector');
|
|
10724
11654
|
setConnectorQuery(name);
|
|
10725
11655
|
setConnectorOnlyReady(false);
|
|
10726
11656
|
setConnectorPage(1);
|
|
@@ -10734,7 +11664,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10734
11664
|
return;
|
|
10735
11665
|
}
|
|
10736
11666
|
if (row?.kind === 'skill') {
|
|
10737
|
-
|
|
11667
|
+
switchTab('skill');
|
|
10738
11668
|
setSkillPage(1);
|
|
10739
11669
|
setSkillKeywordInput(name);
|
|
10740
11670
|
setSkillKeyword(name);
|
|
@@ -10744,12 +11674,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10744
11674
|
if (row?.kind === 'expert') {
|
|
10745
11675
|
seedExpertMarketQuery(name);
|
|
10746
11676
|
setExpertEpoch((value) => value + 1);
|
|
10747
|
-
|
|
11677
|
+
switchTab('expert');
|
|
10748
11678
|
setNote(t('已定位到专家市场:{0}', [name]));
|
|
10749
11679
|
return;
|
|
10750
11680
|
}
|
|
10751
11681
|
setAwesomeQuery(name);
|
|
10752
|
-
|
|
11682
|
+
switchTab('plugin');
|
|
10753
11683
|
setNote(t('已定位到插件市场:{0}', [name]));
|
|
10754
11684
|
};
|
|
10755
11685
|
|
|
@@ -10794,10 +11724,17 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10794
11724
|
h('div', { className: 'dsh-mcp-mk-subtabs', role: 'tablist', 'aria-label': t('WorkBuddy 市场') },
|
|
10795
11725
|
MARKET_TABS.map((row) => h('button', {
|
|
10796
11726
|
key: row.key, type: 'button', role: 'tab', 'aria-selected': tab === row.key,
|
|
10797
|
-
className: 'dsh-mcp-mk-subtab', onClick: () =>
|
|
11727
|
+
className: 'dsh-mcp-mk-subtab', onClick: () => switchTab(row.key),
|
|
10798
11728
|
}, row.label, row.count ? h('i', null, row.count) : null)),
|
|
10799
|
-
//
|
|
11729
|
+
// 右侧:免责声明 + 装备清单入口 + 面包屑(面包屑 aria-hidden:当前位置已由选中页签表达)。
|
|
11730
|
+
// 声明从页签下方的一行挪到这里:不占纵向高度,窄屏自动省略(完整口径在 title 里)。
|
|
10800
11731
|
h('div', { className: 'dsh-mcp-mk-topright' },
|
|
11732
|
+
h('span', {
|
|
11733
|
+
className: 'dsh-mcp-mk-note',
|
|
11734
|
+
role: 'note',
|
|
11735
|
+
title: t('连接器来自 WorkBuddy 官方分发(static.workbuddy.cn);技能来自 SkillHub 公开 API;专家来自 WorkBuddy 专家中心分发。')
|
|
11736
|
+
+ t('第三方接口的字段可能随上游改版变化,安装前请在详情里核对传输类型与凭据要求。'),
|
|
11737
|
+
}, t('连接器 / 技能 / 专家均为第三方分发,本面板只做浏览与安装入口;内容版权归原作者所有。')),
|
|
10801
11738
|
h('button', {
|
|
10802
11739
|
type: 'button', className: 'dsh-mcp-loadout-open',
|
|
10803
11740
|
title: t('导出本机配置,或导入朋友的清单做差异对比'),
|
|
@@ -10810,23 +11747,71 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10810
11747
|
),
|
|
10811
11748
|
),
|
|
10812
11749
|
),
|
|
10813
|
-
// §6.5
|
|
10814
|
-
// 收敛为一行摘要 + details 展开全文,默认折叠。
|
|
10815
|
-
h('details', { className: 'dsh-mcp-mk-disclaim' },
|
|
10816
|
-
h('summary', null, t('连接器 / 技能 / 专家均为第三方分发,本面板只做浏览与安装入口;内容版权归原作者所有。')),
|
|
10817
|
-
h('p', null,
|
|
10818
|
-
t('连接器来自 WorkBuddy 官方分发(static.workbuddy.cn);技能来自 SkillHub 公开 API;专家来自 WorkBuddy 专家中心分发。') +
|
|
10819
|
-
t('第三方接口的字段可能随上游改版变化,安装前请在详情里核对传输类型与凭据要求。')),
|
|
10820
|
-
),
|
|
11750
|
+
// §6.5 免责声明已挪到子页签行右侧(.dsh-mcp-mk-note),这里不再占一行。
|
|
10821
11751
|
// 「最近动静」:本机快照对比,只报真实的目录变化;没有变化就说没有。
|
|
10822
11752
|
h(MarketPulse, {
|
|
10823
11753
|
state: pulse,
|
|
10824
11754
|
onClear: () => { clearMarketPulse(); setPulse(emptyMarketPulse()); },
|
|
10825
11755
|
onJump: (meta) => {
|
|
10826
|
-
if (meta
|
|
11756
|
+
if (!meta?.tab) return;
|
|
11757
|
+
// 直达全量市场:清掉该页签残留的搜索 / 筛选 / 排序 / 页码,再切过去。
|
|
11758
|
+
applyFullMarket(meta.tab);
|
|
11759
|
+
switchTab(meta.tab);
|
|
10827
11760
|
},
|
|
10828
11761
|
}),
|
|
11762
|
+
// 连接器来源切换:同一个列表位置两个来源,默认仍停在 WorkBuddy(行为与改造前一致)。
|
|
11763
|
+
// 用 .dsh-mcp-seg 与作用域切换同一观感;切到社区不卸载 WB 侧的搜索/筛选/页码。
|
|
10829
11764
|
tab === 'connector'
|
|
11765
|
+
? h('div', { className: 'dsh-mcp-wb-toolbar dsh-mcp-wb-feedbar' },
|
|
11766
|
+
h('div', { className: 'dsh-mcp-seg', role: 'tablist', 'aria-label': t('连接器来源') },
|
|
11767
|
+
h('button', {
|
|
11768
|
+
type: 'button', role: 'tab', 'aria-selected': connectorFeed === 'wb', className: 'dsh-mcp-seg-item',
|
|
11769
|
+
title: t('WorkBuddy 官方分发的连接器;多数需要 WorkBuddy 账号授权'),
|
|
11770
|
+
onClick: () => setConnectorFeed('wb'),
|
|
11771
|
+
},
|
|
11772
|
+
t('WorkBuddy 连接器'),
|
|
11773
|
+
connectors.length ? h('span', { className: 'dsh-mcp-seg-count' }, connectors.length) : null,
|
|
11774
|
+
),
|
|
11775
|
+
h('button', {
|
|
11776
|
+
type: 'button', role: 'tab', 'aria-selected': connectorFeed === 'community', className: 'dsh-mcp-seg-item',
|
|
11777
|
+
title: t('官方 MCP Registry 的社区条目,不依赖 WorkBuddy 账号'),
|
|
11778
|
+
onClick: () => setConnectorFeed('community'),
|
|
11779
|
+
},
|
|
11780
|
+
t('社区 MCP'),
|
|
11781
|
+
communityReadTotal ? h('span', { className: 'dsh-mcp-seg-count' }, communityReadTotal) : null,
|
|
11782
|
+
),
|
|
11783
|
+
),
|
|
11784
|
+
)
|
|
11785
|
+
: null,
|
|
11786
|
+
// 未审核声明常驻(可折叠就等于没有):社区条目由第三方提交,命令与环境变量自行确认。
|
|
11787
|
+
tab === 'connector' && connectorFeed === 'community'
|
|
11788
|
+
? h('div', { className: 'dsh-mcp-wb-note warn', role: 'note' },
|
|
11789
|
+
t('社区 MCP 来自官方 MCP Registry,由社区提交;军械库未审核其代码,加入前请自行确认命令与环境变量。'))
|
|
11790
|
+
: null,
|
|
11791
|
+
tab === 'connector' && connectorFeed === 'community'
|
|
11792
|
+
? h('div', { className: 'dsh-mcp-wb-toolbar' },
|
|
11793
|
+
h(WbSearchBox, {
|
|
11794
|
+
value: communityQueryInput, placeholder: t('搜索社区 MCP(名称 / 说明)'), busy: communityLoading,
|
|
11795
|
+
onChange: (value) => setCommunityQueryInput(value),
|
|
11796
|
+
// 回车跳过防抖立即生效(防抖本身也会触发,幂等)。
|
|
11797
|
+
onCommit: () => setCommunityQuery(communityQueryInput.trim()),
|
|
11798
|
+
}),
|
|
11799
|
+
h('label', {
|
|
11800
|
+
className: 'dsh-mcp-check',
|
|
11801
|
+
title: t('只显示带 npx / uvx 启动包的条目;远程 HTTP 条目需要自备令牌'),
|
|
11802
|
+
},
|
|
11803
|
+
h('input', { type: 'checkbox', checked: communityOnlyRunnable, onChange: (e) => setCommunityOnlyRunnable(e.target.checked) }),
|
|
11804
|
+
t('只看可直接运行(npx / uvx)'),
|
|
11805
|
+
),
|
|
11806
|
+
h('span', { className: 'dsh-mcp-builtin-count' },
|
|
11807
|
+
t('已读取 {0} 条', [communityReadTotal]) + (communityStale ? t(' · 缓存结果') : '')),
|
|
11808
|
+
communityOnlyRunnable && communityHiddenCount > 0
|
|
11809
|
+
? h('span', { className: 'dsh-mcp-builtin-count', title: t('这些条目只有远程地址,需要自备令牌') },
|
|
11810
|
+
t('已隐藏 {0} 个远程条目', [communityHiddenCount]))
|
|
11811
|
+
: null,
|
|
11812
|
+
)
|
|
11813
|
+
: null,
|
|
11814
|
+
tab === 'connector' && connectorFeed === 'wb'
|
|
10830
11815
|
? h('div', { className: 'dsh-mcp-wb-toolbar' },
|
|
10831
11816
|
connectorSearch,
|
|
10832
11817
|
h('label', { className: 'dsh-mcp-check', title: t('默认只显示可直接安装的条目;取消勾选可看到「仅展示」的不兼容条目') },
|
|
@@ -10858,7 +11843,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10858
11843
|
: null,
|
|
10859
11844
|
// T02/R2:visible_in 标签筛选 chips(只在连接器 tab 且目录里确实出现标签时显示)。
|
|
10860
11845
|
// 语义:未选任何标签 = 不过滤;选中后任一命中即保留(OR);没标 visible_in 的行不受影响。
|
|
10861
|
-
tab === 'connector' && connectorVisibleTagUniverse.length
|
|
11846
|
+
tab === 'connector' && connectorFeed === 'wb' && connectorVisibleTagUniverse.length
|
|
10862
11847
|
? h('div', { className: 'dsh-mcp-wb-toolbar dsh-mcp-wb-tagbar' },
|
|
10863
11848
|
h('span', { className: 'dsh-mcp-wb-tagbar-label' }, t('适用环境:')),
|
|
10864
11849
|
connectorVisibleTagUniverse.map(({ tag, count }) => h('button', {
|
|
@@ -10917,11 +11902,16 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10917
11902
|
onClick: () => loadSkills(skillPage, skillSort, skillKeyword),
|
|
10918
11903
|
}, t('重试')))
|
|
10919
11904
|
: null,
|
|
10920
|
-
loading && !connectors.length && tab === 'connector' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取连接器目录…')) : null,
|
|
11905
|
+
loading && !connectors.length && tab === 'connector' && connectorFeed === 'wb' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取连接器目录…')) : null,
|
|
11906
|
+
// 首次加载 / 换搜索词后的第一批:还没有这一批的结果时显示读取中(旧结果不冒充新搜索)。
|
|
11907
|
+
communityLoading && tab === 'connector' && connectorFeed === 'community'
|
|
11908
|
+
&& (!communityRows.length || communityLoadedQueryRef.current !== communityQuery)
|
|
11909
|
+
? h('div', { className: 'dsh-mcp-wb-empty', role: 'status' }, t('正在读取社区 MCP…'))
|
|
11910
|
+
: null,
|
|
10921
11911
|
skillLoading && !skills.length && tab === 'skill' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取技能目录…')) : null,
|
|
10922
11912
|
// §1.4 「只看可直接安装」把本页筛空了:这是本页最容易踩的坑(默认勾选),
|
|
10923
11913
|
// 主操作就是取消它——一句话说清「这里本来有条目,是筛选挡掉了」。
|
|
10924
|
-
!loading && tab === 'connector' && !visibleConnectors.length
|
|
11914
|
+
!loading && tab === 'connector' && connectorFeed === 'wb' && !visibleConnectors.length
|
|
10925
11915
|
? (connectors.length
|
|
10926
11916
|
? h(EmptyState, {
|
|
10927
11917
|
icon: 'store',
|
|
@@ -10943,6 +11933,30 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10943
11933
|
actions: [{ label: t('重新读取目录'), icon: 'refresh', onClick: () => { loadConnectors(); } }],
|
|
10944
11934
|
}))
|
|
10945
11935
|
: null,
|
|
11936
|
+
// 社区加载失败:错误只落在列表内(带重试),不影响 WB 连接器;上次结果还在时
|
|
11937
|
+
// 由工具栏的「缓存结果」标注说明,不在这里重复一遍。
|
|
11938
|
+
tab === 'connector' && connectorFeed === 'community' && communityError
|
|
11939
|
+
? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' },
|
|
11940
|
+
t('社区列表加载失败:{0}', [communityError]), ' ',
|
|
11941
|
+
h('button', {
|
|
11942
|
+
type: 'button', className: 'dsh-mcp-btn', disabled: communityLoading,
|
|
11943
|
+
onClick: () => loadCommunity({ cursor: '' }),
|
|
11944
|
+
}, t('重试')))
|
|
11945
|
+
: null,
|
|
11946
|
+
// 社区空结果:搜索无命中与「只看可直接运行」筛空是两种不同的空,提示分开。
|
|
11947
|
+
!communityLoading && tab === 'connector' && connectorFeed === 'community' && !communityError && !communityVisibleRows.length
|
|
11948
|
+
? h(EmptyState, {
|
|
11949
|
+
icon: 'store',
|
|
11950
|
+
tight: true,
|
|
11951
|
+
title: t('没有匹配的社区条目'),
|
|
11952
|
+
hint: communityOnlyRunnable && communityHiddenCount > 0
|
|
11953
|
+
? t('有 {0} 条只有远程地址的条目被「只看可直接运行」挡住了。', [communityHiddenCount])
|
|
11954
|
+
: t('换一个更短的关键词,或取消「只看可直接运行」。'),
|
|
11955
|
+
actions: communityOnlyRunnable && communityHiddenCount > 0
|
|
11956
|
+
? [{ label: t('显示全部条目'), icon: 'eye', onClick: () => setCommunityOnlyRunnable(false) }]
|
|
11957
|
+
: [{ label: t('重新读取'), icon: 'refresh', onClick: () => loadCommunity({ cursor: '' }) }],
|
|
11958
|
+
})
|
|
11959
|
+
: null,
|
|
10946
11960
|
// §1.4 技能:搜索与「只看已安装」是两种完全不同的空,说法必须分开——
|
|
10947
11961
|
// 否则用户会以为 SkillHub 里根本没有这个词。
|
|
10948
11962
|
!skillLoading && tab === 'skill' && !skills.length
|
|
@@ -10965,9 +11979,15 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10965
11979
|
actions: [{ label: t('取消「只看已安装」'), icon: 'x', onClick: () => setSkillOnlyInstalled(false) }],
|
|
10966
11980
|
})
|
|
10967
11981
|
: null,
|
|
10968
|
-
tab === 'connector' && visibleConnectors.length
|
|
11982
|
+
tab === 'connector' && connectorFeed === 'wb' && visibleConnectors.length
|
|
10969
11983
|
? h('div', { className: 'dsh-mcp-wb-grid' },
|
|
10970
|
-
visibleConnectors.map((item) => h('div', {
|
|
11984
|
+
visibleConnectors.map((item) => h('div', {
|
|
11985
|
+
key: item.id,
|
|
11986
|
+
className: 'dsh-mcp-card wb' + (item.installable ? '' : ' blocked'),
|
|
11987
|
+
// P2-3:停住 200ms 才预取授权概览;扫过就离开不发请求。
|
|
11988
|
+
onMouseEnter: () => prefetchConnectorDetail(item),
|
|
11989
|
+
onMouseLeave: cancelHoverPrefetch,
|
|
11990
|
+
},
|
|
10971
11991
|
// §6.2 不兼容卡片:原来只在卡片底部一行 10.5px 橙字「不兼容:…」,
|
|
10972
11992
|
// 用户要滚到卡底才知道装不了。改成右上角橙色角标,一眼可见;
|
|
10973
11993
|
// 完整原因仍留在 blockedReason,hover 卡片或角标都能看到。
|
|
@@ -10979,10 +11999,9 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10979
11999
|
h(ConnectorAvatar, { call, connectorId: item.id, name: item.name, className: 'dsh-mcp-card-avatar' }),
|
|
10980
12000
|
h('div', null, h('div', { className: 'dsh-mcp-card-name' }, item.name)),
|
|
10981
12001
|
),
|
|
10982
|
-
// T04/R8
|
|
10983
|
-
|
|
10984
|
-
|
|
10985
|
-
: h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? t('可安装') : t('仅展示')),
|
|
12002
|
+
// T04/R8 + P0:安装账本只说明「写进过配置」;徽标按真实连接态细分
|
|
12003
|
+
// (已连接 / 需授权 / 未连接),找不到同名 server 时退回「已安装」。
|
|
12004
|
+
connectorBadge(item),
|
|
10986
12005
|
),
|
|
10987
12006
|
h('div', { className: 'dsh-mcp-card-summary' },
|
|
10988
12007
|
// T02/R3:examples 优先(displayBody 由 host 侧 displayCopyOf 产出),
|
|
@@ -11003,6 +12022,15 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11003
12022
|
),
|
|
11004
12023
|
h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
11005
12024
|
h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openConnectorDetail(item) }, t('详情')),
|
|
12025
|
+
// P0:已安装但当前没连上 → 就地给出口(鉴权失败给「重新授权」,其余给「重连」),
|
|
12026
|
+
// 不让用户为了一个红点去 MCP 面板自己找。
|
|
12027
|
+
installedConnectorKeys.has(String(item.id || '')) && connectorHealth(item) !== 'connected' && connectorHealth(item) !== 'unknown'
|
|
12028
|
+
? h('button', {
|
|
12029
|
+
type: 'button', className: 'dsh-mcp-btn', disabled: busy,
|
|
12030
|
+
title: connectorHealth(item) === 'auth' ? t('服务器拒绝当前凭据:重新走一次授权') : t('拆掉这条连接并按当前配置重建'),
|
|
12031
|
+
onClick: () => reconnectConnector(item),
|
|
12032
|
+
}, connectorHealth(item) === 'auth' ? t('重新授权') : t('重连'))
|
|
12033
|
+
: null,
|
|
11006
12034
|
item.installable
|
|
11007
12035
|
? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => requestInstallConnector(item) }, t('安装'))
|
|
11008
12036
|
: h('span', {
|
|
@@ -11013,9 +12041,91 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11013
12041
|
)),
|
|
11014
12042
|
)
|
|
11015
12043
|
: null,
|
|
11016
|
-
tab === 'connector' && connectorPageCount > 1
|
|
12044
|
+
tab === 'connector' && connectorFeed === 'wb' && connectorPageCount > 1
|
|
11017
12045
|
? h(Pager, { page: safeConnectorPage, pageCount: connectorPageCount, total: sortedConnectors.length, onPage: setConnectorPage })
|
|
11018
12046
|
: null,
|
|
12047
|
+
// ── 社区 MCP 卡片 ──
|
|
12048
|
+
// 与 WB 连接器同一视觉(.dsh-mcp-card wb)。字段只填 Registry 真实有的内容:
|
|
12049
|
+
// 标题 / 完整命名空间 / 描述 / 传输类型 / 环境变量数量 / 密钥字段 / 版本。
|
|
12050
|
+
// 命令不折行塞卡片:完整 command / args / env 在点开卡片后的详情弹层里只读展示,
|
|
12051
|
+
// 要改则走「加入 MCP」的预填弹层——两层各司其职,避免卡片被长命令行撑变形。
|
|
12052
|
+
tab === 'connector' && connectorFeed === 'community' && communityVisibleRows.length
|
|
12053
|
+
&& !(communityLoading && communityLoadedQueryRef.current !== communityQuery)
|
|
12054
|
+
? h('div', { className: 'dsh-mcp-wb-grid' },
|
|
12055
|
+
communityVisibleRows.map((row) => {
|
|
12056
|
+
const joined = communityJoined(row);
|
|
12057
|
+
const detailLabel = t('查看 {0} 的详情', [row.title || row.name]);
|
|
12058
|
+
const repoHref = externalHref(row.repoUrl);
|
|
12059
|
+
// 卡片本身不再是 role=button:里面已有「加入 MCP」按钮与仓库链接,
|
|
12060
|
+
// 把整张卡声明成按钮会产生嵌套交互控件,aria-label 还会吞掉整段卡片内容。
|
|
12061
|
+
// 鼠标点卡片任意处仍可开详情;键盘用户走操作区里真正的「详情」按钮。
|
|
12062
|
+
return h('div', {
|
|
12063
|
+
key: row.key,
|
|
12064
|
+
className: 'dsh-mcp-card wb clickable',
|
|
12065
|
+
title: detailLabel,
|
|
12066
|
+
onClick: () => setCommunityDetail(row),
|
|
12067
|
+
},
|
|
12068
|
+
h('div', { className: 'dsh-mcp-card-top' },
|
|
12069
|
+
h('div', { className: 'dsh-mcp-card-person' },
|
|
12070
|
+
// 头像只在 owner 可解析时才探图(与技能卡同一代理通道);解析不出用字母占位。
|
|
12071
|
+
h(Avatar, {
|
|
12072
|
+
url: row.owner ? avatarProxyUrl('https://github.com/' + row.owner + '.png', 64) : '',
|
|
12073
|
+
name: row.title || row.name,
|
|
12074
|
+
className: 'dsh-mcp-card-avatar',
|
|
12075
|
+
}),
|
|
12076
|
+
h('div', null,
|
|
12077
|
+
h('div', { className: 'dsh-mcp-card-name' }, row.title || row.name),
|
|
12078
|
+
h('div', { className: 'dsh-mcp-card-vendor', title: row.name }, row.name),
|
|
12079
|
+
),
|
|
12080
|
+
),
|
|
12081
|
+
joined ? h(Badge, { cls: 's-on', title: t('当前 MCP 列表里已有同名服务器') }, t('已加入')) : null,
|
|
12082
|
+
),
|
|
12083
|
+
h('div', { className: 'dsh-mcp-card-summary' }, row.description || row.name),
|
|
12084
|
+
h('div', { className: 'dsh-mcp-card-tags' },
|
|
12085
|
+
h(Badge, { cls: row.transport === 'http' ? 't-http' : 't-stdio' },
|
|
12086
|
+
row.transport === 'http' ? t('远程(需自备令牌)') : 'stdio'),
|
|
12087
|
+
row.env.length ? h(Badge, { title: row.env.map((item) => item.name).join(' / ') }, t('需 {0} 个环境变量', [row.env.length])) : null,
|
|
12088
|
+
row.env.some((item) => item.secret) ? h(Badge, { cls: 's-warn' }, t('含密钥字段')) : null,
|
|
12089
|
+
row.version ? h(Badge, null, 'v' + row.version) : null,
|
|
12090
|
+
),
|
|
12091
|
+
h('div', { className: 'dsh-mcp-wb-card-actions', onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation() },
|
|
12092
|
+
h('button', {
|
|
12093
|
+
type: 'button', className: 'dsh-mcp-btn', 'aria-label': detailLabel,
|
|
12094
|
+
onClick: () => setCommunityDetail(row),
|
|
12095
|
+
}, t('详情')),
|
|
12096
|
+
h('button', {
|
|
12097
|
+
type: 'button', className: 'dsh-mcp-btn primary', disabled: busy || joined,
|
|
12098
|
+
title: joined ? t('当前 MCP 列表里已有同名服务器') : t('预填一条 MCP 配置,确认后写入全局配置'),
|
|
12099
|
+
onClick: () => openCommunityDraft(row),
|
|
12100
|
+
}, joined ? t('已加入') : t('加入 MCP')),
|
|
12101
|
+
repoHref
|
|
12102
|
+
? h('a', { className: 'dsh-mcp-btn', href: repoHref, target: '_blank', rel: 'noreferrer' }, t('仓库'))
|
|
12103
|
+
: null,
|
|
12104
|
+
),
|
|
12105
|
+
);
|
|
12106
|
+
}),
|
|
12107
|
+
)
|
|
12108
|
+
: null,
|
|
12109
|
+
// 游标翻页:Registry 不返回总页数,只提供「第 N 页 + 下一页是否有」。
|
|
12110
|
+
// 下一页只在用户点击时才请求;当前页内容原地替换,不累加。
|
|
12111
|
+
// 用「已读取 > 0」而不是「本页有条目」作为显示条件:某页刚好被筛空时,
|
|
12112
|
+
// 上一页 / 下一页仍要在,否则用户会被困在空白页里。
|
|
12113
|
+
tab === 'connector' && connectorFeed === 'community' && communityReadTotal > 0
|
|
12114
|
+
? h('div', { className: 'dsh-mcp-wb-more' },
|
|
12115
|
+
h('button', {
|
|
12116
|
+
type: 'button', className: 'dsh-mcp-btn',
|
|
12117
|
+
disabled: communityLoading || communityPrevCursor === null,
|
|
12118
|
+
onClick: () => loadCommunity({ cursor: communityPrevCursor || '', page: communityPage - 1 }),
|
|
12119
|
+
}, t('上一页')),
|
|
12120
|
+
h('span', { className: 'dsh-mcp-builtin-count' }, t('第 {0} 页', [communityPage])),
|
|
12121
|
+
h('button', {
|
|
12122
|
+
type: 'button', className: 'dsh-mcp-btn',
|
|
12123
|
+
disabled: communityLoading || !communityNextCursor,
|
|
12124
|
+
title: communityNextCursor ? undefined : t('已经到底了'),
|
|
12125
|
+
onClick: () => loadCommunity({ cursor: communityNextCursor || '', page: communityPage + 1 }),
|
|
12126
|
+
}, communityLoading ? t('正在加载…') : t('下一页')),
|
|
12127
|
+
)
|
|
12128
|
+
: null,
|
|
11019
12129
|
tab === 'skill' && shownSkills.length
|
|
11020
12130
|
? h('div', { className: 'dsh-mcp-wb-grid' },
|
|
11021
12131
|
shownSkills.map((skill) => {
|
|
@@ -11024,7 +12134,13 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11024
12134
|
// 安装时间:wbSkillInstalled 行自带(账本首次安装时间,无账本回落目录创建时间)。
|
|
11025
12135
|
const installedAt = installedSkills.get(installedKey) || 0;
|
|
11026
12136
|
const installedLabel = installedAt ? t('安装于 {0}', [wbFormatDate(installedAt)]) : '';
|
|
11027
|
-
return h('div', {
|
|
12137
|
+
return h('div', {
|
|
12138
|
+
key: skill.namespace + '/' + skill.slug,
|
|
12139
|
+
className: 'dsh-mcp-card wb',
|
|
12140
|
+
// P2-3:同上,停住才预取技能详情。
|
|
12141
|
+
onMouseEnter: () => prefetchSkillDetail(skill),
|
|
12142
|
+
onMouseLeave: cancelHoverPrefetch,
|
|
12143
|
+
},
|
|
11028
12144
|
h('div', { className: 'dsh-mcp-card-top' },
|
|
11029
12145
|
h('div', { className: 'dsh-mcp-card-person' },
|
|
11030
12146
|
h(Avatar, {
|
|
@@ -11115,7 +12231,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11115
12231
|
const sealTip = verifiedLabel ? verifiedLabel + (verifiedDate ? ' · ' + t('核验 {0}', [verifiedDate]) : '') : '';
|
|
11116
12232
|
const installed = installedPluginKeys.has(String(item.name || ''));
|
|
11117
12233
|
const hostComponent = item.npm && String(item.npm).startsWith('@deepseek-ai/');
|
|
11118
|
-
return h('div', { key: item.name, className: 'dsh-mcp-pcard', role: 'button', tabIndex: 0, title: t('查看 README 介绍与插图'), 'aria-label': t('查看 {0} 的 README', [item.name]), onClick: () => loadAwesomeReadme(item), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadAwesomeReadme(item); } } },
|
|
12234
|
+
return h('div', { key: item.name, className: 'dsh-mcp-pcard', role: 'button', tabIndex: 0, title: t('查看 README 介绍与插图'), 'aria-label': t('查看 {0} 的 README', [item.name]), onClick: () => loadAwesomeReadme(item), onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadAwesomeReadme(item); } }, onMouseEnter: () => prefetchReadme(item), onMouseLeave: cancelHoverPrefetch },
|
|
11119
12235
|
h('div', { className: 'dsh-mcp-phead' },
|
|
11120
12236
|
h('span', { className: 'dsh-mcp-pavatar', 'aria-hidden': true },
|
|
11121
12237
|
String(item.name || '?').slice(0, 1).toUpperCase() || '?',
|
|
@@ -11220,14 +12336,20 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11220
12336
|
seen.add(slot.placeholder);
|
|
11221
12337
|
slots.push(slot);
|
|
11222
12338
|
}
|
|
11223
|
-
return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setTokenDialog(null) },
|
|
12339
|
+
return h('div', { className: 'dsh-mcp-overlay dsh-mcp-front', onClick: (e) => !busy && e.target === e.currentTarget && setTokenDialog(null) },
|
|
11224
12340
|
h('div', { 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(); setTokenDialog(null); } } },
|
|
11225
12341
|
h('h3', null, t('填写 {0} 的凭据', [tokenDialog.item.name])),
|
|
11226
12342
|
h('p', { className: 'dsh-mcp-wb-modal-note', role: 'note' },
|
|
11227
12343
|
t('该连接器的配置里有 {0} 个占位符,需要你提供对应凭据才能生效。没填的会保持原样,安装后仍可在 MCP 配置里手动补全。', [slots.length])),
|
|
11228
12344
|
slots.length
|
|
11229
12345
|
? h('div', { className: 'dsh-mcp-wb-tokenform' }, slots.map((slot) => h('label', { key: slot.placeholder, className: 'dsh-mcp-wb-tokenfield' },
|
|
11230
|
-
h('span', { className: 'dsh-mcp-wb-tokenlabel' }, slot.placeholder + t('({0})', [
|
|
12346
|
+
h('span', { className: 'dsh-mcp-wb-tokenlabel' }, slot.placeholder + t('({0})', [
|
|
12347
|
+
slot.container === 'header'
|
|
12348
|
+
? t('请求头 ') + slot.key
|
|
12349
|
+
: slot.container === 'url'
|
|
12350
|
+
? t('URL 参数')
|
|
12351
|
+
: t('环境变量 ') + slot.key,
|
|
12352
|
+
])),
|
|
11231
12353
|
h('input', {
|
|
11232
12354
|
type: 'password',
|
|
11233
12355
|
autoComplete: 'off',
|
|
@@ -11252,7 +12374,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11252
12374
|
tab === 'expert' ? h(ExpertMarket, { call, key: 'expert-' + expertEpoch }) : null,
|
|
11253
12375
|
// ── R8:OAuth 授权弹层(401 → 浏览器授权 → 自动重装)──
|
|
11254
12376
|
authDialog
|
|
11255
|
-
? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && closeAuthDialog() },
|
|
12377
|
+
? h('div', { className: 'dsh-mcp-overlay dsh-mcp-front', onClick: (e) => !busy && e.target === e.currentTarget && closeAuthDialog() },
|
|
11256
12378
|
h('div', { ref: authDialogRefNode, className: 'dsh-mcp-modal dsh-mcp-wb-modal', role: 'dialog', 'aria-modal': true, 'aria-label': t('授权 {0}', [authDialog.serverName]), tabIndex: -1, onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); closeAuthDialog(); } } },
|
|
11257
12379
|
h('h3', null, t('授权 {0}', [authDialog.serverName])),
|
|
11258
12380
|
h('p', { className: 'dsh-mcp-wb-modal-note', role: 'note' },
|
|
@@ -11336,6 +12458,14 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11336
12458
|
item: connectorDetail, kind: 'connector',
|
|
11337
12459
|
sourceLabel: t('来源:WorkBuddy 官方分发'),
|
|
11338
12460
|
}),
|
|
12461
|
+
// issue #3:R3 让 examples 占正文,description 只在没有 examples 时才出现——于是
|
|
12462
|
+
// NVIDIA App 这类「需先装本机客户端」的前置要求被整段藏掉。examples 在场时补一行
|
|
12463
|
+
// 说明,把要求原样透出(英文界面取 descriptionEn)。
|
|
12464
|
+
connectorDetail.displayFrom === 'examples' && (connectorDetail.description || connectorDetail.descriptionEn)
|
|
12465
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note' },
|
|
12466
|
+
t('说明:{0}', [usesEnglish()
|
|
12467
|
+
? (connectorDetail.descriptionEn || connectorDetail.description)
|
|
12468
|
+
: connectorDetail.description])) : null,
|
|
11339
12469
|
// T02/R3:详情正文同样 examples 优先,description 兜底。
|
|
11340
12470
|
connectorDetail.displayBody || connectorDetail.description
|
|
11341
12471
|
? h('p', { className: 'dsh-mcp-wb-modal-desc' }, connectorDetail.displayBody || connectorDetail.description) : null,
|
|
@@ -11351,15 +12481,68 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11351
12481
|
connectorDetail.authMode ? h(Badge, null, t('凭据: ') + connectorDetail.authMode) : null,
|
|
11352
12482
|
)
|
|
11353
12483
|
: null,
|
|
12484
|
+
// P0:授权状态(只看已安装的连接器)。此前 wbOAuthOverview 声明了却从没被调用,
|
|
12485
|
+
// 用户看不到「授权还有效吗 / 什么时候过期」——token 一过期就只剩一句连接失败。
|
|
12486
|
+
installedConnectorKeys.has(String(connectorDetail.id || '')) && (connectorAuth || connectorDetail.needsOAuth)
|
|
12487
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12488
|
+
h('b', null, t('授权状态')),
|
|
12489
|
+
connectorAuth?.loading
|
|
12490
|
+
? h('p', null, t('正在读取授权状态…'))
|
|
12491
|
+
: connectorAuth?.authorized
|
|
12492
|
+
? h('p', null, connectorAuth.fresh === false
|
|
12493
|
+
? t('授权已过期({0}),点「刷新授权」重新获取;刷不动会自动回到浏览器授权。', [authExpiryText(connectorAuth.expiresAt) || t('无有效期记录')])
|
|
12494
|
+
: t('已授权{0}', [connectorAuth.expiresAt ? t(',有效期至 {0}', [authExpiryText(connectorAuth.expiresAt)]) : '']))
|
|
12495
|
+
: h('p', null, connectorDetail.needsOAuth
|
|
12496
|
+
? t('尚未授权:安装时会引导你完成浏览器授权。')
|
|
12497
|
+
: t('没有可用的授权记录。')),
|
|
12498
|
+
connectorAuth?.refreshable
|
|
12499
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('服务器提供了 refresh_token:过期后可以在这里直接刷新,无需重走浏览器。'))
|
|
12500
|
+
: null,
|
|
12501
|
+
connectorAuth?.authorized
|
|
12502
|
+
? h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
12503
|
+
h(Btn, { disabled: busy, onClick: () => refreshConnectorAuth(connectorDetail), children: t('刷新授权') }))
|
|
12504
|
+
: connectorDetail.needsOAuth
|
|
12505
|
+
? h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
12506
|
+
h(Btn, { disabled: busy, onClick: () => beginConnectorAuth(connectorDetail), children: t('去授权') }))
|
|
12507
|
+
: null,
|
|
12508
|
+
)
|
|
12509
|
+
: null,
|
|
11354
12510
|
connectorDetail.examples && connectorDetail.examples.length
|
|
11355
12511
|
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
11356
12512
|
h('b', null, t('用法示例')),
|
|
11357
12513
|
h('ul', null, connectorDetail.examples.slice(0, 4).map((example, index) => h('li', { key: index }, example))),
|
|
11358
12514
|
)
|
|
11359
12515
|
: null,
|
|
12516
|
+
// 本机固定依赖(issue #3):本地客户端型连接器把绝对路径写死在 mcp.json 里。
|
|
12517
|
+
// 这里直接列出路径与静态检查结果;缺依赖时安装闸会拦住,不再写一条连不上的配置。
|
|
12518
|
+
connectorDetail.localDeps && connectorDetail.localDeps.length
|
|
12519
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12520
|
+
h('b', null, t('本机依赖')),
|
|
12521
|
+
h('ul', null, connectorDetail.localDeps.map((dep) => h('li', { key: dep.path },
|
|
12522
|
+
dep.status === 'found'
|
|
12523
|
+
? t('已找到:{0}', [dep.path])
|
|
12524
|
+
: dep.status === 'platform'
|
|
12525
|
+
? t('当前系统不支持:{0}', [dep.path])
|
|
12526
|
+
: t('未找到:{0}', [dep.path])))),
|
|
12527
|
+
connectorDetail.localDeps.some((dep) => dep.status !== 'found')
|
|
12528
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note' },
|
|
12529
|
+
t('本地客户端缺失时安装会失败;请先安装对应客户端,并开启它的 MCP / 集成访问后再重试。'))
|
|
12530
|
+
: null,
|
|
12531
|
+
)
|
|
12532
|
+
: null,
|
|
11360
12533
|
h('p', { className: 'dsh-mcp-wb-modal-note' },
|
|
11361
12534
|
t(`安装 = 把该连接器的 mcpServers 合并写入当前作用域(与「导入 JSON」同一链路,同名跳过)。`) +
|
|
11362
12535
|
(connectorDetail.skillCount ? t('另含 {0} 个连接器技能,如需请从技能市场或手动安装。', [connectorDetail.skillCount]) : '')),
|
|
12536
|
+
// OAuth 型连接器的逃生口:浏览器授权发现失败时仍可手填令牌;入口常驻在详情里,
|
|
12537
|
+
// 不必先点一次安装失败才出现。
|
|
12538
|
+
connectorDetail.installable && connectorDetail.needsOAuth
|
|
12539
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12540
|
+
h('b', null, t('凭据来源')),
|
|
12541
|
+
h('p', null, t('该连接器需要通过浏览器完成一次授权;如果你已经从服务方拿到访问令牌,也可以在这里直接填写。')),
|
|
12542
|
+
h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
12543
|
+
h(Btn, { disabled: busy, onClick: () => openManualTokenDialog(connectorDetail), children: t('我已有令牌') })),
|
|
12544
|
+
)
|
|
12545
|
+
: null,
|
|
11363
12546
|
h('div', { className: 'dsh-mcp-modal-actions' },
|
|
11364
12547
|
h(Btn, { onClick: () => setConnectorDetail(null), disabled: busy, children: t('取消') }),
|
|
11365
12548
|
connectorDetail.installable
|
|
@@ -11371,8 +12554,8 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11371
12554
|
: null,
|
|
11372
12555
|
// ── 技能详情弹层:安全报告 + 发布方 + 安装/卸载 ──
|
|
11373
12556
|
skillDetail
|
|
11374
|
-
? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget &&
|
|
11375
|
-
h('div', { ref: skillDetailRef, 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();
|
|
12557
|
+
? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && closeSkillDetail() },
|
|
12558
|
+
h('div', { ref: skillDetailRef, 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(); closeSkillDetail(); } } },
|
|
11376
12559
|
h('div', { className: 'dsh-mcp-detail-person' },
|
|
11377
12560
|
h(Avatar, {
|
|
11378
12561
|
url: skillDetail.iconUrl || githubAvatarUrl(skillDetail.homepage, 88),
|
|
@@ -11421,7 +12604,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11421
12604
|
t('安装 = 逐文件下载并 sha256 校验后写入 ~/.agents/skills/,即刻被 DSH 发现(无需重启)。已装版本重装即更新。')),
|
|
11422
12605
|
),
|
|
11423
12606
|
h('div', { className: 'dsh-mcp-modal-actions' },
|
|
11424
|
-
h(Btn, { onClick: () =>
|
|
12607
|
+
h(Btn, { onClick: () => closeSkillDetail(), disabled: busy, children: t('关闭') }),
|
|
11425
12608
|
installedSkills.has(skillInstalledKey(skillDetail))
|
|
11426
12609
|
? h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => uninstallSkill(skillDetail), children: t('卸载') })
|
|
11427
12610
|
: h(Btn, { primary: true, icon: 'plus', disabled: busy || skillDetail.loading, onClick: () => installSkill(skillDetail), children: busy ? t('安装中…') : t('安装') }),
|
|
@@ -11429,6 +12612,92 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11429
12612
|
),
|
|
11430
12613
|
)
|
|
11431
12614
|
: null,
|
|
12615
|
+
// ── 社区 MCP 详情弹层:整卡点开,内容与交互对齐插件 README 弹层 ──
|
|
12616
|
+
// 只展示 Registry 已给的字段:command / args / env 只读,要改走「加入 MCP」预填。
|
|
12617
|
+
communityDetail
|
|
12618
|
+
? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setCommunityDetail(null) },
|
|
12619
|
+
h('div', {
|
|
12620
|
+
ref: communityDetailRef,
|
|
12621
|
+
className: 'dsh-mcp-modal dsh-mcp-wb-modal',
|
|
12622
|
+
role: 'dialog',
|
|
12623
|
+
'aria-modal': true,
|
|
12624
|
+
'aria-label': t('社区 MCP 详情'),
|
|
12625
|
+
tabIndex: -1,
|
|
12626
|
+
onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setCommunityDetail(null); } },
|
|
12627
|
+
},
|
|
12628
|
+
h('div', { className: 'dsh-mcp-detail-person' },
|
|
12629
|
+
h(Avatar, {
|
|
12630
|
+
url: communityDetail.owner ? avatarProxyUrl('https://github.com/' + communityDetail.owner + '.png', 88) : '',
|
|
12631
|
+
name: communityDetail.title || communityDetail.name,
|
|
12632
|
+
className: 'dsh-mcp-detail-avatar',
|
|
12633
|
+
}),
|
|
12634
|
+
h('div', null,
|
|
12635
|
+
h('h3', null, communityDetail.title || communityDetail.name),
|
|
12636
|
+
h('div', { className: 'dsh-mcp-card-vendor', title: communityDetail.name }, communityDetail.name),
|
|
12637
|
+
),
|
|
12638
|
+
),
|
|
12639
|
+
h('div', { className: 'dsh-mcp-card-tags' },
|
|
12640
|
+
h(Badge, { cls: communityDetail.transport === 'http' ? 't-http' : 't-stdio' },
|
|
12641
|
+
communityDetail.transport === 'http' ? t('远程(需自备令牌)') : 'stdio'),
|
|
12642
|
+
communityDetail.version ? h(Badge, null, 'v' + communityDetail.version) : null,
|
|
12643
|
+
communityDetail.env.length ? h(Badge, null, t('需 {0} 个环境变量', [communityDetail.env.length])) : null,
|
|
12644
|
+
communityDetail.env.some((item) => item.secret) ? h(Badge, { cls: 's-warn' }, t('含密钥字段')) : null,
|
|
12645
|
+
communityJoined(communityDetail) ? h(Badge, { cls: 's-on' }, t('已加入')) : null,
|
|
12646
|
+
),
|
|
12647
|
+
communityDetail.description
|
|
12648
|
+
? h('p', { className: 'dsh-mcp-wb-modal-desc' }, communityDetail.description)
|
|
12649
|
+
: null,
|
|
12650
|
+
// 环境变量:默认值原样展示(可能含令牌),但绝不写进配置;是否落盘由用户在草稿里决定。
|
|
12651
|
+
communityDetail.env.length
|
|
12652
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12653
|
+
h('b', null, t('环境变量')),
|
|
12654
|
+
h('ul', null, communityDetail.env.map((item) => h('li', { key: item.name },
|
|
12655
|
+
h('code', null, item.name),
|
|
12656
|
+
' · ' + (item.required ? t('必填') : t('可选')),
|
|
12657
|
+
item.secret ? ' · ' + t('密钥') : '',
|
|
12658
|
+
item.description ? ' — ' + item.description : '',
|
|
12659
|
+
item.hasDefault ? ' · ' + t('默认值:') + item.default : ''))),
|
|
12660
|
+
)
|
|
12661
|
+
: null,
|
|
12662
|
+
communityDetail.runnable
|
|
12663
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12664
|
+
h('b', null, t('启动命令')),
|
|
12665
|
+
h('p', { className: 'dsh-mcp-cmdline' }, [communityDetail.command].concat(communityDetail.args || []).filter(Boolean).join(' ')))
|
|
12666
|
+
: communityDetail.remoteUrl
|
|
12667
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12668
|
+
h('b', null, t('远程地址')),
|
|
12669
|
+
h('p', { className: 'dsh-mcp-cmdline' }, communityDetail.remoteUrl))
|
|
12670
|
+
: null,
|
|
12671
|
+
h('p', { className: 'dsh-mcp-wb-modal-note' },
|
|
12672
|
+
t('社区 MCP 来自官方 MCP Registry,由社区提交;军械库未审核其代码,加入前请自行确认命令与环境变量。')),
|
|
12673
|
+
h('div', { className: 'dsh-mcp-modal-actions' },
|
|
12674
|
+
h(Btn, { disabled: busy, onClick: () => setCommunityDetail(null), children: t('关闭') }),
|
|
12675
|
+
externalHref(communityDetail.repoUrl)
|
|
12676
|
+
? h('a', { className: 'dsh-mcp-btn', href: externalHref(communityDetail.repoUrl), target: '_blank', rel: 'noreferrer' }, t('仓库'))
|
|
12677
|
+
: null,
|
|
12678
|
+
communityJoined(communityDetail)
|
|
12679
|
+
? h(Btn, { disabled: true, children: t('已加入') })
|
|
12680
|
+
: h(Btn, {
|
|
12681
|
+
primary: true, icon: 'plus', disabled: busy,
|
|
12682
|
+
// 先关详情再开草稿:两层弹层不叠加,焦点陷阱也不会互抢。
|
|
12683
|
+
onClick: () => { const row = communityDetail; setCommunityDetail(null); openCommunityDraft(row); },
|
|
12684
|
+
children: t('加入 MCP'),
|
|
12685
|
+
}),
|
|
12686
|
+
),
|
|
12687
|
+
),
|
|
12688
|
+
)
|
|
12689
|
+
: null,
|
|
12690
|
+
// 社区条目的「加入 MCP」:与 MCP 页同一个表单,draft 模式(预填 + 提交走新增)。
|
|
12691
|
+
// 不静默写盘:用户必须在这层确认 command / args / env 后才落盘。
|
|
12692
|
+
h(AddEditModal, {
|
|
12693
|
+
open: !!communityDraft,
|
|
12694
|
+
initial: communityDraft,
|
|
12695
|
+
draft: true,
|
|
12696
|
+
onClose: () => setCommunityDraft(null),
|
|
12697
|
+
onSubmit: submitCommunityDraft,
|
|
12698
|
+
busy,
|
|
12699
|
+
scopeName: t('全局'),
|
|
12700
|
+
}),
|
|
11432
12701
|
// 装备清单:导出 / 导入对比。只读,不自动安装。
|
|
11433
12702
|
loadoutOpen
|
|
11434
12703
|
? h(LoadoutModal, {
|
|
@@ -12685,6 +13954,10 @@ const MCP_CONTRIBUTION = {
|
|
|
12685
13954
|
{ id: 'dsh-armory#mcpManager/skillDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'skillDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillDetailResult') },
|
|
12686
13955
|
{ id: 'dsh-armory#mcpManager/revealSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'revealSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12687
13956
|
{ id: 'dsh-armory#mcpManager/removeSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'removeSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
13957
|
+
// 禁用 / 释放 skill(工具裁缝技能账本行内按钮):写 SKILL.md frontmatter 的
|
|
13958
|
+
// disable-model-invocation 标志。与 typert.js、lib/index.js 三处同步,漏一处恒 404。
|
|
13959
|
+
{ id: 'dsh-armory#mcpManager/disableSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'disableSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
13960
|
+
{ id: 'dsh-armory#mcpManager/enableSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'enableSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12688
13961
|
{ id: 'dsh-armory#mcpManager/skillInstallInfo', service: 'mcpManager', namespace: 'mcpManager', method: 'skillInstallInfo', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillInstallInfoResult') },
|
|
12689
13962
|
// 自定义套件:读全量集合 / 整集合覆盖保存(与 typert.js、lib/index.js 三处同步,
|
|
12690
13963
|
// 漏一处这条通道恒 404 —— toolTailor、installInfo 各踩过一次)。
|
|
@@ -12705,6 +13978,7 @@ const MCP_CONTRIBUTION = {
|
|
|
12705
13978
|
{ id: 'dsh-armory#mcpManager/wbOAuthCancel', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthCancel', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12706
13979
|
{ id: 'dsh-armory#mcpManager/wbOAuthToken', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthToken', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12707
13980
|
{ id: 'dsh-armory#mcpManager/wbOAuthOverview', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthOverview', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
13981
|
+
{ id: 'dsh-armory#mcpManager/wbOAuthRefresh', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthRefresh', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12708
13982
|
{ id: 'dsh-armory#mcpManager/wbOAuthOpen', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthOpen', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12709
13983
|
{ id: 'dsh-armory#mcpManager/wbSkillCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbSkillCatalog', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbSkillListResult') },
|
|
12710
13984
|
{ id: 'dsh-armory#mcpManager/wbSkillDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbSkillDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbSkillDetailResult') },
|
|
@@ -12723,6 +13997,9 @@ const MCP_CONTRIBUTION = {
|
|
|
12723
13997
|
{ id: 'dsh-armory#mcpManager/wbAwesomeDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeDetailResult') },
|
|
12724
13998
|
{ id: 'dsh-armory#mcpManager/wbAwesomeReadme', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeReadme', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeReadmeResult') },
|
|
12725
13999
|
{ id: 'dsh-armory#mcpManager/wbAwesomeUpdateCheck', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeUpdateCheck', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbUpdateHintResult') },
|
|
14000
|
+
// 社区 MCP(官方 MCP Registry v0,只读):连接器页第二个来源。payload {search, cursor,
|
|
14001
|
+
// limit, takenNames},返回 {ok, rows, nextCursor, fetchedAt, cached}。三处同步,漏一处恒 404。
|
|
14002
|
+
{ id: 'dsh-armory#mcpManager/wbMcpCommunityList', service: 'mcpManager', namespace: 'mcpManager', method: 'wbMcpCommunityList', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbMcpCommunityResult') },
|
|
12726
14003
|
// 社区元信息(仓库维护信号):payload {repos, refresh},返回 {repos:{repo:{...}}}。
|
|
12727
14004
|
{ id: 'dsh-armory#mcpManager/wbRepoMeta', service: 'mcpManager', namespace: 'mcpManager', method: 'wbRepoMeta', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbRepoMetaResult') },
|
|
12728
14005
|
// npm 近 30 天下载量:payload {packages, refresh},返回 {packages:{pkg:{ok,downloads,...}}}。
|
|
@@ -12747,6 +14024,8 @@ const MCP_CONTRIBUTION = {
|
|
|
12747
14024
|
};
|
|
12748
14025
|
// ── 插件入口:挂 RPC、注入样式、注册六个界面槽位 ──────────────────────────────
|
|
12749
14026
|
async function apply(ctx) {
|
|
14027
|
+
// P0-2:apply 起止(相对 bundle materialize 起点)。开关关闭时 PERF_ON 为 false,空转。
|
|
14028
|
+
if (PERF_ON) perfMark('apply', 'start', performance.now() - PERF_T0);
|
|
12750
14029
|
// OPT-25:先接宿主 locale(设置 → 通用里的语言),再注册槽位——槽位 label 是
|
|
12751
14030
|
// 注册时求值的字符串,必须等这里算完。ctx.get 是可选读取,不写进 inject:
|
|
12752
14031
|
// 老版本 DSH 没有 locale 服务时返回 undefined,自动回落 navigator/zh-CN。
|
|
@@ -12785,13 +14064,24 @@ async function apply(ctx) {
|
|
|
12785
14064
|
disposeCss = __injectCss(MCP_CSS);
|
|
12786
14065
|
timer = createTimer();
|
|
12787
14066
|
MODULE_CTX.ctx = ctx;
|
|
14067
|
+
// P0-2:首个 business RPC 的方法名与往返耗时。第一次调用之后不再计时(只付一次分支)。
|
|
14068
|
+
let firstRpcDone = !PERF_ON;
|
|
12788
14069
|
const call = async (method, args) => {
|
|
12789
|
-
const
|
|
12790
|
-
|
|
12791
|
-
|
|
12792
|
-
|
|
12793
|
-
|
|
12794
|
-
|
|
14070
|
+
const rpcStartedAt = firstRpcDone ? 0 : performance.now();
|
|
14071
|
+
try {
|
|
14072
|
+
const svc = (typeof ctx.get === 'function' ? ctx.get('remote.mcpManager') : null) || null;
|
|
14073
|
+
if (!svc || typeof svc[method] !== 'function') return { ok: false, error: t('MCP 市场服务未连接(面板仍可用)') };
|
|
14074
|
+
const r = args === undefined || args === null ? await svc[method]() : await svc[method](args);
|
|
14075
|
+
if (r && r.ok) return { ok: true, ...((r.value && typeof r.value === 'object') ? r.value : {}) };
|
|
14076
|
+
if (r && !r.ok) return { ok: false, error: (r.error && (r.error.message || r.error.code)) || t('调用失败') };
|
|
14077
|
+
return r;
|
|
14078
|
+
} finally {
|
|
14079
|
+
if (rpcStartedAt) {
|
|
14080
|
+
firstRpcDone = true;
|
|
14081
|
+
perfMark('firstRpc', 'ms', performance.now() - rpcStartedAt);
|
|
14082
|
+
if (perfBucket) perfBucket.firstRpc.method = method;
|
|
14083
|
+
}
|
|
14084
|
+
}
|
|
12795
14085
|
};
|
|
12796
14086
|
// 一键升级要用官方 pluginManager,而它只在 apply 的 ctx 上取得到(面板组件拿不到 ctx——
|
|
12797
14087
|
// 它的 props 来自 slot 的 inject)。所以在这里取好、随 inject 传进面板,与 call 同一手法;
|
|
@@ -12874,6 +14164,7 @@ async function apply(ctx) {
|
|
|
12874
14164
|
ctx.slots.inject('sidebar.footer.action', () =>
|
|
12875
14165
|
ctx.slots.register({ name: 'sidebar.footer.action', id: 'mcp-manager', order: 100, label: panelLabel() }, McpFooterAction),
|
|
12876
14166
|
);
|
|
14167
|
+
if (PERF_ON) perfMark('apply', 'end', performance.now() - PERF_T0);
|
|
12877
14168
|
return cleanup;
|
|
12878
14169
|
} catch (error) {
|
|
12879
14170
|
await cleanup();
|
|
@@ -12883,6 +14174,8 @@ async function apply(ctx) {
|
|
|
12883
14174
|
const inject = ['slots', 'remote', 'typert', 'remote.pluginManager'];
|
|
12884
14175
|
exports.apply = apply;
|
|
12885
14176
|
exports.inject = inject;
|
|
14177
|
+
// P0-2:bundle 解析 + 全部组件定义完成的耗时(开关关闭时 perfMark 空转)。
|
|
14178
|
+
if (PERF_ON) perfMark('module', 'materialize', performance.now() - PERF_T0);
|
|
12886
14179
|
|
|
12887
14180
|
return module.exports;
|
|
12888
14181
|
}
|