dsh-armory 2.4.3 → 2.4.5
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 +10 -3
- package/README.md +18 -5
- package/docs/perf-market-load.md +469 -0
- package/docs/project-mcp-lifecycle.md +2 -0
- package/lib/client.js +1677 -146
- package/lib/expert-market.js +17 -1
- package/lib/index.js +323 -64
- package/lib/mcp-community.js +439 -0
- package/lib/mcp-config.js +15 -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 +13 -0
- package/lib/wb-auth-rules.js +47 -16
- package/lib/wb-market.js +85 -1
- package/lib/whats-new-en.js +44 -0
- package/lib/whats-new.js +50 -0
- package/lib/win32-stdio.js +194 -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,95 @@ 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
|
+
"重连失败": "Reconnect failed",
|
|
1185
|
+
// 顶部进度浮层(安装 / 加入 MCP / 重连共用一枚浮层,分组只换措辞)。
|
|
1186
|
+
"正在处理 {0} 个 MCP 任务…": "Handling {0} MCP task(s)…",
|
|
1187
|
+
"MCP 任务已结束": "MCP tasks finished",
|
|
1188
|
+
"写入配置中…": "Writing config…",
|
|
1189
|
+
"已加入 · 宿主自动连接中": "Added · the host is connecting",
|
|
1190
|
+
"加入失败": "Add failed",
|
|
1191
|
+
"正在刷新授权…": "Refreshing authorization…",
|
|
1192
|
+
"{0}:授权已刷新,正在把新凭据写回配置…": "{0}: authorization refreshed, writing the new credential back to the config…",
|
|
1193
|
+
"刷新授权失败": "Couldn't refresh authorization",
|
|
1194
|
+
"服务器拒绝当前凭据:重新走一次授权": "The server rejected the credential; run authorization again",
|
|
1195
|
+
"拆掉这条连接并按当前配置重建": "Tear down this connection and rebuild it from the current config",
|
|
1196
|
+
"授权状态": "Authorization status",
|
|
1197
|
+
"正在读取授权状态…": "Reading authorization status…",
|
|
1198
|
+
"授权已过期({0}),点「刷新授权」重新获取;刷不动会自动回到浏览器授权。": "Authorization expired ({0}). Use Refresh authorization; if that fails you'll be taken back to browser authorization.",
|
|
1199
|
+
"无有效期记录": "no expiry recorded",
|
|
1200
|
+
"已授权{0}": "Authorized{0}",
|
|
1201
|
+
",有效期至 {0}": ", valid until {0}",
|
|
1202
|
+
"尚未授权:安装时会引导你完成浏览器授权。": "Not authorized yet: installing will walk you through browser authorization.",
|
|
1203
|
+
"没有可用的授权记录。": "No usable authorization record.",
|
|
1204
|
+
"服务器提供了 refresh_token:过期后可以在这里直接刷新,无需重走浏览器。": "The server issued a refresh_token: once it expires you can refresh here without repeating browser authorization.",
|
|
1205
|
+
"刷新授权": "Refresh authorization",
|
|
1206
|
+
"去授权": "Authorize",
|
|
1207
|
+
"凭据来源": "Where the credential comes from",
|
|
1208
|
+
"我已有令牌": "I already have a token",
|
|
1209
|
+
"服务器拒绝当前凭据(401/403 类):重连不会恢复,需要重新授权": "The server rejected the credential (401/403): reconnecting won't help, re-authorize instead",
|
|
1210
|
+
"需授权": "Needs authorization",
|
|
1211
|
+
"这台服务器拒绝当前凭据(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.",
|
|
1212
|
+
|
|
1109
1213
|
":": ": ",
|
|
1214
|
+
|
|
1215
|
+
// ── 社区 MCP(连接器页第二个来源:官方 MCP Registry)──────────────────────
|
|
1216
|
+
// 新增文案统一放这一节,方便与常量 / 面板代码就近对照;键仍进双向差集护栏。
|
|
1217
|
+
"连接器来源": "Connector source",
|
|
1218
|
+
"WorkBuddy 连接器": "WorkBuddy connectors",
|
|
1219
|
+
"WorkBuddy 官方分发的连接器;多数需要 WorkBuddy 账号授权": "Connectors distributed by WorkBuddy; most need WorkBuddy account authorization",
|
|
1220
|
+
"社区 MCP": "Community MCP",
|
|
1221
|
+
"官方 MCP Registry 的社区条目,不依赖 WorkBuddy 账号": "Community entries from the official MCP Registry; no WorkBuddy account needed",
|
|
1222
|
+
"社区 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.",
|
|
1223
|
+
"搜索社区 MCP(名称 / 说明)": "Search community MCPs (name / description)",
|
|
1224
|
+
"只看可直接运行(npx / uvx)": "Only directly runnable (npx / uvx)",
|
|
1225
|
+
"只显示带 npx / uvx 启动包的条目;远程 HTTP 条目需要自备令牌": "Only show entries with an npx / uvx launch package; remote HTTP entries need your own token",
|
|
1226
|
+
"已读取 {0} 条": "Read {0} entries",
|
|
1227
|
+
" · 缓存结果": " · cached result",
|
|
1228
|
+
"这些条目只有远程地址,需要自备令牌": "These entries only have a remote URL and need your own token",
|
|
1229
|
+
"已隐藏 {0} 个远程条目": "Hidden {0} remote entries",
|
|
1230
|
+
"正在读取社区 MCP…": "Loading community MCPs…",
|
|
1231
|
+
"读取社区 MCP 失败": "Failed to read community MCPs",
|
|
1232
|
+
"社区列表加载失败:{0}": "Failed to load the community list: {0}",
|
|
1233
|
+
"没有匹配的社区条目": "No matching community entries",
|
|
1234
|
+
"有 {0} 条只有远程地址的条目被「只看可直接运行」挡住了。": "{0} entries with only a remote URL are hidden by \"Only directly runnable\".",
|
|
1235
|
+
"换一个更短的关键词,或取消「只看可直接运行」。": "Try a shorter keyword, or turn off \"Only directly runnable\".",
|
|
1236
|
+
"正在加载…": "Loading…",
|
|
1237
|
+
"已经到底了": "No more results",
|
|
1238
|
+
"第 {0} 页": "Page {0}",
|
|
1239
|
+
// 社区目录全库总数(徽标常驻):官方 Registry 不返回总数,只能逐页统计,所以分「共 N 条」
|
|
1240
|
+
// 与「至少 N 条」两种口径,别让下界被读成上限。
|
|
1241
|
+
"正在后台统计社区目录条目数,稍后自动显示…": "Counting the community catalog in the background — it shows up shortly…",
|
|
1242
|
+
"社区目录共 {0} 条(逐页统计;官方 Registry 不返回总数)": "The community catalog holds {0} entries (counted page by page; the official Registry returns no total)",
|
|
1243
|
+
"社区目录至少 {0} 条,仍在后台续爬统计(官方 Registry 不返回总数)": "The community catalog holds at least {0} entries - still counting in the background (the official Registry returns no total)",
|
|
1244
|
+
"本次查询已逐页读取的条数;上方徽标显示的是社区目录全库总数": "Entries read page by page for this query; the badge above shows the whole community catalog",
|
|
1245
|
+
"WorkBuddy 官方目录里的连接器总数": "Total connectors in the official WorkBuddy catalog",
|
|
1246
|
+
"正在读取连接器列表…": "Loading the connector list…",
|
|
1247
|
+
"加入 MCP": "Add MCP",
|
|
1248
|
+
"已加入": "Added",
|
|
1249
|
+
"已加入 ": "Added ",
|
|
1250
|
+
"预填一条 MCP 配置,确认后写入全局配置": "Prefill an MCP config and write it to the global config after you confirm",
|
|
1251
|
+
"当前 MCP 列表里已有同名服务器": "A server with the same name already exists in your MCP list",
|
|
1252
|
+
"远程(需自备令牌)": "Remote (bring your own token)",
|
|
1253
|
+
"需 {0} 个环境变量": "Needs {0} env vars",
|
|
1254
|
+
"含密钥字段": "Has secret fields",
|
|
1255
|
+
"有 {0} 个必填环境变量为空,服务器可能启动失败:{1}": "{0} required env vars are empty; the server may fail to start: {1}",
|
|
1256
|
+
"查看 {0} 的详情": "View details for {0}",
|
|
1257
|
+
"社区 MCP 详情": "Community MCP details",
|
|
1258
|
+
"启动命令": "Launch command",
|
|
1259
|
+
"远程地址": "Remote URL",
|
|
1260
|
+
"密钥": "Secret",
|
|
1261
|
+
"仓库": "Repository",
|
|
1110
1262
|
};
|
|
1111
1263
|
// 模块加载时先用 ①/③ 判定的 locale 装载一次;apply() 拿到宿主 locale 后会再按
|
|
1112
1264
|
// ② 重算(见 applyI18nLocale)。zh-CN(源语言)保持空字典恒等直出,其他语言
|
|
@@ -1223,9 +1375,54 @@ function writeAwesomeSnapshot(payload) {
|
|
|
1223
1375
|
});
|
|
1224
1376
|
// 超出配额安全线就不写:留着旧快照比写坏一次更划算。
|
|
1225
1377
|
if (text.length > AWESOME_SNAPSHOT_MAX_BYTES) return;
|
|
1378
|
+
// 与连接器快照共用 4.5MB 预算(对称):两边都看对方的现存字节数,谁后写都不会
|
|
1379
|
+
// 把合计顶过线,否则先写的那份会被下一次写入挤爆 localStorage。
|
|
1380
|
+
let otherBytes = 0;
|
|
1381
|
+
try { otherBytes = (localStorage.getItem(CONNECTOR_SNAPSHOT_KEY) || '').length; } catch { otherBytes = 0; }
|
|
1382
|
+
if (otherBytes + text.length > SNAPSHOT_BUDGET_BYTES) return;
|
|
1226
1383
|
localStorage.setItem(AWESOME_SNAPSHOT_KEY, text);
|
|
1227
1384
|
} catch { /* 配额溢出 / 隐私模式:快照只是加速,失败不致命 */ }
|
|
1228
1385
|
}
|
|
1386
|
+
// ── P1-4:连接器目录快照(浏览器侧镜像)─────────────────────────────────
|
|
1387
|
+
// 连接器目录要读 48MB 的 ZIP 再解出 769KB 索引,宿主侧约 350ms,再加 IPC 与 JSON
|
|
1388
|
+
// 反序列化;而用户重开市场的第一眼永远是这一屏。这里把上次成功渲染的目录留一份
|
|
1389
|
+
// (≤1.5MB / 24h),重开时先铺快照、再静默刷新。
|
|
1390
|
+
// 与插件快照共用 4.5MB 预算:两份合起来超线就不写这一份——宁可少一层加速,也不要把
|
|
1391
|
+
// localStorage 写满(写满会让本插件和同源其它插件一起失效)。
|
|
1392
|
+
const CONNECTOR_SNAPSHOT_KEY = KEY_PREFIX + '.connectorCatalog.v1';
|
|
1393
|
+
const CONNECTOR_SNAPSHOT_MAX_AGE = 24 * 60 * 60 * 1000;
|
|
1394
|
+
const CONNECTOR_SNAPSHOT_MAX_BYTES = 1.5 * 1024 * 1024;
|
|
1395
|
+
const SNAPSHOT_BUDGET_BYTES = 4.5 * 1024 * 1024;
|
|
1396
|
+
// 本次会话内的内存镜像:面板关掉再打开不必重新 parse localStorage(也躲开 24h 过期判定)。
|
|
1397
|
+
let connectorCatalogMemo = null;
|
|
1398
|
+
function readConnectorSnapshot() {
|
|
1399
|
+
if (!MARKET_PERF.connectorSnapshot) return null;
|
|
1400
|
+
try {
|
|
1401
|
+
const raw = localStorage.getItem(CONNECTOR_SNAPSHOT_KEY);
|
|
1402
|
+
if (!raw) return null;
|
|
1403
|
+
const parsed = JSON.parse(raw);
|
|
1404
|
+
if (!parsed || !Array.isArray(parsed.connectors) || !parsed.connectors.length) return null;
|
|
1405
|
+
const savedAt = Number(parsed.savedAt || 0);
|
|
1406
|
+
if (!savedAt || Date.now() - savedAt > CONNECTOR_SNAPSHOT_MAX_AGE) return null;
|
|
1407
|
+
return parsed;
|
|
1408
|
+
} catch { return null; }
|
|
1409
|
+
}
|
|
1410
|
+
function writeConnectorSnapshot(payload) {
|
|
1411
|
+
if (!MARKET_PERF.connectorSnapshot) return;
|
|
1412
|
+
try {
|
|
1413
|
+
if (!payload || !Array.isArray(payload.connectors) || !payload.connectors.length) return;
|
|
1414
|
+
const text = JSON.stringify({
|
|
1415
|
+
savedAt: Date.now(),
|
|
1416
|
+
source: payload.source || '',
|
|
1417
|
+
connectors: payload.connectors,
|
|
1418
|
+
});
|
|
1419
|
+
if (text.length > CONNECTOR_SNAPSHOT_MAX_BYTES) return;
|
|
1420
|
+
let otherBytes = 0;
|
|
1421
|
+
try { otherBytes = (localStorage.getItem(AWESOME_SNAPSHOT_KEY) || '').length; } catch { otherBytes = 0; }
|
|
1422
|
+
if (otherBytes + text.length > SNAPSHOT_BUDGET_BYTES) return;
|
|
1423
|
+
localStorage.setItem(CONNECTOR_SNAPSHOT_KEY, text);
|
|
1424
|
+
} catch { /* 配额溢出 / 隐私模式:快照只是加速,失败不致命 */ }
|
|
1425
|
+
}
|
|
1229
1426
|
// ── 更新探测节流:把「每次进面板都 HEAD 一发」压到 6 小时一次 ────────────────
|
|
1230
1427
|
// 目录本身已有服务端 stale-while-revalidate 在后台自动换新,这个 ETag 探测只是
|
|
1231
1428
|
// 锦上添花;没必要每开一次面板就多打一次网络、多一次误报机会。
|
|
@@ -1238,6 +1435,58 @@ function writeAwesomeProbeAt(value) {
|
|
|
1238
1435
|
try { localStorage.setItem(AWESOME_PROBE_AT_KEY, String(value)); } catch { /* 隐私模式:退化为不节流 */ }
|
|
1239
1436
|
}
|
|
1240
1437
|
|
|
1438
|
+
// ── 社区目录「全库总数」:连接器页那枚常驻徽标上的数 ─────────────────────────
|
|
1439
|
+
// 为什么不是直接问上游要:官方 Registry 不给总数——实测 `metadata.count` 就是本页条数
|
|
1440
|
+
// (limit=1 时 1、limit=100 时 100),`/v0/servers/count` 与 `/v0/registry/stats` 都是
|
|
1441
|
+
// 404,`limit>100` 直接 422。要数出来只能顺游标一页页爬,而全库实测 ≥13,000 条
|
|
1442
|
+
// (130 页仍未到底)、单页 2.4–8s,一次爬完要十几分钟和约 9MB,绝不能挡在用户面前。
|
|
1443
|
+
// 所以做成「有界 + 可续爬」:每次打开面板最多数 COMMUNITY_COUNT_PAGE_BUDGET 页,进度落
|
|
1444
|
+
// 在这里,下次接着数;数到底显示精确值,没到底显示下界「N+」。
|
|
1445
|
+
// 为什么值得为它写持久化:徽标原先显示的是「已读取 98 条」,用户会读成"社区一共只有 98 个"
|
|
1446
|
+
// (2026-10-30 用户反馈)。一个会慢慢长对的「1200+」比一个看起来像总数的「98」诚实得多。
|
|
1447
|
+
const COMMUNITY_COUNT_KEY = KEY_PREFIX + '.communityCount.v1';
|
|
1448
|
+
/** 数据层单块页数(lib/mcp-community.js 的 COMMUNITY_COUNT_CHUNK_PAGES 同值,靠测试钉住)。 */
|
|
1449
|
+
const COMMUNITY_COUNT_CHUNK_PAGES = 3;
|
|
1450
|
+
/** 一次面板会话的页数预算:4 块 × 3 页 = 12 页 = 1200 条,约 0.9MB。 */
|
|
1451
|
+
const COMMUNITY_COUNT_PAGE_BUDGET = 12;
|
|
1452
|
+
/** 空记录:还没数过。徽标显示「…」而不是消失——数字常驻才不会被当成"这一页就是全部"。 */
|
|
1453
|
+
const emptyCommunityCount = () => ({ counted: 0, cursor: '', done: false, at: 0 });
|
|
1454
|
+
|
|
1455
|
+
function readCommunityCount() {
|
|
1456
|
+
try {
|
|
1457
|
+
const raw = localStorage.getItem(COMMUNITY_COUNT_KEY);
|
|
1458
|
+
if (!raw) return null;
|
|
1459
|
+
const parsed = JSON.parse(raw);
|
|
1460
|
+
const counted = Number(parsed?.counted);
|
|
1461
|
+
if (!Number.isFinite(counted) || counted < 0) return null;
|
|
1462
|
+
return {
|
|
1463
|
+
counted: Math.floor(counted),
|
|
1464
|
+
cursor: typeof parsed.cursor === 'string' ? parsed.cursor : '',
|
|
1465
|
+
done: parsed.done === true,
|
|
1466
|
+
at: Number(parsed.at) || 0,
|
|
1467
|
+
};
|
|
1468
|
+
} catch { return null; }
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function writeCommunityCount(record) {
|
|
1472
|
+
// 只存四个标量(约 120 字节),不进快照那套 4.5MB 预算。
|
|
1473
|
+
try { localStorage.setItem(COMMUNITY_COUNT_KEY, JSON.stringify(record)); } catch { /* 隐私模式:下次进面板重数 */ }
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/** 徽标文本:没数过 / 数为 0 且没到底 → 「…」;数到底 → 精确值;否则 → 下界「N+」。 */
|
|
1477
|
+
function communityCountBadgeText(record) {
|
|
1478
|
+
if (!record) return '…';
|
|
1479
|
+
if (record.done) return String(record.counted);
|
|
1480
|
+
return record.counted > 0 ? `${record.counted}+` : '…';
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** 徽标 tooltip:把「这个数是什么口径」写清楚,别让 1200+ 被读成"只能看到 1200 个"。 */
|
|
1484
|
+
function communityCountBadgeTitle(record) {
|
|
1485
|
+
if (!record || !record.counted) return t('正在后台统计社区目录条目数,稍后自动显示…');
|
|
1486
|
+
if (record.done) return t('社区目录共 {0} 条(逐页统计;官方 Registry 不返回总数)', [String(record.counted)]);
|
|
1487
|
+
return t('社区目录至少 {0} 条,仍在后台续爬统计(官方 Registry 不返回总数)', [String(record.counted)]);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1241
1490
|
// ── 安装轻提示:挂在 document.body 上的常驻浮层 ─────────────────────────────
|
|
1242
1491
|
// 安装进度此前只存在组件本地 state(busy / busyLabel):用户一切到别的面板或窗口,
|
|
1243
1492
|
// 组件卸载 → 进度消失 → 看起来像「安装被取消了」。这个浮层直接挂 document.body,
|
|
@@ -1246,10 +1495,42 @@ function writeAwesomeProbeAt(value) {
|
|
|
1246
1495
|
// 写插件名,不拼 innerHTML。
|
|
1247
1496
|
const INSTALL_TOAST_ID = KEY_PREFIX + '-install-toast';
|
|
1248
1497
|
const INSTALL_TOAST_CSS_ID = KEY_PREFIX + '-install-toast-css';
|
|
1249
|
-
//
|
|
1498
|
+
// key → { name, phase: 'running' | 'ok' | 'fail', at, group, labels }
|
|
1499
|
+
// plugin 组的 key 就是插件名:isInstallRunning(名字) 靠它禁用「安装」按钮,这个约定不能改;
|
|
1500
|
+
// MCP 组的 key 带 'mcp:<动作>:<名称>' 前缀,既不会被同名插件顶掉一行,也不参与按钮禁用。
|
|
1250
1501
|
const installTasks = new Map();
|
|
1251
1502
|
const installTimers = new Map();
|
|
1252
1503
|
let installToastNode = null;
|
|
1504
|
+
/** 浮层分组:决定标题措辞。 */
|
|
1505
|
+
const PROGRESS_GROUP_PLUGIN = 'plugin';
|
|
1506
|
+
const PROGRESS_GROUP_MCP = 'mcp';
|
|
1507
|
+
// 状态文案一律写成「渲染时才调 t()」的函数:语言在模块加载之后还可能切换,
|
|
1508
|
+
// 提前求值会把首次加载那一刻的 locale 钉进常量里(顺带也满足全文件中文包裹不变式)。
|
|
1509
|
+
const PLUGIN_TASK_LABELS = {
|
|
1510
|
+
running: () => t('安装中…'),
|
|
1511
|
+
ok: () => t('已安装'),
|
|
1512
|
+
fail: () => t('安装失败'),
|
|
1513
|
+
};
|
|
1514
|
+
const MCP_ADD_TASK_LABELS = {
|
|
1515
|
+
running: () => t('写入配置中…'),
|
|
1516
|
+
ok: () => t('已加入 · 宿主自动连接中'),
|
|
1517
|
+
fail: () => t('加入失败'),
|
|
1518
|
+
};
|
|
1519
|
+
const MCP_RECONNECT_TASK_LABELS = {
|
|
1520
|
+
running: () => t('正在重连…'),
|
|
1521
|
+
ok: () => t('已请求重连'),
|
|
1522
|
+
fail: () => t('重连失败'),
|
|
1523
|
+
};
|
|
1524
|
+
const PROGRESS_TITLES = {
|
|
1525
|
+
[PROGRESS_GROUP_PLUGIN]: {
|
|
1526
|
+
running: (count) => t('正在安装 {0} 个插件…', [count]),
|
|
1527
|
+
done: () => t('安装任务已结束'),
|
|
1528
|
+
},
|
|
1529
|
+
[PROGRESS_GROUP_MCP]: {
|
|
1530
|
+
running: (count) => t('正在处理 {0} 个 MCP 任务…', [count]),
|
|
1531
|
+
done: () => t('MCP 任务已结束'),
|
|
1532
|
+
},
|
|
1533
|
+
};
|
|
1253
1534
|
function installToastCss() {
|
|
1254
1535
|
if (typeof document === 'undefined' || document.getElementById(INSTALL_TOAST_CSS_ID)) return;
|
|
1255
1536
|
const style = document.createElement('style');
|
|
@@ -1257,17 +1538,25 @@ function installToastCss() {
|
|
|
1257
1538
|
style.textContent = [
|
|
1258
1539
|
'.dsh-mcp-install-toast{position:fixed;top:64px;left:50%;transform:translate(-50%,-14px);z-index:2147483000;',
|
|
1259
1540
|
'display:none;flex-direction:column;gap:6px;min-width:220px;max-width:min(420px,calc(100vw - 32px));',
|
|
1260
|
-
'padding:10px 14px 11px;border-radius:12px;pointer-events:none;',
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1541
|
+
'padding:10px 14px 11px;border-radius:12px;pointer-events:none;text-align:start;',
|
|
1542
|
+
// 与底部 .dsh-mcp-toast 同一对自持色(--mcp-tip-*):宿主 token 里
|
|
1543
|
+
// --dsw-alias-bg-elevated / --dsw-alias-text-primary 本皮根本没定义,全靠这里的 fallback
|
|
1544
|
+
// 撑着;一旦宿主基础样式给其中一个赋值,就会重演「深蓝字压深蓝底」。fallback 值与
|
|
1545
|
+
// --mcp-tip-bg/--mcp-tip-fg 同色,两处浮层从此共用一套对比度已实测的色面。
|
|
1546
|
+
'background:var(--mcp-tip-bg,rgba(23,38,63,.93));color:var(--mcp-tip-fg,#f4f7fd);',
|
|
1547
|
+
'border:1px solid var(--mcp-tip-line,rgba(255,255,255,.18));',
|
|
1548
|
+
'box-shadow:var(--mcp-tip-shadow,0 10px 30px rgba(0,0,0,.30));backdrop-filter:blur(14px) saturate(160%);',
|
|
1264
1549
|
'font-size:12px;line-height:1.45;opacity:0;transition:opacity .18s ease,transform .18s ease}',
|
|
1265
1550
|
'.dsh-mcp-install-toast.on{display:flex;opacity:1;transform:translate(-50%,0)}',
|
|
1266
1551
|
'.dsh-mcp-install-toast-title{font-weight:600;font-size:12px;letter-spacing:.01em}',
|
|
1267
1552
|
'.dsh-mcp-install-toast-row{display:flex;align-items:center;gap:8px;min-width:0}',
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1553
|
+
// 状态圆点同样走自持色:宿主 --dsw-alias-accent-primary / -status-success / -status-danger
|
|
1554
|
+
// 三个键在本皮都不存在(皮肤用的是 state-*-primary 命名),旧写法一直吃 fallback。
|
|
1555
|
+
// 7px 的点属非文本信息,但它是「进行中/成功/失败」的唯一视觉差异,必须在深色面上站得住
|
|
1556
|
+
// (#7fb2ff / --mcp-tip-ok / --mcp-tip-err 对 #17263f 实测 6.7:1 以上)。
|
|
1557
|
+
'.dsh-mcp-install-toast-dot{flex:0 0 auto;width:7px;height:7px;border-radius:50%;background:#7fb2ff;animation:dsh-mcp-install-pulse 1.1s ease-in-out infinite}',
|
|
1558
|
+
'.dsh-mcp-install-toast-row[data-phase="ok"] .dsh-mcp-install-toast-dot{animation:none;background:var(--mcp-tip-ok,#7ee0a1)}',
|
|
1559
|
+
'.dsh-mcp-install-toast-row[data-phase="fail"] .dsh-mcp-install-toast-dot{animation:none;background:var(--mcp-tip-err,#ff8a8a)}',
|
|
1271
1560
|
'.dsh-mcp-install-toast-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
1272
1561
|
'.dsh-mcp-install-toast-status{flex:0 0 auto;opacity:.72;font-variant-numeric:tabular-nums}',
|
|
1273
1562
|
'@keyframes dsh-mcp-install-pulse{0%,100%{opacity:.35;transform:scale(.85)}50%{opacity:1;transform:scale(1)}}',
|
|
@@ -1290,16 +1579,28 @@ function ensureInstallToast() {
|
|
|
1290
1579
|
installToastNode = node;
|
|
1291
1580
|
return node;
|
|
1292
1581
|
}
|
|
1582
|
+
/** 行内状态文案:取该任务所属组的 labels,按当前 phase 调对应的 t() 函数。 */
|
|
1583
|
+
function taskStatusLabel(task) {
|
|
1584
|
+
const label = (task.labels || PLUGIN_TASK_LABELS)[task.phase];
|
|
1585
|
+
return typeof label === 'function' ? label() : '';
|
|
1586
|
+
}
|
|
1293
1587
|
function renderInstallToast() {
|
|
1294
1588
|
const node = ensureInstallToast();
|
|
1295
1589
|
if (!node) return;
|
|
1296
1590
|
const tasks = [...installTasks.values()];
|
|
1297
1591
|
node.replaceChildren();
|
|
1298
1592
|
if (!tasks.length) { node.classList.remove('on'); return; }
|
|
1299
|
-
const
|
|
1593
|
+
const runningTasks = tasks.filter((task) => task.phase === 'running');
|
|
1594
|
+
// 标题按「正在跑的那一组」措辞;两组同时在跑(装插件的同时点 MCP 重连)很罕见,
|
|
1595
|
+
// 取第一条进行中的分组就够,不为它另设一套文案。
|
|
1596
|
+
const group = (runningTasks[0] || tasks[0]).group || PROGRESS_GROUP_PLUGIN;
|
|
1597
|
+
const titles = PROGRESS_TITLES[group] || PROGRESS_TITLES[PROGRESS_GROUP_PLUGIN];
|
|
1598
|
+
// 计数只数「标题所指的这一组」:两组同时在跑时说「正在安装 2 个插件」而其中一个是 MCP
|
|
1599
|
+
// 就是假计数。另一组的任务不消失,仍在下面的行里逐条列出。
|
|
1600
|
+
const groupRunning = runningTasks.filter((task) => (task.group || PROGRESS_GROUP_PLUGIN) === group).length;
|
|
1300
1601
|
const title = document.createElement('div');
|
|
1301
1602
|
title.className = 'dsh-mcp-install-toast-title';
|
|
1302
|
-
title.textContent =
|
|
1603
|
+
title.textContent = groupRunning ? titles.running(String(groupRunning)) : titles.done();
|
|
1303
1604
|
node.appendChild(title);
|
|
1304
1605
|
for (const task of tasks.slice(0, 3)) {
|
|
1305
1606
|
const row = document.createElement('div');
|
|
@@ -1312,37 +1613,53 @@ function renderInstallToast() {
|
|
|
1312
1613
|
name.textContent = task.name;
|
|
1313
1614
|
const status = document.createElement('span');
|
|
1314
1615
|
status.className = 'dsh-mcp-install-toast-status';
|
|
1315
|
-
status.textContent =
|
|
1616
|
+
status.textContent = t(taskStatusLabel(task));
|
|
1316
1617
|
row.appendChild(dot); row.appendChild(name); row.appendChild(status);
|
|
1317
1618
|
node.appendChild(row);
|
|
1318
1619
|
}
|
|
1319
1620
|
node.classList.add('on');
|
|
1320
1621
|
}
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1622
|
+
/**
|
|
1623
|
+
* 通用进度浮层入口:key 唯一标识一个在跑的任务(重复开装顶掉同一行,不叠两行)。
|
|
1624
|
+
* 挂的是 document.body,面板卸载、切标签页、切窗口都还在——这正是它存在的全部理由。
|
|
1625
|
+
*/
|
|
1626
|
+
function progressToastStart(key, name, group, labels) {
|
|
1627
|
+
const taskKey = String(key || '');
|
|
1628
|
+
if (!taskKey) return;
|
|
1629
|
+
const pending = installTimers.get(taskKey);
|
|
1630
|
+
if (pending) { clearTimeout(pending); installTimers.delete(taskKey); }
|
|
1631
|
+
installTasks.set(taskKey, {
|
|
1632
|
+
name: String(name || taskKey),
|
|
1633
|
+
phase: 'running',
|
|
1634
|
+
at: Date.now(),
|
|
1635
|
+
group: group || PROGRESS_GROUP_PLUGIN,
|
|
1636
|
+
labels: labels || PLUGIN_TASK_LABELS,
|
|
1637
|
+
});
|
|
1327
1638
|
renderInstallToast();
|
|
1328
1639
|
}
|
|
1329
|
-
function
|
|
1330
|
-
const
|
|
1331
|
-
const task = installTasks.get(
|
|
1640
|
+
function progressToastFinish(key, phase) {
|
|
1641
|
+
const taskKey = String(key || '');
|
|
1642
|
+
const task = installTasks.get(taskKey);
|
|
1332
1643
|
if (!task) return;
|
|
1333
1644
|
task.phase = phase;
|
|
1334
1645
|
task.at = Date.now();
|
|
1335
1646
|
renderInstallToast();
|
|
1336
|
-
// 结果态留 4.2s 让用户看清「装完了 /
|
|
1647
|
+
// 结果态留 4.2s 让用户看清「装完了 / 失败了」,再淡出;期间同一任务重新开跑会取消这个定时器。
|
|
1337
1648
|
const timer = setTimeout(() => {
|
|
1338
|
-
installTimers.delete(
|
|
1339
|
-
const current = installTasks.get(
|
|
1649
|
+
installTimers.delete(taskKey);
|
|
1650
|
+
const current = installTasks.get(taskKey);
|
|
1340
1651
|
if (current && current.phase !== 'running' && Date.now() - current.at >= 4000) {
|
|
1341
|
-
installTasks.delete(
|
|
1652
|
+
installTasks.delete(taskKey);
|
|
1342
1653
|
renderInstallToast();
|
|
1343
1654
|
}
|
|
1344
1655
|
}, 4200);
|
|
1345
|
-
installTimers.set(
|
|
1656
|
+
installTimers.set(taskKey, timer);
|
|
1657
|
+
}
|
|
1658
|
+
function installToastStart(name) {
|
|
1659
|
+
progressToastStart(name, name, PROGRESS_GROUP_PLUGIN, PLUGIN_TASK_LABELS);
|
|
1660
|
+
}
|
|
1661
|
+
function installToastFinish(name, phase) {
|
|
1662
|
+
progressToastFinish(name, phase);
|
|
1346
1663
|
}
|
|
1347
1664
|
|
|
1348
1665
|
// 面板卸载后组件的 busy 会归零,但模块级的 installTasks 还在跑:按钮禁用要同时看它,
|
|
@@ -1801,12 +2118,17 @@ function renderReadmeBlocks(markdown, base) {
|
|
|
1801
2118
|
const FAB_SIZE = 50;
|
|
1802
2119
|
const FAB_EDGE = 22;
|
|
1803
2120
|
|
|
1804
|
-
const MCP_CSS = `body{--mcp-cat-all:var(--dsw-alias-label-primary);--mcp-cat-uncat:var(--dsw-alias-label-primary);--mcp-cat-dev:var(--dsw-static-deepseek-500);--mcp-cat-design:var(--dsw-static-blue-400);--mcp-cat-office:var(--dsw-static-green-500);--mcp-cat-auto:var(--dsw-static-amber-500);--mcp-cat-ops:var(--dsw-static-neutral-bluish-500);--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(--dsw-alias-state-success-primary);--mcp-err:var(--dsw-alias-state-error-primary);--mcp-warn:var(--dsw-alias-state-warn-primary);--mcp-mut:var(--dsw-alias-label-secondary);--mcp-bg-1:var(--dsw-alias-bg-layer-1);--mcp-bg-2:var(--dsw-alias-bg-layer-2);--mcp-bg-3:var(--dsw-alias-bg-layer-3);--mcp-line:var(--dsw-alias-border-l2);--mcp-mono:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Menlo,monospace);--mcp-r-xs:var(--dsw-radius-xs);--mcp-r-sm:var(--dsw-radius-sm);--mcp-r-md:var(--dsw-radius-md);--mcp-r-lg:var(--dsw-radius-lg);--mcp-r-xl:var(--dsw-radius-xl);--mcp-r-panel:var(--dsw-radius-panel)}
|
|
2121
|
+
const MCP_CSS = `body{--mcp-cat-all:var(--dsw-alias-label-primary);--mcp-cat-uncat:var(--dsw-alias-label-primary);--mcp-cat-dev:var(--dsw-static-deepseek-500);--mcp-cat-design:var(--dsw-static-blue-400);--mcp-cat-office:var(--dsw-static-green-500);--mcp-cat-auto:var(--dsw-static-amber-500);--mcp-cat-ops:var(--dsw-static-neutral-bluish-500);--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(--dsw-alias-state-success-primary);--mcp-err:var(--dsw-alias-state-error-primary);--mcp-warn:var(--dsw-alias-state-warn-primary);--mcp-mut:var(--dsw-alias-label-secondary);--mcp-bg-1:var(--dsw-alias-bg-layer-1);--mcp-bg-2:var(--dsw-alias-bg-layer-2);--mcp-bg-3:var(--dsw-alias-bg-layer-3);--mcp-line:var(--dsw-alias-border-l2);--mcp-mono:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Menlo,monospace);--mcp-r-xs:var(--dsw-radius-xs);--mcp-r-sm:var(--dsw-radius-sm);--mcp-r-md:var(--dsw-radius-md);--mcp-r-lg:var(--dsw-radius-lg);--mcp-r-xl:var(--dsw-radius-xl);--mcp-r-panel:var(--dsw-radius-panel);--mcp-tip-bg:#17263fec;--mcp-tip-fg:#f4f7fd;--mcp-tip-line:#ffffff2e;--mcp-tip-err:#ff8a8a;--mcp-tip-ok:#7ee0a1;--mcp-tip-shadow:0 10px 28px rgba(0,0,0,.30)}
|
|
1805
2122
|
/* Skill 看板升级 · 分类色(决策 10:只从宿主既有色相取,只用于色点与徽标)。
|
|
1806
2123
|
暗色套与宿主 ui-theme 同构(body[data-ds-dark-theme],取证见进度文档 §七);
|
|
1807
2124
|
分类色永远与文字标签成对出现,不单独用颜色传达信息。 */
|
|
1808
|
-
body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-design:var(--dsw-static-blue-300);--mcp-cat-office:var(--dsw-static-green-400);--mcp-cat-auto:var(--dsw-static-amber-400);--mcp-cat-ops:var(--dsw-static-neutral-bluish-400)}
|
|
1809
|
-
|
|
2125
|
+
body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-design:var(--dsw-static-blue-300);--mcp-cat-office:var(--dsw-static-green-400);--mcp-cat-auto:var(--dsw-static-amber-400);--mcp-cat-ops:var(--dsw-static-neutral-bluish-400);--mcp-tip-bg:#0c1a34e6}
|
|
2126
|
+
/* 浮层提示(底部 toast / 顶部进度 pill)不跟页面主文字色:它们的底是插件自持的深色面
|
|
2127
|
+
(--mcp-tip-bg)。皮肤把 --dsw-alias-toast-bg 定成深蓝、把 --dsw-alias-label-primary 定成
|
|
2128
|
+
近黑 Navy(whale-song 浅色皮 #1a3a7a / #0a1e4a,实测 1.49:1),两者一组就是「深蓝字压深蓝底」,
|
|
2129
|
+
用户读不清。所以这里把浮层从 label-primary 的分组选择器里摘出来,只留字体族。 */
|
|
2130
|
+
.dsh-mcp-wrap,.dsh-mcp-panel-overlay,.dsh-mcp-overlay,.dsh-mcp-fab,.dsh-mcp-footer-action,.dsh-mcp-toast{font-family:var(--dsw-font-family,inherit)}
|
|
2131
|
+
.dsh-mcp-wrap,.dsh-mcp-panel-overlay,.dsh-mcp-overlay,.dsh-mcp-fab,.dsh-mcp-footer-action{color:var(--dsw-alias-label-primary)}
|
|
1810
2132
|
.dsh-mcp-wrap{box-sizing:border-box;display:flex;flex-direction:column;height:100%;min-height:0;gap:14px;padding:4px 2px;font-size:13px;-webkit-font-smoothing:antialiased}
|
|
1811
2133
|
.dsh-mcp-wrap ::-webkit-scrollbar{width:8px;height:8px}
|
|
1812
2134
|
.dsh-mcp-wrap ::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2);border-radius:999px;corner-shape:round;border:2px solid transparent;background-clip:padding-box}
|
|
@@ -1845,7 +2167,7 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
1845
2167
|
.dsh-mcp-item-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
|
|
1846
2168
|
.dsh-mcp-item-name{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:.1px}
|
|
1847
2169
|
.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}
|
|
2170
|
+
.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
2171
|
.dsh-mcp-badge.dot::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor;margin-right:5px;opacity:.85;corner-shape:round}
|
|
1850
2172
|
/* §1.3 色语:橙色的名额只留给「需注意」(不兼容 / 缺参数 / 有死工具)。
|
|
1851
2173
|
传输类型是描述值、不是状态,此前 stdio 独占橙底会让人误读成「这台有问题」。
|
|
@@ -1883,6 +2205,9 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
1883
2205
|
.dsh-mcp-tool-desc{font-size:11.5px;color:var(--mcp-mut);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1884
2206
|
.dsh-mcp-empty-list{padding:20px;text-align:center;color:var(--mcp-mut);font-size:12px}
|
|
1885
2207
|
.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}
|
|
2208
|
+
/* 令牌 / 授权弹层可能从详情弹层里打开:DOM 上排在详情之后仍会被同 z-index 的兄弟盖住,
|
|
2209
|
+
单独抬到 210,保证用户点「我已有令牌」后弹窗真的在最前面(命令面板 320、toast 300 不动)。 */
|
|
2210
|
+
.dsh-mcp-overlay.dsh-mcp-front{z-index:210}
|
|
1886
2211
|
.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
2212
|
.dsh-mcp-whatsnew-head{display:flex;flex-direction:column;gap:2px;margin-bottom:12px}
|
|
1888
2213
|
.dsh-mcp-whatsnew-head b{font-size:15px;font-weight:650}
|
|
@@ -1924,6 +2249,13 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
1924
2249
|
.dsh-mcp-wb-tab:hover{border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
|
|
1925
2250
|
.dsh-mcp-wb-tab.sel{border-color:var(--mcp-accent);color:var(--dsw-alias-label-primary);font-weight:600}
|
|
1926
2251
|
.dsh-mcp-wb-disclaim{font-size:11px;line-height:1.55;color:var(--mcp-mut)}
|
|
2252
|
+
/* 连接器来源切换行:左边分段控件,右侧留白(社区未审核声明另起一行常驻) */
|
|
2253
|
+
.dsh-mcp-wb-feedbar{justify-content:space-between;gap:8px}
|
|
2254
|
+
/* 社区未审核声明:warn 配色,常驻不可折叠 */
|
|
2255
|
+
.dsh-mcp-wb-note.warn{color:var(--mcp-warn);border-color:var(--mcp-warn);background:var(--dsw-alias-state-warn-tertiary)}
|
|
2256
|
+
/* 社区游标翻页:居中一行「上一页 / 第 N 页 / 下一页」,列表不被按钮撑开 */
|
|
2257
|
+
.dsh-mcp-wb-more{display:flex;justify-content:center;align-items:center;padding:12px 0 4px;flex:none}
|
|
2258
|
+
.dsh-mcp-wb-card-actions a.dsh-mcp-btn{text-decoration:none}
|
|
1927
2259
|
.dsh-mcp-wb-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
1928
2260
|
.dsh-mcp-wb-toolbar .dsh-mcp-check{margin:0!important;font-size:12px}
|
|
1929
2261
|
.dsh-mcp-wb-toolbar input[type='search']{flex:1 1 220px;min-width:160px;box-sizing:border-box}
|
|
@@ -1960,6 +2292,10 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
1960
2292
|
.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
2293
|
.dsh-mcp-card.wb.off{opacity:.82}
|
|
1962
2294
|
.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}
|
|
2295
|
+
/* 社区卡片整卡可点开详情:只有带 .clickable 的卡获得手型与焦点环,WB / 技能卡保持原样。 */
|
|
2296
|
+
.dsh-mcp-card.wb.clickable{cursor:pointer;transition:border-color .15s,box-shadow .15s,background .15s}
|
|
2297
|
+
.dsh-mcp-card.wb.clickable:hover{border-color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover)}
|
|
2298
|
+
.dsh-mcp-card.wb.clickable:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:2px}
|
|
1963
2299
|
.dsh-mcp-wb-card-actions{display:flex;align-items:center;gap:8px;margin-top:auto;flex-wrap:wrap}
|
|
1964
2300
|
/* ── DSH 社区插件卡片:五条固定水平线(头部 34 / 摘要 38 / 标签 18 / 页脚 38)──
|
|
1965
2301
|
只作用于 .dsh-mcp-pcard,连接器 / 技能 / 专家卡片不受影响。
|
|
@@ -2030,12 +2366,15 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2030
2366
|
.dsh-mcp-wb-tokenfield input:focus{border-color:var(--mcp-accent)}
|
|
2031
2367
|
.dsh-mcp-wb-tokenfield input::placeholder{color:var(--mcp-mut)}
|
|
2032
2368
|
.dsh-mcp-wb-examples ul{margin:4px 0 0;padding-left:18px}
|
|
2369
|
+
/* 详情里的 command / args / 远程地址:等宽、可断行,绝不横向撑破弹层。 */
|
|
2370
|
+
.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
2371
|
.dsh-mcp-wb-roster{list-style:none;margin:6px 0 0;padding:0;display:flex;flex-direction:column;gap:5px}
|
|
2034
2372
|
.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
2373
|
.dsh-mcp-wb-roster b{font-weight:650;min-width:0;overflow-wrap:anywhere}
|
|
2036
2374
|
.dsh-mcp-wb-roster-role{font-size:11px;color:var(--dsw-alias-brand-text);flex:none}
|
|
2037
2375
|
.dsh-mcp-wb-roster-prof{font-size:11px;color:var(--mcp-mut);flex:none}
|
|
2038
2376
|
.dsh-mcp-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;flex-wrap:wrap}
|
|
2377
|
+
.dsh-mcp-modal-actions a.dsh-mcp-btn{text-decoration:none}
|
|
2039
2378
|
.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
2379
|
.dsh-mcp-builtin-count{font-size:11.5px;color:var(--mcp-mut)}
|
|
2041
2380
|
.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)}
|
|
@@ -2045,9 +2384,9 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2045
2384
|
.dsh-mcp-import-group>b{flex:0 0 auto;color:var(--mcp-mut)}
|
|
2046
2385
|
.dsh-mcp-import-group>b.warn{color:var(--mcp-warn)}
|
|
2047
2386
|
.dsh-mcp-import-group-note{font-size:11px;line-height:1.5;color:var(--mcp-mut);overflow-wrap:anywhere;margin-top:2px}
|
|
2048
|
-
.dsh-mcp-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--
|
|
2049
|
-
.dsh-mcp-toast.err{border:
|
|
2050
|
-
.dsh-mcp-toast.ok{border:
|
|
2387
|
+
.dsh-mcp-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--mcp-tip-bg);backdrop-filter:blur(10px);border:1px solid var(--mcp-tip-line);color:var(--mcp-tip-fg);padding:9px 16px;border-radius:var(--mcp-r-lg);font-size:12.5px;line-height:1.5;text-align:center;z-index:300;box-shadow:var(--mcp-tip-shadow);max-width:min(560px,calc(100vw - 32px));overflow-wrap:anywhere;animation:mcpPop .16s ease-out}
|
|
2388
|
+
.dsh-mcp-toast.err{border-color:var(--mcp-tip-err)}
|
|
2389
|
+
.dsh-mcp-toast.ok{border-color:var(--mcp-tip-ok)}
|
|
2051
2390
|
.dsh-mcp-toggle{position:relative;width:34px;height:19px;box-sizing:border-box;border-radius:999px;border:0;cursor:pointer;background:var(--dsw-alias-border-l3);transition:background .18s,border-color .18s;flex:none;box-shadow:none;corner-shape:round}
|
|
2052
2391
|
.dsh-mcp-toggle[aria-checked='true']{background:var(--mcp-accent);border-color:transparent}
|
|
2053
2392
|
.dsh-mcp-toggle:disabled{opacity:.65;cursor:not-allowed}
|
|
@@ -2295,7 +2634,7 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2295
2634
|
.dsh-mcp-skill-name{font-weight:500;color:var(--dsw-alias-label-primary);font-size:13px;line-height:18px;word-break:break-word}
|
|
2296
2635
|
.dsh-mcp-skill-sub{display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:3px}
|
|
2297
2636
|
.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}
|
|
2637
|
+
.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
2638
|
.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
2639
|
.dsh-mcp-skill-d-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;padding:14px 16px 10px}
|
|
2301
2640
|
.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 +2720,7 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2381
2720
|
.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
2721
|
/* 主列画布:替代旧 340px 侧栏(旧 .dsh-mcp-skill-side 仍被 760px 媒体查询引用,保留无害)。 */
|
|
2383
2722
|
.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}
|
|
2723
|
+
.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
2724
|
.dsh-mcp-skill-card-foot .dsh-mcp-skill-src{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
2386
2725
|
/* 详情栏 sticky(决策 3):宿主页面是滚动容器,sticky 生效;被 760px 以下布局覆盖时自动失效。 */
|
|
2387
2726
|
.dsh-mcp-skill-detail{align-self:flex-start;position:sticky;top:16px;max-height:calc(100vh - 48px)}
|
|
@@ -2706,7 +3045,7 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2706
3045
|
.dsh-mcp-seg-item{flex:none;white-space:nowrap;display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border:0;border-radius:var(--mcp-r-md);background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;font-family:inherit;cursor:pointer;transition:background 120ms ease,color 120ms ease}
|
|
2707
3046
|
.dsh-mcp-seg-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
|
|
2708
3047
|
.dsh-mcp-seg-item[aria-selected="true"]{background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font-weight:600;box-shadow:0 1px 2px rgba(16,32,48,.08)}
|
|
2709
|
-
.dsh-mcp-seg-count{font-size:10px;padding:0 4px;border-radius:var(--mcp-r-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);line-height:14px;font-variant-numeric:tabular-nums}
|
|
3048
|
+
.dsh-mcp-seg-count{font-size:10px;padding:0 4px;border-radius:var(--mcp-r-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);line-height:14px;font-variant-numeric:tabular-nums;white-space:nowrap;flex:0 0 auto}
|
|
2710
3049
|
.dsh-mcp-seg-item[aria-selected="true"] .dsh-mcp-seg-count{background:var(--mcp-accent);color:var(--dsw-alias-label-on-brand,#fff)}
|
|
2711
3050
|
.dsh-mcp-seg-item:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
|
|
2712
3051
|
@media (prefers-reduced-motion:reduce){.dsh-mcp-seg-item{transition:none}}
|
|
@@ -2787,6 +3126,18 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2787
3126
|
.dsh-mcp-tailor-row.head .t-name{background:var(--mcp-bg-2)}
|
|
2788
3127
|
.dsh-mcp-tailor-row:not(.head):hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
2789
3128
|
.dsh-mcp-tailor-row.dead:not(.head):hover{background:color-mix(in srgb,var(--mcp-warn) 12%,transparent)}
|
|
3129
|
+
/* ── §7.1 技能行行内禁用/释放按钮 ─────────────────────────────────────────── */
|
|
3130
|
+
/* 技能行尾列放的是文字按钮(禁用/释放),比工具行的 24px ⋯ 菜单宽:只对技能表放宽
|
|
3131
|
+
尾列(.skills 修饰类),工具表保持 30px 不动。 */
|
|
3132
|
+
.dsh-mcp-tailor-table.skills .dsh-mcp-tailor-row{grid-template-columns:minmax(180px,1.6fr) 88px 118px 96px minmax(96px,.8fr) 52px}
|
|
3133
|
+
.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}
|
|
3134
|
+
.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)}
|
|
3135
|
+
.dsh-mcp-tailor-op:disabled{opacity:.55;cursor:default}
|
|
3136
|
+
.dsh-mcp-tailor-op.release{color:var(--dsw-alias-state-success-primary)}
|
|
3137
|
+
.dsh-mcp-tailor-op.release:hover:not(:disabled){border-color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-interactive-bg-hover)}
|
|
3138
|
+
.dsh-mcp-tailor-op:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
|
|
3139
|
+
/* 禁用行:名字弱化,与 .dead 的「零调用」底色语义分开(禁用是主动态,不是数据缺失)。 */
|
|
3140
|
+
.dsh-mcp-tailor-row.off:not(.head) .t-name{color:var(--dsw-alias-label-tertiary)}
|
|
2790
3141
|
/* sparkline:7 段柱状,高度按窗口内分布归一,纯装饰不可交互 */
|
|
2791
3142
|
.dsh-mcp-spark{display:inline-flex;align-items:flex-end;gap:1.5px;height:14px;flex:none;vertical-align:-2px}
|
|
2792
3143
|
.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 +3209,10 @@ button.dsh-mcp-chip-s.on .dsh-mcp-chip-s-count{color:var(--mcp-accent)}
|
|
|
2858
3209
|
.dsh-mcp-mk-crumb-root{color:var(--dsw-alias-label-secondary)}
|
|
2859
3210
|
.dsh-mcp-mk-crumb-sep{opacity:.55}
|
|
2860
3211
|
.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:
|
|
3212
|
+
.dsh-mcp-mk-topright{display:flex;align-items:center;gap:10px;flex:0 1 auto;min-width:0;margin-left:auto}
|
|
2862
3213
|
.dsh-mcp-mk-topright .dsh-mcp-mk-crumb{margin-left:0;padding-left:0}
|
|
3214
|
+
/* 子页签行右侧的免责声明:单行、可截断(完整口径在 title 里),窄屏先让位给页签与入口 */
|
|
3215
|
+
.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
3216
|
.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
3217
|
.dsh-mcp-loadout-open:hover{border-color:var(--mcp-accent);color:var(--dsw-alias-label-primary)}
|
|
2865
3218
|
.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 +3242,9 @@ button.dsh-mcp-chip-s.on .dsh-mcp-chip-s-count{color:var(--mcp-accent)}
|
|
|
2889
3242
|
@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
3243
|
/* 窄屏页签行放不下 5 个页签 + 面包屑:页签优先,面包屑让位(页签本身已标明当前位置)。 */
|
|
2891
3244
|
@media (max-width:480px){.dsh-mcp-mk-crumb{display:none}}
|
|
3245
|
+
/* 免责声明在窄屏让位:先缩到一句被截断也读不出信息,再窄干脆收起(要点已在 title 里)。 */
|
|
3246
|
+
@media (max-width:900px){.dsh-mcp-mk-note{max-width:220px}}
|
|
3247
|
+
@media (max-width:760px){.dsh-mcp-mk-note{display:none}}
|
|
2892
3248
|
.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
3249
|
.dsh-mcp-mk-subtabs-idle:hover{opacity:1;color:var(--dsw-alias-label-secondary);text-decoration:underline;text-underline-offset:2px}
|
|
2894
3250
|
.dsh-mcp-mk-subtabs-idle:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-2px}
|
|
@@ -2916,13 +3272,6 @@ button.dsh-mcp-chip-s.on .dsh-mcp-chip-s-count{color:var(--mcp-accent)}
|
|
|
2916
3272
|
.dsh-mcp-card.wb.blocked:hover{opacity:1}
|
|
2917
3273
|
.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
3274
|
.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
3275
|
|
|
2927
3276
|
/* ── 骨架屏(OPT-25 · 6.6 三处并行初始加载的占位)──────────────────────
|
|
2928
3277
|
容器与条只用既有主题令牌 + 中性 rgba,暗/亮主题都不需要额外分支。
|
|
@@ -3724,7 +4073,11 @@ const ServerList = ({ servers, total, selected, query, onQuery, onSelect, onTogg
|
|
|
3724
4073
|
h('span', null, s.scope === 'workspace' ? wsSub(s) : s.toolCount + t(' 工具')),
|
|
3725
4074
|
heavy ? h('span', { className: 'dsh-mcp-tag-heavy', title: t('工具数 ≥ {0}:进详情默认折叠工具清单', [HEAVY_TOOL_COUNT]) }, t('大体量')) : null,
|
|
3726
4075
|
// 失败原因就地给一个词,不逼用户点进去看日志才知道哪里坏了。
|
|
3727
|
-
|
|
4076
|
+
// 401/403 类失败(host 侧 authRequired)单独说「需授权」:重连连不回来,
|
|
4077
|
+
// 该做的是重新授权,这条区别以前被并进「连接失败」里了。
|
|
4078
|
+
failed && !mountFailed(s) && !scopeFailed(s)
|
|
4079
|
+
? h(Badge, { cls: 's-err', title: s.authRequired ? t('服务器拒绝当前凭据(401/403 类):重连不会恢复,需要重新授权') : undefined }, s.authRequired ? t('需授权') : t('连接失败'))
|
|
4080
|
+
: null,
|
|
3728
4081
|
mountFailed(s) ? h(Badge, { cls: 's-err' }, t('挂载失败')) : null,
|
|
3729
4082
|
scopeFailed(s) ? h(Badge, { cls: 's-err' }, t('作用域故障')) : null,
|
|
3730
4083
|
),
|
|
@@ -3915,8 +4268,8 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
|
|
|
3915
4268
|
// 这里只说**连接**那一半事实("在不在");"几个会话在用"由详情页的连接信息与列表行的
|
|
3916
4269
|
// 副文案给出——两者刻意不合并,见 wsSub 的注释。
|
|
3917
4270
|
// 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('已停止');
|
|
4271
|
+
? (status === 'connected' ? t('已连接') : status === 'failed' ? (server.authRequired ? t('需授权') : t('连接失败')) : status === 'unknown' ? t('状态未知') : status === 'loading' ? t('连接中…') : t('尚未建立连接(新会话自动挂载)'))
|
|
4272
|
+
: status === 'connected' ? t('已连接') : status === 'failed' ? (server.authRequired ? t('需授权') : t('连接失败')) : status === 'unknown' ? t('状态未知') : status === 'loading' || status === 'waiting' ? t('连接中…') : t('已停止');
|
|
3920
4273
|
const mut = (v) => {
|
|
3921
4274
|
const visible = displayValue(v);
|
|
3922
4275
|
return typeof visible === 'string' && visible.length > 90 ? visible.slice(0, 88) + '…' : visible;
|
|
@@ -4048,6 +4401,10 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
|
|
|
4048
4401
|
// 把它摆成原因;成功时不刷屏,更不把一条好连接说成冲突。
|
|
4049
4402
|
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
4403
|
server.lastError ? h('div', { className: 'dsh-mcp-log', title: t('来自 mcp-client 最近日志') }, server.lastError) : null,
|
|
4404
|
+
// 401/403 类的失败重连多少次都没用:直接把用户指到授权入口,别让他反复点「重连」。
|
|
4405
|
+
server.authRequired
|
|
4406
|
+
? h('div', { className: 'dsh-mcp-log warn' }, t('这台服务器拒绝当前凭据(401/403 类),重连不会恢复:请到「市场 → 连接器」打开对应连接器点「重新授权」。由 WorkBuddy 账号代注入凭据的连接器(如 ima)DSH 无法代办。'))
|
|
4407
|
+
: null,
|
|
4051
4408
|
server.toolCountAmbiguous ? h('div', { className: 'dsh-mcp-log warn' }, t('工具命名与其他 serverName 前缀重叠,无法可靠判断归属;请避免在 serverName 中使用双下划线。')) : null,
|
|
4052
4409
|
// §2.4 连接健康条:原来「连没连上、几个工具、几个会话」埋在两行徽章里,
|
|
4053
4410
|
// 而且要滚到「连接信息」才知道传输方式。这里把四个最常被问到的结论提到最上层:
|
|
@@ -4126,7 +4483,7 @@ const ServerDetail = ({ server, tools, onToggle, onReconnect, onEdit, onRemove,
|
|
|
4126
4483
|
};
|
|
4127
4484
|
|
|
4128
4485
|
// ── 新增 / 编辑服务器弹窗:表单校验与 secret 字段遮蔽 ────────────────────────────
|
|
4129
|
-
const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, revealEpoch, scopeName }) => {
|
|
4486
|
+
const AddEditModal = ({ open, onClose, onSubmit, busy, initial, draft, onRevealValue, revealEpoch, scopeName }) => {
|
|
4130
4487
|
const dialogRef = useRef(null);
|
|
4131
4488
|
const [name, setName] = useState('');
|
|
4132
4489
|
const [transport, setTransport] = useState('http');
|
|
@@ -4146,7 +4503,9 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
|
|
|
4146
4503
|
const [revealBusy, setRevealBusy] = useState('');
|
|
4147
4504
|
const [editRevealed, setEditRevealed] = useState({});
|
|
4148
4505
|
const editRevealGeneration = useRef(0);
|
|
4149
|
-
|
|
4506
|
+
// draft:预填草稿(市场社区条目)。表单吃 initial 的预填,但提交必须走「新增」,
|
|
4507
|
+
// 名称也可改——否则 AddEditModal 会把它当成一次 update,去改一条并不存在的服务器。
|
|
4508
|
+
const editing = !!initial && !draft;
|
|
4150
4509
|
const applyPreset = (id) => {
|
|
4151
4510
|
const preset = MCP_PRESETS.find((p) => p.id === id);
|
|
4152
4511
|
if (!preset) return;
|
|
@@ -4336,6 +4695,16 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
|
|
|
4336
4695
|
}),
|
|
4337
4696
|
h('div', { className: 'dsh-mcp-kv-add' }, h(Btn, { onClick: () => addRow(kvRows, setKvRows), children: t('+ 添加') })),
|
|
4338
4697
|
),
|
|
4698
|
+
// 草稿里的必填环境变量为空:只提示、不阻塞——有些值允许运行时兜底,删不删由用户决定。
|
|
4699
|
+
draft && initial && Array.isArray(initial.requiredEnv) && initial.requiredEnv.length
|
|
4700
|
+
? (() => {
|
|
4701
|
+
const missing = initial.requiredEnv.filter((key) => !envs.some((row) => row.k === key && String(row.v || '').trim()));
|
|
4702
|
+
return missing.length
|
|
4703
|
+
? h('div', { className: 'dsh-mcp-wb-note warn', role: 'note' },
|
|
4704
|
+
t('有 {0} 个必填环境变量为空,服务器可能启动失败:{1}', [missing.length, missing.join(' / ')]))
|
|
4705
|
+
: null;
|
|
4706
|
+
})()
|
|
4707
|
+
: null,
|
|
4339
4708
|
h('div', { className: 'dsh-mcp-card dsh-mcp-advanced' },
|
|
4340
4709
|
h('h4', null, t('运行策略')),
|
|
4341
4710
|
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 +4959,19 @@ const fallbackTimer = {
|
|
|
4590
4959
|
return () => clearInterval(id);
|
|
4591
4960
|
},
|
|
4592
4961
|
};
|
|
4962
|
+
// P0-3 / P1-1:把「不着急的后台工作」挪到浏览器空闲帧。会话头 / 首屏渲染期间主线程
|
|
4963
|
+
// 本来就在忙,空闲回调能把这类 RPC 与其后的 setState 推到渲染之后。
|
|
4964
|
+
// requestIdleCallback 不可用时退回 setTimeout(0);两者都返回取消函数,调用方统一在
|
|
4965
|
+
// effect 清理时调用(组件卸载后不再发请求)。
|
|
4966
|
+
function scheduleIdle(fn, timeoutMs = 2000) {
|
|
4967
|
+
const win = typeof window !== 'undefined' ? window : null;
|
|
4968
|
+
if (win && typeof win.requestIdleCallback === 'function') {
|
|
4969
|
+
const id = win.requestIdleCallback(() => fn(), { timeout: timeoutMs });
|
|
4970
|
+
return () => { try { win.cancelIdleCallback(id); } catch { /* 宿主已卸载 */ } };
|
|
4971
|
+
}
|
|
4972
|
+
const id = setTimeout(fn, 0);
|
|
4973
|
+
return () => clearTimeout(id);
|
|
4974
|
+
}
|
|
4593
4975
|
function startVisibilityAwarePolling(doc, timer, intervalMs, run) {
|
|
4594
4976
|
const visible = () => !doc || doc.visibilityState !== 'hidden';
|
|
4595
4977
|
const host = timer && typeof timer.interval === 'function' ? timer : fallbackTimer;
|
|
@@ -5422,6 +5804,13 @@ const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || t
|
|
|
5422
5804
|
const SKILL_REMOVABLE_SOURCES = new Set(['user-agents', 'user-dsh', 'project-agents', 'project-dsh']);
|
|
5423
5805
|
const skillRemovable = (source) => SKILL_REMOVABLE_SOURCES.has(String(source || ''));
|
|
5424
5806
|
|
|
5807
|
+
// 可禁用来源白名单:与宿主 lib/skill-disable.js 同集(两边的安全闸门同一套理由——
|
|
5808
|
+
// 没有可信根目录做包含性校验的来源不给写入口)。工具裁缝技能行的「禁用 / 释放」按钮
|
|
5809
|
+
// 只对这里的来源出现;宿主侧 setSkillDisabled 会拿着注册表解析出的真实路径二次校验。
|
|
5810
|
+
const SKILL_DISABLE_SOURCES = SKILL_REMOVABLE_SOURCES;
|
|
5811
|
+
const skillDisableActionable = (row) =>
|
|
5812
|
+
row.registered === true && SKILL_DISABLE_SOURCES.has(String(row.source || ''));
|
|
5813
|
+
|
|
5425
5814
|
// ── Skill 分类升级(决策 6/10):6 类 + 未分类兜底,两段式强/弱特征词匹配 ─────────
|
|
5426
5815
|
// 为什么两段式:中英混排描述里 code/image/frontend 这类词跨类污染严重,单层表会把
|
|
5427
5816
|
// imagegen-frontend-* 误判成「编程开发」。先按 SKILL_CAT_MATCH_ORDER 整体扫强特征词
|
|
@@ -5539,11 +5928,13 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5539
5928
|
// 只有用户切窗口(7/90 天)或点「重扫」时才真的重拉。
|
|
5540
5929
|
const firstResolveRef = useRef(false);
|
|
5541
5930
|
|
|
5542
|
-
const load = useCallback(async () => {
|
|
5931
|
+
const load = useCallback(async (force = false) => {
|
|
5543
5932
|
setBusy(true);
|
|
5544
5933
|
setError('');
|
|
5545
5934
|
try {
|
|
5546
|
-
|
|
5935
|
+
// P2-1:宿主侧 5 分钟缓存。普通挂载 / 切窗口吃缓存;「重扫」与动作后的刷新
|
|
5936
|
+
// 必须带 refresh:true —— 禁用/启用会改变注册表,吃缓存等于看旧账。
|
|
5937
|
+
const res = await call('toolTailor', { days, ...(force ? { refresh: true } : {}) });
|
|
5547
5938
|
if (res && res.ok) {
|
|
5548
5939
|
commitReport(res);
|
|
5549
5940
|
setActionError('');
|
|
@@ -5594,7 +5985,7 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5594
5985
|
flash(String((e && e.message) || e), 'err');
|
|
5595
5986
|
} finally {
|
|
5596
5987
|
setBusy(false);
|
|
5597
|
-
timer.timeout(load, 300);
|
|
5988
|
+
timer.timeout(() => load(true), 300);
|
|
5598
5989
|
}
|
|
5599
5990
|
};
|
|
5600
5991
|
|
|
@@ -5634,23 +6025,31 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5634
6025
|
: t('技能账本:窗口内共 {0} 次 skill 调用;注册表不可用,只列出日志里出现过的 {1} 个', [fmtInt(counted), fmtInt(skillRows.length)]);
|
|
5635
6026
|
return h('div', null,
|
|
5636
6027
|
h('div', { className: 'dsh-mcp-tailor-note' }, h('b', null, t('技能(skill 工具)')), summary),
|
|
5637
|
-
h('div', { className: 'dsh-mcp-tailor-table' },
|
|
6028
|
+
h('div', { className: 'dsh-mcp-tailor-table skills' },
|
|
5638
6029
|
h('div', { className: 'dsh-mcp-tailor-row head' },
|
|
5639
6030
|
h('span', { className: 't-name' }, t('技能')),
|
|
5640
6031
|
h('span', { className: 't-num' }, t('调用')),
|
|
5641
6032
|
h('span', { className: 't-num' }, t('错误 (率)')),
|
|
5642
6033
|
h('span', { className: 't-tax' }),
|
|
5643
6034
|
h('span', null, t('最后使用')),
|
|
5644
|
-
h('span', { className: 't-op' }),
|
|
6035
|
+
h('span', { className: 't-op', 'aria-label': t('操作') }),
|
|
5645
6036
|
),
|
|
5646
6037
|
skillRows.length === 0
|
|
5647
6038
|
? h('div', { className: 'dsh-mcp-tailor-empty' }, t('暂无技能调用'))
|
|
5648
6039
|
: skillRows.map((skill) => {
|
|
5649
6040
|
const isDead = skill.calls === 0;
|
|
5650
6041
|
const errRate = skill.errorRate || 0;
|
|
5651
|
-
|
|
6042
|
+
// §7.1 行内禁用/释放:写 SKILL.md frontmatter 的 disable-model-invocation
|
|
6043
|
+
// 标志(宿主官方键,监听器自动生效,无需重启)。禁用 = 模型不再加载该技能,
|
|
6044
|
+
// 不再缴它的 Schema 税;手动 /引用 不受影响。按钮只给「注册表在、来源在
|
|
6045
|
+
// 白名单」的行——bundled / runtime / custom 与已卸载技能的历史调用没有
|
|
6046
|
+
// 可信写入口,按「不伪造控件」的口径不出按钮。
|
|
6047
|
+
const actionable = skillDisableActionable(skill);
|
|
6048
|
+
const isOff = skill.disabled === true;
|
|
6049
|
+
return h('div', { key: skill.name, className: 'dsh-mcp-tailor-row' + (isDead ? ' dead' : '') + (isOff ? ' off' : '') },
|
|
5652
6050
|
h('span', { className: 't-name', title: skill.name },
|
|
5653
6051
|
skill.name,
|
|
6052
|
+
isOff ? h('span', { className: 't-badge warn' }, t('已禁用')) : null,
|
|
5654
6053
|
isDead ? h('span', { className: 't-badge' }, t('一次没被调用')) : null,
|
|
5655
6054
|
errRate >= 0.5 ? h('span', { className: 't-badge err' }, t('高错误率')) : null,
|
|
5656
6055
|
),
|
|
@@ -5659,7 +6058,17 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5659
6058
|
skill.errors > 0 ? fmtInt(skill.errors) + ' (' + fmtPct(errRate) + ')' : fmtInt(skill.errors)),
|
|
5660
6059
|
h('span', { className: 't-tax' }),
|
|
5661
6060
|
h('span', { className: 't-last' }, fmtLast(skill.lastUsedMs, report.generatedAt)),
|
|
5662
|
-
h('span', { className: 't-op' }
|
|
6061
|
+
h('span', { className: 't-op' },
|
|
6062
|
+
actionable
|
|
6063
|
+
? h('button', {
|
|
6064
|
+
type: 'button',
|
|
6065
|
+
className: 'dsh-mcp-tailor-op' + (isOff ? ' release' : ''),
|
|
6066
|
+
disabled: busy,
|
|
6067
|
+
title: isOff ? t('恢复模型调用该技能') : t('禁用后模型不再加载该技能,不再缴它的上下文税;手动 /引用 不受影响'),
|
|
6068
|
+
onClick: () => tailorAct(isOff ? 'enableSkill' : 'disableSkill', { name: skill.name }, isOff ? t('已释放技能 {0}', [skill.name]) : t('已禁用技能 {0}', [skill.name])),
|
|
6069
|
+
}, isOff ? t('释放') : t('禁用'))
|
|
6070
|
+
: null,
|
|
6071
|
+
),
|
|
5663
6072
|
);
|
|
5664
6073
|
}),
|
|
5665
6074
|
),
|
|
@@ -5810,7 +6219,7 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5810
6219
|
h('button', { key: w.key, type: 'button', className: 'dsh-mcp-btn' + (windowKey === w.key ? ' primary' : ''), onClick: () => setWindowKey(w.key) }, w.label),
|
|
5811
6220
|
),
|
|
5812
6221
|
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('重扫')),
|
|
6222
|
+
h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => load(true), disabled: busy }, t('重扫')),
|
|
5814
6223
|
h('span', { className: 'dsh-mcp-tailor-meta' }, metaText),
|
|
5815
6224
|
),
|
|
5816
6225
|
// 账本缓存读数(只读,动作已删;见 cacheLine 上方注释)。
|
|
@@ -6893,16 +7302,29 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
6893
7302
|
const label = days <= 30 ? t('装于 {0}', [fmtLast(at)]) : t('装于 {0}', [wbFormatDate(at)]);
|
|
6894
7303
|
return h(Badge, { cls: days <= 7 ? 's-on' : '', title: t('安装于 {0}', [wbFormatDate(at)]) }, label);
|
|
6895
7304
|
};
|
|
6896
|
-
//
|
|
6897
|
-
//
|
|
6898
|
-
|
|
7305
|
+
// 卡片备注行(描述下方小字,不改动 skill 实际名称):市场装的显示「市场原名」,
|
|
7306
|
+
// 其余磁盘技能显示安装目录名(namespace__slug 形态即它在市场里的标识)——
|
|
7307
|
+
// 目录名/原名与看板名一致时是冗余,不显示。原徽标方案挤在 foot 行且对无账本
|
|
7308
|
+
// 记录的技能(clawhub 等外部工具装的)没有数据,改为目录名兜底后覆盖所有技能。
|
|
7309
|
+
const skillNote = (s) => {
|
|
6899
7310
|
const info = installInfo.get(s.name);
|
|
6900
|
-
if (!info
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
+ (label ? ' ' +
|
|
6905
|
-
|
|
7311
|
+
if (!info) return null;
|
|
7312
|
+
if (info.marketName && info.marketName !== s.name) {
|
|
7313
|
+
const label = info.marketplace === 'workbuddy-skills' ? t('SkillHub') : (info.marketplace || '');
|
|
7314
|
+
return {
|
|
7315
|
+
text: t('市场原名:{0}', [info.marketName]) + (label ? ' · ' + label : ''),
|
|
7316
|
+
title: t('它在市场里叫「{0}」,安装后看板显示的是 SKILL.md 的 name 字段。', [info.marketName])
|
|
7317
|
+
+ (label ? ' ' + t('来源:{0}', [label]) : ''),
|
|
7318
|
+
};
|
|
7319
|
+
}
|
|
7320
|
+
const dirName = String(info.dir || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || '';
|
|
7321
|
+
if (dirName && dirName !== s.name) {
|
|
7322
|
+
return {
|
|
7323
|
+
text: t('安装目录:{0}', [dirName]),
|
|
7324
|
+
title: t('这个 skill 的安装目录名,namespace__slug 形态即它在市场里的标识。'),
|
|
7325
|
+
};
|
|
7326
|
+
}
|
|
7327
|
+
return null;
|
|
6906
7328
|
};
|
|
6907
7329
|
// 「最近安装」视图行:只留拿得到安装时间的(bundled / runtime 没有落盘时间,不在此列),
|
|
6908
7330
|
// 按时间倒序。其余筛选(搜索 / 来源 / 权限 / 分类)照常叠加。
|
|
@@ -7286,6 +7708,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7286
7708
|
h('div', { className: 'dsh-mcp-skill-grid' },
|
|
7287
7709
|
cluster.rows.map((s) => {
|
|
7288
7710
|
const sCat = catOf.get(s.name) || SKILL_UNCAT;
|
|
7711
|
+
const note = skillNote(s);
|
|
7289
7712
|
return h('button', {
|
|
7290
7713
|
key: s.name,
|
|
7291
7714
|
type: 'button',
|
|
@@ -7306,10 +7729,10 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7306
7729
|
s.description
|
|
7307
7730
|
? h('div', { className: 'dsh-mcp-skill-desc' }, s.description)
|
|
7308
7731
|
: h('div', { className: 'dsh-mcp-skill-desc dsh-mcp-skill-desc-none' }, t('(无描述)')),
|
|
7732
|
+
note ? h('div', { className: 'dsh-mcp-skill-note', title: note.title }, note.text) : null,
|
|
7309
7733
|
h('div', { className: 'dsh-mcp-skill-card-foot' },
|
|
7310
7734
|
h('span', { className: 'dsh-mcp-skill-cat-tag' }, sCat.name),
|
|
7311
7735
|
skillTimeBadge(s),
|
|
7312
|
-
skillMarketBadge(s),
|
|
7313
7736
|
h('div', { className: 'dsh-mcp-skill-sub' },
|
|
7314
7737
|
s.modelInvocable ? h(Badge, { cls: 't-http' }, t('模型可调用')) : null,
|
|
7315
7738
|
s.userInvocable ? h(Badge, { cls: 't-stdio' }, t('用户可调用')) : null,
|
|
@@ -7377,7 +7800,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7377
7800
|
type: 'button',
|
|
7378
7801
|
className: 'dsh-mcp-skill-d-path',
|
|
7379
7802
|
title: t('把 skill 所在目录交给宿主在资源管理器中打开(只读定位)'),
|
|
7380
|
-
onClick: () => { call('revealSkill', { path: summary.path }); },
|
|
7803
|
+
onClick: () => { call('revealSkill', { ...skillScopeArg(scope), name: summary.name, path: summary.path }); },
|
|
7381
7804
|
}, h(Icon, { d: ICONS.folder }), t('打开目录')) : null,
|
|
7382
7805
|
) : null,
|
|
7383
7806
|
summary ? h('div', { className: 'dsh-mcp-skill-d-body' },
|
|
@@ -7474,6 +7897,10 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
7474
7897
|
// wb* RPC(浏览器不直连腾讯/SkillHub),失败文案保留 Host 给出的原因。
|
|
7475
7898
|
// 已安装标记:技能按 wbSkillInstalled 的目录记录——只在进入面板时取一次,装/卸后局部刷新。
|
|
7476
7899
|
const WB_MARKET_CONNECTOR_PAGE_SIZE = 60;
|
|
7900
|
+
// 社区 MCP(官方 Registry):单页 100 是服务端上限,也是翻页步长——页内容互斥,
|
|
7901
|
+
// 内存里始终只有一页,不再需要「累计上限」。与 lib/mcp-community.js 的
|
|
7902
|
+
// COMMUNITY_PAGE_LIMIT 同值(浏览器侧是独立产物,改一处记得另一处)。
|
|
7903
|
+
const COMMUNITY_BATCH_LIMIT = 100;
|
|
7477
7904
|
// T02/R2:visible_in 标签筛选语义与 lib/wb-market.js 的 matchesVisibleIn 一致——
|
|
7478
7905
|
// selected 为空 = 全过;行未标 = 不受该维度过滤;命中为「任一命中」(OR,UI 直观)。
|
|
7479
7906
|
// 放这里而不是组件内:纯数据判断,避免每次渲染重建闭包。
|
|
@@ -7851,6 +8278,9 @@ const ExpertInstalledBoard = ({ call }) => {
|
|
|
7851
8278
|
? h('div', { className: 'dsh-mcp-card-tags' },
|
|
7852
8279
|
detail.skills.map((skill) => h(Badge, { key: skill.name, cls: skill.local ? 's-on' : '' }, skill.name)))
|
|
7853
8280
|
: null,
|
|
8281
|
+
detail.personaSource === 'generated'
|
|
8282
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('人设正文是按专家介绍生成的替身,不是原作者设定;原文不可用。'))
|
|
8283
|
+
: null,
|
|
7854
8284
|
detail.persona
|
|
7855
8285
|
? h('div', { className: 'dsh-mcp-skill-d-sec' },
|
|
7856
8286
|
h('b', null, t('人设正文({0} 字符)', [detail.personaChars || detail.persona.length])),
|
|
@@ -8196,6 +8626,9 @@ const ExpertMarket = ({ call }) => {
|
|
|
8196
8626
|
: h(Badge, null, t('无捆绑技能')),
|
|
8197
8627
|
)
|
|
8198
8628
|
: null,
|
|
8629
|
+
detail.personaSource === 'generated'
|
|
8630
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('人设正文是按专家介绍生成的替身,不是原作者设定;原文不可用。'))
|
|
8631
|
+
: null,
|
|
8199
8632
|
detail.persona
|
|
8200
8633
|
? h('div', { className: 'dsh-mcp-skill-d-sec' },
|
|
8201
8634
|
h('b', null, t('人设正文({0} 字符,召唤后成为会话系统提示)', [detail.personaChars || detail.persona.length])),
|
|
@@ -8592,8 +9025,11 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
8592
9025
|
// systemPrompt 的「本会话作用域 section」注入(与 dsh-persona / dsh-subagent 同款
|
|
8593
9026
|
// deployment:persona-prefix 遮蔽机制),因此:
|
|
8594
9027
|
// - 不受 agent-preset/locked 限制 —— 会话已开过口也能挂、能换、能摘;
|
|
8595
|
-
// -
|
|
9028
|
+
// - 只影响这一个会话,不动工具目录、不动 profile 预设;
|
|
8596
9029
|
// - 反复点不同专家 = 直接替换(Host 先摘旧的再装新的),不再是一次性。
|
|
9030
|
+
// 选择由 Host 落盘(session-persona.json),切会话/重启/压缩导致 agent 重建时会自动
|
|
9031
|
+
// 补回人设;chip 从 expertActive 同步的是「用户选择」而不是「当前是否恰好生效」,
|
|
9032
|
+
// 所以恢复过程中也不会闪没。
|
|
8597
9033
|
// 挂载只走人设通道(预设通道 agentPresets.select 会在会话投影里留下永不更新的
|
|
8598
9034
|
// 专家名,造成「顶栏显示 A、实际生效 B」的分叉——见 SessionExpertBadge 注释)。
|
|
8599
9035
|
// sessionId 来源:conversation.input.right 是 session 座位,slot 渲染时把 sessionId
|
|
@@ -8620,7 +9056,10 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
8620
9056
|
// 团队型说明只在 trigger 的 title(悬停)里出现,不进菜单气泡:
|
|
8621
9057
|
// 这句 60+ 字,菜单 min-width 只有 128px,塞进气泡会折成多行把菜单撑高。
|
|
8622
9058
|
// 成功态本来就有两处可见反馈——专家 chip(名字 + ×)与菜单项的「已挂」徽章。
|
|
8623
|
-
|
|
9059
|
+
// adopted:人设生效但拿不到撤销句柄,必须如实告诉用户取消要重启。
|
|
9060
|
+
setAttachNote(res.needsRestart
|
|
9061
|
+
? t('人设已生效,但该会话残留旧注册,取消需重启 DSH 后生效。')
|
|
9062
|
+
: (res.personaSource === 'generated' ? t('人设正文是按专家介绍生成的替身,不是原作者设定;原文不可用。') : ''));
|
|
8624
9063
|
setTeamHint(res.expertType === 'team'
|
|
8625
9064
|
? t('成员由主理人用 subagent 调度;若本会话没有 subagent 工具,请新建会话并选择「WorkBuddy 专家 · {0}」预设', [res.name || item.name || item.plugin])
|
|
8626
9065
|
: '');
|
|
@@ -8646,7 +9085,9 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
8646
9085
|
const res = await call('expertDetach', { sessionId }).catch(() => null);
|
|
8647
9086
|
setAttached(null);
|
|
8648
9087
|
setTeamHint('');
|
|
8649
|
-
setAttachNote(res
|
|
9088
|
+
setAttachNote(res?.needsRestart
|
|
9089
|
+
? t('人设已生效,但该会话残留旧注册,取消需重启 DSH 后生效。')
|
|
9090
|
+
: (res && res.ok && res.removed ? t('✓ 已取消本会话的专家') : t('本会话没有挂着专家')));
|
|
8650
9091
|
// 摘掉也要广播:会话头徽章退场、被隐身的预设标签复原。
|
|
8651
9092
|
emitExpertChange();
|
|
8652
9093
|
} catch (e) {
|
|
@@ -8869,11 +9310,15 @@ const SessionExpertBadge = ({ call, sessionId }) => {
|
|
|
8869
9310
|
}).catch(() => { if (!cancelled) setReady(true); });
|
|
8870
9311
|
};
|
|
8871
9312
|
setReady(false);
|
|
8872
|
-
|
|
9313
|
+
// P0-3:首拉推迟到空闲帧。会话头挂载时宿主正在铺消息列表,这一发 RPC + 回包后的
|
|
9314
|
+
// setState 会跟首屏抢主线程;徽章本来就要等真源回来才有内容,晚一个空闲帧无感。
|
|
9315
|
+
// 专家变更总线(onExpertChange)仍然立即同步——那是用户动作,等不得。
|
|
9316
|
+
const cancelIdleSync = scheduleIdle(() => { if (!cancelled) sync(); });
|
|
8873
9317
|
const off = onExpertChange(sync);
|
|
8874
|
-
// OPT-05:同上,徽章这一处也改走可见性感知轮询。
|
|
8875
|
-
|
|
8876
|
-
|
|
9318
|
+
// OPT-05:同上,徽章这一处也改走可见性感知轮询。P0-3:5s → 15s——挂/换/摘都由总线
|
|
9319
|
+
// 立即触发,轮询只兜底「别的进程改了人设」这类外部变化,15s 足够且省下 2/3 的调用。
|
|
9320
|
+
const stopPoll = startVisibilityAwarePolling(typeof document === 'undefined' ? null : document, null, 15000, sync);
|
|
9321
|
+
return () => { cancelled = true; cancelIdleSync(); off(); stopPoll(); };
|
|
8877
9322
|
}, [sessionId, call]);
|
|
8878
9323
|
// 挂上专家时,把同容器里那条**陈旧的预设标签**藏掉:它读的是会话创建时的
|
|
8879
9324
|
// agentPreset 投影(宿主只读、换专家不动它),所以换到第二个专家后它仍显示第一个,
|
|
@@ -8963,6 +9408,20 @@ const wbFormatDate = (ms) => {
|
|
|
8963
9408
|
if (!ms) return '';
|
|
8964
9409
|
try { return new Date(Number(ms)).toISOString().slice(0, 10); } catch { return ''; }
|
|
8965
9410
|
};
|
|
9411
|
+
// OAuth 记录里的 expiresAt 是 ISO 串(旧版本可能写成数字毫秒):两种都认,认不出就原样透出,
|
|
9412
|
+
// 不编一个「永久有效」——用户要的是「什么时候会失效」这个事实。
|
|
9413
|
+
const authExpiryText = (value) => {
|
|
9414
|
+
if (value === undefined || value === null || value === '') return '';
|
|
9415
|
+
const raw = typeof value === 'number' ? value : Date.parse(String(value));
|
|
9416
|
+
if (!Number.isFinite(raw)) return String(value);
|
|
9417
|
+
try {
|
|
9418
|
+
const date = new Date(raw);
|
|
9419
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
9420
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
9421
|
+
} catch {
|
|
9422
|
+
return String(value);
|
|
9423
|
+
}
|
|
9424
|
+
};
|
|
8966
9425
|
|
|
8967
9426
|
// ── 社区「人味」信号:真实的人、真实的维护节奏、真实的反馈出口 ────────────────
|
|
8968
9427
|
// 市场目录里的条目不该只是「一个可安装的对象」:谁做的、最后一次动是什么时候、
|
|
@@ -9475,6 +9934,17 @@ const browsableHomepageUrl = (value) => {
|
|
|
9475
9934
|
if (/^https?:\/\/api\.skillhub\.cn(?=[/?#]|$)/i.test(url)) return '';
|
|
9476
9935
|
return url;
|
|
9477
9936
|
};
|
|
9937
|
+
// 外部链接只接受 https。社区 MCP 的 repository.url 由上游提交,javascript: / file:
|
|
9938
|
+
// 直接进 href 会在点击时执行;数据层已过滤,渲染层再兜一层(旧缓存 / 上游变化)。
|
|
9939
|
+
const externalHref = (value) => {
|
|
9940
|
+
const url = String(value || '').trim();
|
|
9941
|
+
if (!/^https:\/\//i.test(url)) return '';
|
|
9942
|
+
try {
|
|
9943
|
+
return new URL(url).toString();
|
|
9944
|
+
} catch {
|
|
9945
|
+
return '';
|
|
9946
|
+
}
|
|
9947
|
+
};
|
|
9478
9948
|
// 详情回包只覆盖「有值」的字段:上游对缺失字段返回空串 / 空数组时,不能把列表项里
|
|
9479
9949
|
// 已有的中文名、介绍、上架时间冲掉(issue #4)。0 与 false 是合法值,照常覆盖。
|
|
9480
9950
|
const mergeSkillDetail = (listItem, detail) => {
|
|
@@ -10031,10 +10501,119 @@ const WbSearchBox = ({ value, onChange, placeholder, busy, onCommit }) =>
|
|
|
10031
10501
|
}, h(Icon, { d: ICONS.x, size: 12 }))
|
|
10032
10502
|
: null,
|
|
10033
10503
|
);
|
|
10504
|
+
// ── P1-5:技能列表 SWR(模块级,跨面板开关存活)────────────────────────────
|
|
10505
|
+
// SkillHub 是远程分页接口(本机实测 187–229ms/次),而用户在「技能 ↔ 连接器」之间
|
|
10506
|
+
// 来回切时看到的往往还是同一页。按 page|sort|keyword 缓存 60 秒:命中直接渲染,
|
|
10507
|
+
// 不再发那一次远程请求;过期后照常请求。搜索词 / 排序 / 页码都是缓存键的一部分,
|
|
10508
|
+
// 不会把 A 的结果显示到 B 上。条数封顶 30 条,避免长会话里无限涨。
|
|
10509
|
+
const SKILL_SWR_TTL_MS = 60 * 1000;
|
|
10510
|
+
const SKILL_SWR_MAX_ENTRIES = 30;
|
|
10511
|
+
const skillListCache = new Map(); // key -> { at, skills, total }
|
|
10512
|
+
function readSkillCache(key) {
|
|
10513
|
+
if (!MARKET_PERF.skillSwr) return null;
|
|
10514
|
+
const hit = skillListCache.get(key);
|
|
10515
|
+
if (!hit) return null;
|
|
10516
|
+
if (Date.now() - hit.at > SKILL_SWR_TTL_MS) { skillListCache.delete(key); return null; }
|
|
10517
|
+
return hit;
|
|
10518
|
+
}
|
|
10519
|
+
function writeSkillCache(key, value) {
|
|
10520
|
+
if (!MARKET_PERF.skillSwr) return;
|
|
10521
|
+
skillListCache.set(key, { at: Date.now(), ...value });
|
|
10522
|
+
while (skillListCache.size > SKILL_SWR_MAX_ENTRIES) {
|
|
10523
|
+
const oldest = skillListCache.keys().next();
|
|
10524
|
+
if (oldest.done) break;
|
|
10525
|
+
skillListCache.delete(oldest.value);
|
|
10526
|
+
}
|
|
10527
|
+
}
|
|
10528
|
+
// ── P2-3:卡片 hover 预取详情(模块级,单条缓存)──────────────────────────────
|
|
10529
|
+
// 鼠标扫过一屏卡片会连发几十次 mouseenter,所以在「停在同一张卡上 200ms」后才发一条;
|
|
10530
|
+
// 离开卡片立即取消,进入下一张会把上一条挂起的取消掉(同时最多一条待发、一条在飞)。
|
|
10531
|
+
// 三类详情各留**最近一条**结果:用户真点开时直接用手上的回包,二次请求为 0;
|
|
10532
|
+
// 换一张卡就覆盖,不做无界字典。60 秒 TTL 只为了不把「很久前的授权态」摆到眼前
|
|
10533
|
+
// (授权 / 重连之后状态会变);技能详情与 README 属内容型数据,超时重拉也不吃亏。
|
|
10534
|
+
const HOVER_PREFETCH_DELAY_MS = 200;
|
|
10535
|
+
const DETAIL_MEMO_TTL_MS = 60 * 1000;
|
|
10536
|
+
let hoverPrefetchTimer = null;
|
|
10537
|
+
const hoverMemo = {
|
|
10538
|
+
skillDetail: { key: '', at: 0, value: null },
|
|
10539
|
+
connectorAuth: { key: '', at: 0, value: null },
|
|
10540
|
+
readme: { key: '', at: 0, value: null },
|
|
10541
|
+
};
|
|
10542
|
+
function cancelHoverPrefetch() {
|
|
10543
|
+
if (hoverPrefetchTimer) { clearTimeout(hoverPrefetchTimer); hoverPrefetchTimer = null; }
|
|
10544
|
+
}
|
|
10545
|
+
function scheduleHoverPrefetch(run) {
|
|
10546
|
+
if (!MARKET_PERF.hoverPrefetch) return;
|
|
10547
|
+
cancelHoverPrefetch();
|
|
10548
|
+
hoverPrefetchTimer = setTimeout(() => {
|
|
10549
|
+
hoverPrefetchTimer = null;
|
|
10550
|
+
try { run(); } catch { /* 预取失败无感:点开时照常请求 */ }
|
|
10551
|
+
}, HOVER_PREFETCH_DELAY_MS);
|
|
10552
|
+
}
|
|
10553
|
+
function readDetailMemo(slot, key) {
|
|
10554
|
+
if (!MARKET_PERF.hoverPrefetch) return null;
|
|
10555
|
+
if (!key || slot.key !== key || !slot.value) return null;
|
|
10556
|
+
if (Date.now() - slot.at > DETAIL_MEMO_TTL_MS) return null;
|
|
10557
|
+
return slot.value;
|
|
10558
|
+
}
|
|
10559
|
+
function writeDetailMemo(slot, key, value) {
|
|
10560
|
+
if (!MARKET_PERF.hoverPrefetch || !key || !value) return;
|
|
10561
|
+
slot.key = key; slot.at = Date.now(); slot.value = value;
|
|
10562
|
+
}
|
|
10563
|
+
const skillDetailMemoKey = (skill) => String(skill?.namespace || '') + '/' + String(skill?.slug || '');
|
|
10564
|
+
const readmeMemoKey = (item) => {
|
|
10565
|
+
const npm = typeof item?.npm === 'string' ? item.npm : (item?.npm && item.npm.name) || '';
|
|
10566
|
+
return String(item?.name || '') + '|' + npm + '|' + String(item?.repo || '');
|
|
10567
|
+
};
|
|
10568
|
+
// ── 「最近动静」跳转口径:直达该来源的**全量市场** ────────────────────────────
|
|
10569
|
+
// 「新增 3 条 / 更新 2 条」说的是整个目录的变化,落到一半的过滤条件里就看不见它了:
|
|
10570
|
+
// 搜索词、只看已安装、分类、排序、页码任意一项生效,用户还得自己挨个清掉才能核对。
|
|
10571
|
+
// 所以点条目时把目标页签重置回全量:搜索清空、筛选取消、排序回默认、页码回 1。
|
|
10572
|
+
// 口径写成纯函数(不碰 state),行为可被测试直接跑;组件只负责把值写回 setter。
|
|
10573
|
+
// 连接器:回到 WorkBuddy 源(动静只记录这份目录),并取消「只看可直接安装 / 已安装」;
|
|
10574
|
+
// 技能:清空搜索与排序、回第 1 页(记录口径本身就是默认页 / 无关键词);
|
|
10575
|
+
// 插件:清空搜索、分类、标签与「只看已安装」,排序回 stars;
|
|
10576
|
+
// 专家:状态全在 ExpertMarket 内部,交回「重挂」信号,由组件递增 key 回到全量首屏。
|
|
10577
|
+
const marketFullScope = (tabKey) => {
|
|
10578
|
+
if (tabKey === 'connector') {
|
|
10579
|
+
return {
|
|
10580
|
+
connectorFeed: 'wb',
|
|
10581
|
+
connectorQuery: '',
|
|
10582
|
+
connectorOnlyReady: false,
|
|
10583
|
+
connectorOnlyInstalled: false,
|
|
10584
|
+
connectorSort: 'default',
|
|
10585
|
+
connectorPage: 1,
|
|
10586
|
+
};
|
|
10587
|
+
}
|
|
10588
|
+
if (tabKey === 'skill') {
|
|
10589
|
+
return {
|
|
10590
|
+
skillKeyword: '',
|
|
10591
|
+
skillKeywordInput: '',
|
|
10592
|
+
skillSort: 'downloads',
|
|
10593
|
+
skillOnlyInstalled: false,
|
|
10594
|
+
skillPage: 1,
|
|
10595
|
+
};
|
|
10596
|
+
}
|
|
10597
|
+
if (tabKey === 'plugin') {
|
|
10598
|
+
return {
|
|
10599
|
+
awesomeQuery: '',
|
|
10600
|
+
awesomeCategory: 'all',
|
|
10601
|
+
awesomeTags: [],
|
|
10602
|
+
awesomeSort: 'stars',
|
|
10603
|
+
awesomeOnlyInstalled: false,
|
|
10604
|
+
awesomePage: 1,
|
|
10605
|
+
};
|
|
10606
|
+
}
|
|
10607
|
+
if (tabKey === 'expert') return { expertRemount: true };
|
|
10608
|
+
return null;
|
|
10609
|
+
};
|
|
10034
10610
|
const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
10035
10611
|
// ── 共享状态 ──
|
|
10036
10612
|
const [tab, setTab] = useState('connector'); // connector | skill
|
|
10037
10613
|
const [loading, setLoading] = useState(true);
|
|
10614
|
+
// P1-1:连接器首屏(含快照)是否已落地。插件 / 专家目录预取都等它,避免三个市场
|
|
10615
|
+
// 同时抢 IPC 与主线程——首屏那 300ms 是用户唯一能感知到的部分。
|
|
10616
|
+
const [connectorSettled, setConnectorSettled] = useState(false);
|
|
10038
10617
|
const [error, setError] = useState('');
|
|
10039
10618
|
const [busy, setBusy] = useState(false);
|
|
10040
10619
|
const [busyLabel, setBusyLabel] = useState('');
|
|
@@ -10057,12 +10636,49 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10057
10636
|
const [connectors, setConnectors] = useState([]);
|
|
10058
10637
|
const [connectorQuery, setConnectorQuery] = useState('');
|
|
10059
10638
|
const [connectorOnlyReady, setConnectorOnlyReady] = useState(true);
|
|
10639
|
+
// ── 社区 MCP(连接器页的第二个来源)──
|
|
10640
|
+
// 与上面的 connectorSource(目录来自 network/disk)不是一回事:这里是「当前看哪个来源」,
|
|
10641
|
+
// 名字必须区分。默认停在 wb,行为与改造前完全一致。
|
|
10642
|
+
const [connectorFeed, setConnectorFeed] = useState('wb');
|
|
10643
|
+
const [communityRows, setCommunityRows] = useState([]);
|
|
10644
|
+
const [communityQueryInput, setCommunityQueryInput] = useState('');
|
|
10645
|
+
const [communityQuery, setCommunityQuery] = useState('');
|
|
10646
|
+
const [communityNextCursor, setCommunityNextCursor] = useState(null);
|
|
10647
|
+
// 翻页:Registry 是游标分页且不返回总数,前端只记「第几页」与本次查询已读取的累计条数。
|
|
10648
|
+
// 页内容互斥(第 2 页替换第 1 页),不像「加载更多」那样越堆越长。
|
|
10649
|
+
const [communityPage, setCommunityPage] = useState(1);
|
|
10650
|
+
const [communityReadTotal, setCommunityReadTotal] = useState(0);
|
|
10651
|
+
const [communityLoading, setCommunityLoading] = useState(false);
|
|
10652
|
+
const [communityError, setCommunityError] = useState('');
|
|
10653
|
+
// 「缓存结果」:断网 / 上游变慢时保留上次内存结果并如实标注,而不是清空列表。
|
|
10654
|
+
const [communityStale, setCommunityStale] = useState(false);
|
|
10655
|
+
const [communityOnlyRunnable, setCommunityOnlyRunnable] = useState(true);
|
|
10656
|
+
// 社区目录「全库总数」(连接器页徽标常驻显示的那个数)。口径与续爬策略见模块级
|
|
10657
|
+
// COMMUNITY_COUNT_KEY 一节:上游不给总数,只能有界地一段一段数,进度存本地。
|
|
10658
|
+
const [communityCount, setCommunityCount] = useState(readCommunityCount);
|
|
10659
|
+
const communityCountRef = useRef(communityCount);
|
|
10660
|
+
// 本次面板会话已花掉的统计页数(不是累计条数):预算用尽就不再占网络,下次开面板接着来。
|
|
10661
|
+
const communityCountPagesRef = useRef(0);
|
|
10662
|
+
const communityCountBusyRef = useRef(false);
|
|
10663
|
+
// 社区条目的「加入 MCP」草稿:预填 AddEditModal,用户确认后才写盘。
|
|
10664
|
+
const [communityDraft, setCommunityDraft] = useState(null);
|
|
10665
|
+
// 社区卡片详情:点整卡打开,交互与插件 README 弹层一致(遮罩 / Esc 关闭、焦点陷阱)。
|
|
10666
|
+
// 内容全部来自当前列表行(Registry 一次响应已带 command / args / env),不再发请求。
|
|
10667
|
+
const [communityDetail, setCommunityDetail] = useState(null);
|
|
10060
10668
|
// T04/R8:已安装筛选(全量本地筛选,不受分页影响——与技能侧「仅本页」不同)。
|
|
10061
10669
|
const [connectorOnlyInstalled, setConnectorOnlyInstalled] = useState(false);
|
|
10062
10670
|
// T05/R10:连接器排序键(全量本地排序)。
|
|
10063
10671
|
const [connectorSort, setConnectorSort] = useState('default');
|
|
10064
10672
|
const [connectorPage, setConnectorPage] = useState(1);
|
|
10065
10673
|
const [connectorDetail, setConnectorDetail] = useState(null);
|
|
10674
|
+
// P0:连接器「真实连接态」。市场卡片此前只认安装账本——「写进过配置」不等于「连得上」,
|
|
10675
|
+
// 用户看到「已安装」却用不了(ima / 腾讯自选股就是这么被误判的)。这里读 MCP 面板同一份
|
|
10676
|
+
// list() 投影:连接态的唯一证据是工具数(lib/mcp-observability.js deriveMcpPhase),不猜日志。
|
|
10677
|
+
const [mcpServerRows, setMcpServerRows] = useState([]);
|
|
10678
|
+
// 详情弹层里的授权概况(wbOAuthOverview:只读落盘记录,不联网)。此前这个 RPC 声明了
|
|
10679
|
+
// 却从未被调用,用户看不到授权是否还有效、什么时候过期。
|
|
10680
|
+
const [connectorAuth, setConnectorAuth] = useState(null);
|
|
10681
|
+
const connectorDetailIdRef = useRef('');
|
|
10066
10682
|
// ── 技能 ──
|
|
10067
10683
|
const [skillPage, setSkillPage] = useState(1);
|
|
10068
10684
|
const [skillSort, setSkillSort] = useState('downloads');
|
|
@@ -10108,8 +10724,19 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10108
10724
|
// OPT-21:两个详情弹层此前都没有 ref(焦点陷阱需要容器节点)。
|
|
10109
10725
|
const connectorDetailRef = useRef(null);
|
|
10110
10726
|
const skillDetailRef = useRef(null);
|
|
10111
|
-
|
|
10727
|
+
// 技能详情请求序号:关掉弹层或改点另一条后,在途回包必须作废,
|
|
10728
|
+
// 否则会把已关闭的弹层重新打开,或把 A 的详情盖到 B 上(issue #4 同类竞态)。
|
|
10729
|
+
const skillDetailSeq = useRef(0);
|
|
10730
|
+
const closeSkillDetail = () => {
|
|
10731
|
+
skillDetailSeq.current += 1;
|
|
10732
|
+
setSkillDetail(null);
|
|
10733
|
+
};
|
|
10734
|
+
// 社区详情若从弹层里点「加入 MCP」会先关掉自己再开 AddEditModal,两层不会叠加。
|
|
10735
|
+
const communityDetailRef = useRef(null);
|
|
10736
|
+
// 令牌 / 授权弹层可能叠在详情之上:此时只让上层陷阱接管 Tab,避免两层焦点陷阱互抢。
|
|
10737
|
+
useFocusTrap(connectorDetailRef, !!connectorDetail && !tokenDialog && !authDialog);
|
|
10112
10738
|
useFocusTrap(skillDetailRef, !!skillDetail);
|
|
10739
|
+
useFocusTrap(communityDetailRef, !!communityDetail);
|
|
10113
10740
|
const WB_PAGE_SIZE = 20;
|
|
10114
10741
|
|
|
10115
10742
|
const loadInstalled = useCallback(async () => {
|
|
@@ -10133,22 +10760,54 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10133
10760
|
installRecords.filter((row) => row && row.kind === kind).map((row) => String(row.key || '')),
|
|
10134
10761
|
);
|
|
10135
10762
|
|
|
10763
|
+
// P0:MCP 连接态。失败只清空(不显示健康徽标),绝不打断市场主流程——连接态是辅助信息。
|
|
10764
|
+
const loadMcpServerRows = useCallback(async () => {
|
|
10765
|
+
try {
|
|
10766
|
+
const res = await call('list');
|
|
10767
|
+
setMcpServerRows(Array.isArray(res?.servers) ? res.servers : []);
|
|
10768
|
+
} catch {
|
|
10769
|
+
setMcpServerRows([]);
|
|
10770
|
+
}
|
|
10771
|
+
}, [call]);
|
|
10772
|
+
|
|
10136
10773
|
const loadConnectors = useCallback(async () => {
|
|
10137
|
-
|
|
10774
|
+
// P1-4 / P2-2:先铺快照(本次会话的内存镜像优先,其次 24h 内的 localStorage 镜像),
|
|
10775
|
+
// 再后台刷新。快照命中时不再进入骨架屏——用户第一眼就是列表。
|
|
10776
|
+
const cached = connectorCatalogMemo || readConnectorSnapshot();
|
|
10777
|
+
if (cached) {
|
|
10778
|
+
setConnectors(cached.connectors);
|
|
10779
|
+
setConnectorSource(cached.source || '');
|
|
10780
|
+
setLoading(false);
|
|
10781
|
+
setConnectorSettled(true);
|
|
10782
|
+
} else {
|
|
10783
|
+
setLoading(true);
|
|
10784
|
+
}
|
|
10785
|
+
setError(''); setNote('');
|
|
10138
10786
|
try {
|
|
10139
10787
|
const res = await call('wbConnectorCatalog');
|
|
10140
10788
|
if (!res?.ok) throw new Error(res?.error || t('读取连接器目录失败'));
|
|
10141
10789
|
const rows = res.connectors || [];
|
|
10142
10790
|
setConnectors(rows);
|
|
10143
10791
|
setConnectorSource(res.loadedFrom || '');
|
|
10792
|
+
connectorCatalogMemo = { connectors: rows, source: res.loadedFrom || '', at: Date.now() };
|
|
10793
|
+
const snapshotSig = rows.length + '|' + (res.loadedFrom || '') + '|'
|
|
10794
|
+
+ rows.map((row) => row.id + ':' + (row.version || '') + ':' + (row.installable ? '1' : '0')).join(',');
|
|
10795
|
+
if (snapshotSig !== connectorSnapshotSigRef.current) {
|
|
10796
|
+
connectorSnapshotSigRef.current = snapshotSig;
|
|
10797
|
+
scheduleIdle(() => writeConnectorSnapshot({ connectors: rows, source: res.loadedFrom }), 5000);
|
|
10798
|
+
}
|
|
10144
10799
|
// 目录快照只记「版本 + 可装性」这种稳定签名:星数 / 下载量波动不是「动静」。
|
|
10145
10800
|
if (recordMarketSnapshot('connector', rows.map((row) => ({
|
|
10146
10801
|
key: row.id, name: row.name, sig: String(row.version || '') + '|' + (row.installable ? '1' : '0'),
|
|
10147
10802
|
})))) refreshPulse();
|
|
10148
10803
|
} catch (e) {
|
|
10149
|
-
|
|
10804
|
+
// 有快照时不拿错误条盖掉列表:列表还能用(只是可能过期),错误降级成一行提示。
|
|
10805
|
+
if (cached) setNote(t('刷新连接器目录失败,当前显示的是本机快照:{0}', [String(e?.message || e)]));
|
|
10806
|
+
else setError(String(e?.message || e));
|
|
10150
10807
|
} finally {
|
|
10151
10808
|
setLoading(false);
|
|
10809
|
+
// P1-1:连接器首屏已落地(成功或失败都算),后台预取可以排队了。
|
|
10810
|
+
setConnectorSettled(true);
|
|
10152
10811
|
}
|
|
10153
10812
|
}, [call, refreshPulse]);
|
|
10154
10813
|
|
|
@@ -10156,6 +10815,17 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10156
10815
|
const skillSeq = useRef(0);
|
|
10157
10816
|
const loadSkills = useCallback(async (page, sort, keyword) => {
|
|
10158
10817
|
const seq = ++skillSeq.current;
|
|
10818
|
+
// P1-5:60 秒内的同参查询直接吃缓存(不占 SkillHub 配额、不闪 loading)。
|
|
10819
|
+
const cacheKey = page + '|' + sort + '|' + String(keyword || '');
|
|
10820
|
+
const cached = readSkillCache(cacheKey);
|
|
10821
|
+
if (cached) {
|
|
10822
|
+
setSkills(cached.skills);
|
|
10823
|
+
setSkillTotal(cached.total);
|
|
10824
|
+
if (!String(keyword || '').trim()) setSkillMarketTotal(cached.total);
|
|
10825
|
+
setSkillLoading(false);
|
|
10826
|
+
setSkillError('');
|
|
10827
|
+
return;
|
|
10828
|
+
}
|
|
10159
10829
|
setSkillLoading(true); setSkillError('');
|
|
10160
10830
|
try {
|
|
10161
10831
|
const res = await call('wbSkillCatalog', { page, sort, keyword, pageSize: WB_PAGE_SIZE });
|
|
@@ -10164,6 +10834,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10164
10834
|
const rows = res.skills || [];
|
|
10165
10835
|
setSkills(rows);
|
|
10166
10836
|
setSkillTotal(res.total || 0);
|
|
10837
|
+
writeSkillCache(cacheKey, { skills: rows, total: res.total || 0 });
|
|
10167
10838
|
if (!String(keyword || '').trim()) setSkillMarketTotal(res.total || 0);
|
|
10168
10839
|
// 「最近动静」:技能是远程分页 + 服务端搜索,任何一次响应都只是 19 万库里的一个切片,
|
|
10169
10840
|
// 切片之间互比等于自欺。只有**服务端全局排序**(最多安装 / 最多星标 / 综合评分)的
|
|
@@ -10189,7 +10860,8 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10189
10860
|
loadConnectors();
|
|
10190
10861
|
loadInstalled();
|
|
10191
10862
|
loadInstallState();
|
|
10192
|
-
|
|
10863
|
+
loadMcpServerRows();
|
|
10864
|
+
}, [loadConnectors, loadInstalled, loadInstallState, loadMcpServerRows]);
|
|
10193
10865
|
useEffect(() => {
|
|
10194
10866
|
loadSkills(skillPage, skillSort, skillKeyword);
|
|
10195
10867
|
}, [loadSkills, skillPage, skillSort, skillKeyword]);
|
|
@@ -10243,9 +10915,313 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10243
10915
|
const safeConnectorPage = Math.min(connectorPage, connectorPageCount);
|
|
10244
10916
|
const visibleConnectors = sortedConnectors.slice((safeConnectorPage - 1) * WB_MARKET_CONNECTOR_PAGE_SIZE, safeConnectorPage * WB_MARKET_CONNECTOR_PAGE_SIZE);
|
|
10245
10917
|
|
|
10918
|
+
// ── 社区 MCP:取数 / 游标翻页 ──
|
|
10919
|
+
// Registry 是游标分页、不返回总数,所以不用按总页数算的 Pager:页面只保留当前一页,
|
|
10920
|
+
// 「上一页 / 下一页」按游标栈跳转(第 N 页的游标 = 第 N-1 页返回的 nextCursor)。
|
|
10921
|
+
// 顶部数量显示的是本次查询「已读取的总数」(跨页累计去重),不是当前页条数。
|
|
10922
|
+
// 请求序号照 skillSeq,过期响应直接丢弃。
|
|
10923
|
+
// 页面卸载时清掉「加入后补刷」的定时器,避免对已卸载组件 setState。
|
|
10924
|
+
const communityDraftRefreshRef = useRef(null);
|
|
10925
|
+
// 连接器快照的签名:目录没变(条数 / 来源 / 每条的 id|version|installable)就不重写,
|
|
10926
|
+
// 也不在返回渲染的同一帧里做一次 769KB 序列化——写盘推到空闲帧(与插件快照同款)。
|
|
10927
|
+
const connectorSnapshotSigRef = useRef('');
|
|
10928
|
+
useEffect(() => () => { if (communityDraftRefreshRef.current) clearTimeout(communityDraftRefreshRef.current); }, []);
|
|
10929
|
+
// 离场时取消还在防抖窗口里的 hover 预取:晚到的预取只会白烧一次远程请求
|
|
10930
|
+
// (结果写进模块级 memo,但用户已经不在这个面板上了)。
|
|
10931
|
+
useEffect(() => () => cancelHoverPrefetch(), []);
|
|
10932
|
+
const communitySeq = useRef(0);
|
|
10933
|
+
const communityRowsRef = useRef([]);
|
|
10934
|
+
const communityQueryRef = useRef('');
|
|
10935
|
+
const communityLoadedQueryRef = useRef(null);
|
|
10936
|
+
const communityNamesRef = useRef([]);
|
|
10937
|
+
// 游标栈:cursors[0] 恒为 ''(第一页),cursors[n] = 第 n+1 页的游标。
|
|
10938
|
+
// 已读 key 集合与游标栈都按查询词隔离——换搜索词就从头开始。
|
|
10939
|
+
const communityCursorsRef = useRef([]);
|
|
10940
|
+
const communityReadKeysRef = useRef(new Set());
|
|
10941
|
+
const communityReadQueryRef = useRef(null);
|
|
10942
|
+
// 后台统计要让路给列表请求:循环里读到的是最新值,所以每次渲染同步一次。
|
|
10943
|
+
const communityLoadingRef = useRef(false);
|
|
10944
|
+
// 每次渲染同步最新值:loadCommunity 不把 rows/query/已装列表塞进依赖——
|
|
10945
|
+
// 否则 MCP 列表一刷新就重建回调,让防抖 / 懒加载 effect 误触发第二次请求。
|
|
10946
|
+
communityRowsRef.current = communityRows;
|
|
10947
|
+
communityLoadingRef.current = communityLoading;
|
|
10948
|
+
communityQueryRef.current = communityQuery;
|
|
10949
|
+
communityNamesRef.current = mcpServerRows.map((row) => String(row?.serverName || '')).filter(Boolean);
|
|
10950
|
+
|
|
10951
|
+
const loadCommunity = useCallback(async ({ cursor = '', page = 1, query } = {}) => {
|
|
10952
|
+
const search = query === undefined ? communityQueryRef.current : String(query || '');
|
|
10953
|
+
const seq = ++communitySeq.current;
|
|
10954
|
+
setCommunityLoading(true);
|
|
10955
|
+
setCommunityError('');
|
|
10956
|
+
try {
|
|
10957
|
+
const res = await call('wbMcpCommunityList', {
|
|
10958
|
+
search,
|
|
10959
|
+
cursor,
|
|
10960
|
+
limit: COMMUNITY_BATCH_LIMIT,
|
|
10961
|
+
takenNames: communityNamesRef.current,
|
|
10962
|
+
});
|
|
10963
|
+
if (seq !== communitySeq.current) return; // 过期响应,直接丢弃
|
|
10964
|
+
if (!res?.ok) throw new Error(res?.error || t('读取社区 MCP 失败'));
|
|
10965
|
+
// 新查询:游标栈与已读计数一起归零(同一个词的来回翻页则保留累计)。
|
|
10966
|
+
if (communityReadQueryRef.current !== search) {
|
|
10967
|
+
communityReadQueryRef.current = search;
|
|
10968
|
+
communityReadKeysRef.current = new Set();
|
|
10969
|
+
communityCursorsRef.current = [];
|
|
10970
|
+
}
|
|
10971
|
+
const incoming = Array.isArray(res.rows) ? res.rows.filter(Boolean) : [];
|
|
10972
|
+
const unique = [];
|
|
10973
|
+
const seen = new Set();
|
|
10974
|
+
for (const row of incoming) {
|
|
10975
|
+
if (!row?.key || seen.has(row.key)) continue;
|
|
10976
|
+
seen.add(row.key);
|
|
10977
|
+
unique.push(row);
|
|
10978
|
+
}
|
|
10979
|
+
setCommunityRows(unique);
|
|
10980
|
+
setCommunityPage(page);
|
|
10981
|
+
setCommunityNextCursor(res.nextCursor || null);
|
|
10982
|
+
setCommunityStale(res.cached === true);
|
|
10983
|
+
communityCursorsRef.current[page - 1] = String(cursor || '');
|
|
10984
|
+
communityCursorsRef.current[page] = res.nextCursor ? String(res.nextCursor) : '';
|
|
10985
|
+
for (const row of unique) communityReadKeysRef.current.add(row.key);
|
|
10986
|
+
setCommunityReadTotal(communityReadKeysRef.current.size);
|
|
10987
|
+
communityLoadedQueryRef.current = search;
|
|
10988
|
+
} catch (e) {
|
|
10989
|
+
if (seq !== communitySeq.current) return;
|
|
10990
|
+
setCommunityError(String(e?.message || e));
|
|
10991
|
+
// 上次结果还在内存里就保留并标「缓存结果」;否则列表保持为空、只显示错误行。
|
|
10992
|
+
if (communityRowsRef.current.length) setCommunityStale(true);
|
|
10993
|
+
// 失败不算「这个词已经加载过」:切回来 / 重试时应重新请求。
|
|
10994
|
+
communityLoadedQueryRef.current = null;
|
|
10995
|
+
} finally {
|
|
10996
|
+
if (seq === communitySeq.current) setCommunityLoading(false);
|
|
10997
|
+
}
|
|
10998
|
+
}, [call]);
|
|
10999
|
+
|
|
11000
|
+
// 社区搜索:输入即搜(服务端 search),500ms 防抖合并连击;回车立即搜。
|
|
11001
|
+
useEffect(() => {
|
|
11002
|
+
const timer = setTimeout(() => setCommunityQuery(communityQueryInput.trim()), 500);
|
|
11003
|
+
return () => clearTimeout(timer);
|
|
11004
|
+
}, [communityQueryInput]);
|
|
11005
|
+
|
|
11006
|
+
// 懒加载:首次切到社区、或换搜索词才发请求;切回 WB 不卸载任何状态(列表/搜索词原样保留)。
|
|
11007
|
+
useEffect(() => {
|
|
11008
|
+
if (connectorFeed !== 'community') return;
|
|
11009
|
+
// 只看「这个词是否成功加载过」:空结果是合法结果,不能因为 length=0 每次切回来都重发。
|
|
11010
|
+
if (communityLoadedQueryRef.current === communityQuery) return;
|
|
11011
|
+
loadCommunity({ cursor: '' });
|
|
11012
|
+
}, [connectorFeed, communityQuery, loadCommunity]);
|
|
11013
|
+
|
|
11014
|
+
// ── 社区目录全库总数:有界、可续爬的后台统计 ──
|
|
11015
|
+
// 进连接器页 + 停在社区源时才开,而且先用 scheduleIdle 让列表请求走前面;然后一块一块数
|
|
11016
|
+
// (每块 3 页),本次会话最多 12 页 —— 全库实测 ≥13,000 条,一次爬完要十几分钟和约 9MB,
|
|
11017
|
+
// 不能占住用户的网络。每数完一块立刻落盘并更新徽标,所以用户看到的是数字在慢慢长准。
|
|
11018
|
+
// 上游失败就保住已数到的部分直接收工(不重试、不清进度),下次进面板从记录的游标接着来。
|
|
11019
|
+
useEffect(() => {
|
|
11020
|
+
if (tab !== 'connector' || connectorFeed !== 'community') return undefined;
|
|
11021
|
+
let cancelled = false;
|
|
11022
|
+
let pauseTimer = null;
|
|
11023
|
+
const runChunks = async () => {
|
|
11024
|
+
if (cancelled || communityCountBusyRef.current) return; // 同一面板里只允许一条统计流水
|
|
11025
|
+
communityCountBusyRef.current = true;
|
|
11026
|
+
try {
|
|
11027
|
+
while (!cancelled && communityCountPagesRef.current < COMMUNITY_COUNT_PAGE_BUDGET) {
|
|
11028
|
+
const record = communityCountRef.current || emptyCommunityCount();
|
|
11029
|
+
if (record.done) break;
|
|
11030
|
+
const res = await call('wbMcpCommunityCount', {
|
|
11031
|
+
cursor: record.cursor || '',
|
|
11032
|
+
pages: Math.min(COMMUNITY_COUNT_CHUNK_PAGES, COMMUNITY_COUNT_PAGE_BUDGET - communityCountPagesRef.current),
|
|
11033
|
+
});
|
|
11034
|
+
if (cancelled) break; // 面板已切走 / 卸载:结果不要,进度也不回滚
|
|
11035
|
+
if (!res || res.ok !== true) break;
|
|
11036
|
+
const done = res.done === true;
|
|
11037
|
+
const next = {
|
|
11038
|
+
counted: record.counted + Math.max(0, Number(res.counted) || 0),
|
|
11039
|
+
// 数到底就不留游标,避免下次又误以为"还有活要干"。
|
|
11040
|
+
cursor: done ? '' : String(res.nextCursor || record.cursor || ''),
|
|
11041
|
+
done,
|
|
11042
|
+
at: Date.now(),
|
|
11043
|
+
};
|
|
11044
|
+
communityCountPagesRef.current += Math.max(1, Number(res.pages) || 1);
|
|
11045
|
+
communityCountRef.current = next; // 不等 re-render:下一块必须用最新游标
|
|
11046
|
+
setCommunityCount(next);
|
|
11047
|
+
writeCommunityCount(next);
|
|
11048
|
+
if (done) break;
|
|
11049
|
+
// 列表请求在飞就多让一拍;没在飞也歇一下,别把公共 API 当自家目录连着打。
|
|
11050
|
+
await new Promise((resolve) => {
|
|
11051
|
+
pauseTimer = setTimeout(resolve, communityLoadingRef.current ? 1500 : 400);
|
|
11052
|
+
});
|
|
11053
|
+
}
|
|
11054
|
+
} catch { /* 徽标那枚数字而已:任何异常都不该把社区列表带崩 */ }
|
|
11055
|
+
finally { communityCountBusyRef.current = false; }
|
|
11056
|
+
};
|
|
11057
|
+
const cancelIdle = scheduleIdle(runChunks, 3000);
|
|
11058
|
+
return () => { cancelled = true; if (pauseTimer) clearTimeout(pauseTimer); cancelIdle(); };
|
|
11059
|
+
}, [tab, connectorFeed, call]);
|
|
11060
|
+
|
|
11061
|
+
const communityVisibleRows = communityOnlyRunnable ? communityRows.filter((row) => row.runnable) : communityRows;
|
|
11062
|
+
const communityHiddenCount = communityRows.length - communityVisibleRows.length;
|
|
11063
|
+
// 上一页的游标来自游标栈(第 N-1 页的入口游标);第 1 页没有上一页。
|
|
11064
|
+
const communityPrevCursor = communityPage > 1 ? (communityCursorsRef.current[communityPage - 2] || '') : null;
|
|
11065
|
+
// 「已加入」只按 serverName 命中现有 MCP 列表:社区条目没有 WorkBuddy 代注入,
|
|
11066
|
+
// 不存在「已安装 · 需授权」这种工作流。
|
|
11067
|
+
// 「已加入」比的是**原始名**,不是带冲突后缀的预填名(baseServerName,见 lib/mcp-community.js)。
|
|
11068
|
+
// 用 serverName 比是死锁:装完 adeu 之后 taken 里就有 adeu,下一轮列表把这条预填成 adeu-2,
|
|
11069
|
+
// adeu-2 永远不等于任何已装名 → 徽标与按钮禁用一起失效,用户以为没装、再点一次装出第二条。
|
|
11070
|
+
const communityJoined = (row) => {
|
|
11071
|
+
const base = String(row?.baseServerName || row?.serverName || '');
|
|
11072
|
+
const prefill = String(row?.serverName || '');
|
|
11073
|
+
if (!base) return false;
|
|
11074
|
+
return mcpServerRows.some((server) => {
|
|
11075
|
+
const installed = String(server?.serverName || '');
|
|
11076
|
+
return installed === base || installed === prefill;
|
|
11077
|
+
});
|
|
11078
|
+
};
|
|
11079
|
+
|
|
11080
|
+
// 「加入 MCP」= 预填草稿,用户在弹层里确认后才由宿主写盘(与连接器一键安装不同语义)。
|
|
11081
|
+
const openCommunityDraft = (row) => {
|
|
11082
|
+
const env = {};
|
|
11083
|
+
for (const item of row.env || []) env[item.name] = item.hasDefault ? item.default : '';
|
|
11084
|
+
setCommunityDraft({
|
|
11085
|
+
serverName: row.serverName,
|
|
11086
|
+
transport: row.transport === 'http' ? 'http' : 'stdio',
|
|
11087
|
+
url: row.remoteUrl || '',
|
|
11088
|
+
command: row.command || '',
|
|
11089
|
+
args: Array.isArray(row.args) ? row.args : [],
|
|
11090
|
+
env,
|
|
11091
|
+
// 必填环境变量只用于弹层内的温和提示(允许提交:有些值允许运行时兜底)。
|
|
11092
|
+
requiredEnv: (row.env || []).filter((item) => item.required).map((item) => item.name),
|
|
11093
|
+
});
|
|
11094
|
+
};
|
|
11095
|
+
const submitCommunityDraft = async (spec, setFormError) => {
|
|
11096
|
+
setBusy(true);
|
|
11097
|
+
// 进度浮层:写盘 + 宿主自动连接这两段都在 RPC 之后才发生,弹层一关就没了现场。
|
|
11098
|
+
// key 带动作前缀,重连同一台服务器不会顶掉这条「加入」行(也不是插件名,不参与按钮禁用)。
|
|
11099
|
+
const taskKey = spec?.name ? 'mcp:add:' + String(spec.name) : '';
|
|
11100
|
+
progressToastStart(taskKey, spec?.name, PROGRESS_GROUP_MCP, MCP_ADD_TASK_LABELS);
|
|
11101
|
+
try {
|
|
11102
|
+
const res = await call('add', spec);
|
|
11103
|
+
if (res && res.ok) {
|
|
11104
|
+
setCommunityDraft(null);
|
|
11105
|
+
setNote(t('已加入 ') + spec.name);
|
|
11106
|
+
progressToastFinish(taskKey, 'ok');
|
|
11107
|
+
// 宿主写盘后列表刷新有一拍延迟:先立即刷一次,再补一次让「已加入」稳定亮起。
|
|
11108
|
+
loadMcpServerRows();
|
|
11109
|
+
communityDraftRefreshRef.current = setTimeout(() => { loadMcpServerRows(); }, 1200);
|
|
11110
|
+
} else {
|
|
11111
|
+
setFormError((res && res.error) || t('添加失败'));
|
|
11112
|
+
progressToastFinish(taskKey, 'fail');
|
|
11113
|
+
}
|
|
11114
|
+
} catch (e) {
|
|
11115
|
+
setFormError(String(e?.message || e));
|
|
11116
|
+
progressToastFinish(taskKey, 'fail');
|
|
11117
|
+
} finally {
|
|
11118
|
+
setBusy(false);
|
|
11119
|
+
}
|
|
11120
|
+
};
|
|
11121
|
+
|
|
11122
|
+
// P0:连接器卡的真实健康度。只认 list() 里的同名 server(serverNames 来自 mcp.json):
|
|
11123
|
+
// connected 至少一条已注册工具;auth 有 server 报 401/403 类失败;failed 其余失败。
|
|
11124
|
+
// 找不到同名行一律 unknown——连接器可能装在别的 profile / 别的会话里,不能拿「没搜到」冒充「没连上」。
|
|
11125
|
+
const mcpRowByName = useMemo(
|
|
11126
|
+
() => new Map(mcpServerRows.map((row) => [String(row?.serverName || ''), row])),
|
|
11127
|
+
[mcpServerRows],
|
|
11128
|
+
);
|
|
11129
|
+
const connectorServerRows = (item) => (Array.isArray(item?.serverNames) ? item.serverNames : [])
|
|
11130
|
+
.map((name) => mcpRowByName.get(String(name)))
|
|
11131
|
+
.filter(Boolean);
|
|
11132
|
+
const connectorHealth = (item) => {
|
|
11133
|
+
const rows = connectorServerRows(item);
|
|
11134
|
+
if (!rows.length) return 'unknown';
|
|
11135
|
+
if (rows.some((row) => row.status === 'connected')) return 'connected';
|
|
11136
|
+
if (rows.some((row) => row.authRequired)) return 'auth';
|
|
11137
|
+
return 'failed';
|
|
11138
|
+
};
|
|
11139
|
+
// 卡片右上角徽标:安装账本只说明「写进过配置」,健康度说明「现在到底能不能用」。
|
|
11140
|
+
const connectorBadge = (item) => {
|
|
11141
|
+
if (installedConnectorKeys.has(String(item.id || ''))) {
|
|
11142
|
+
const health = connectorHealth(item);
|
|
11143
|
+
if (health === 'connected') return h(Badge, { cls: 's-on', title: t('服务器已连接并注册了工具') }, t('已安装 · 已连接'));
|
|
11144
|
+
if (health === 'auth') return h(Badge, { cls: 's-warn', title: t('服务器返回 401/403 类鉴权失败,重连不会恢复,需要重新授权') }, t('已安装 · 需授权'));
|
|
11145
|
+
if (health === 'failed') return h(Badge, { cls: 's-err', title: t('配置已写入,但服务器当前没有连上(点「重连」再试)') }, t('已安装 · 未连接'));
|
|
11146
|
+
return h(Badge, { cls: 's-on', title: t('已写入 MCP 配置') }, t('已安装'));
|
|
11147
|
+
}
|
|
11148
|
+
return h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? t('可安装') : t('仅展示'));
|
|
11149
|
+
};
|
|
11150
|
+
|
|
11151
|
+
// P2-3:hover 预取。连接器详情真正要显示的是授权概览(openConnectorDetail 只调
|
|
11152
|
+
// wbOAuthOverview),未安装的连接器没有这项数据,预取只会白跑一次 IPC —— 直接跳过。
|
|
11153
|
+
const prefetchConnectorDetail = (item) => {
|
|
11154
|
+
const key = String(item?.id || '');
|
|
11155
|
+
if (!key || !installedConnectorKeys.has(key)) return;
|
|
11156
|
+
if (readDetailMemo(hoverMemo.connectorAuth, key)) return; // 已有可用的回包,不再发
|
|
11157
|
+
scheduleHoverPrefetch(() => {
|
|
11158
|
+
call('wbOAuthOverview', { connectorId: item.id })
|
|
11159
|
+
.then((res) => { if (res) writeDetailMemo(hoverMemo.connectorAuth, key, res); })
|
|
11160
|
+
.catch(() => { /* 预取失败留给点开时的正式请求报错 */ });
|
|
11161
|
+
});
|
|
11162
|
+
};
|
|
11163
|
+
|
|
10246
11164
|
const openConnectorDetail = async (item) => {
|
|
10247
11165
|
setConnectorDetail(item);
|
|
11166
|
+
connectorDetailIdRef.current = String(item?.id || '');
|
|
10248
11167
|
setNote('');
|
|
11168
|
+
setConnectorAuth(null);
|
|
11169
|
+
if (!installedConnectorKeys.has(String(item?.id || ''))) return;
|
|
11170
|
+
const memoKey = String(item.id);
|
|
11171
|
+
const precached = readDetailMemo(hoverMemo.connectorAuth, memoKey);
|
|
11172
|
+
if (precached) { setConnectorAuth({ loading: false, ...precached }); return; }
|
|
11173
|
+
setConnectorAuth({ loading: true });
|
|
11174
|
+
try {
|
|
11175
|
+
const res = await call('wbOAuthOverview', { connectorId: item.id });
|
|
11176
|
+
// 用户可能已经关了弹层 / 换了另一条:迟到的回包不写回界面。
|
|
11177
|
+
if (connectorDetailIdRef.current !== String(item.id)) return;
|
|
11178
|
+
if (res) writeDetailMemo(hoverMemo.connectorAuth, memoKey, res);
|
|
11179
|
+
setConnectorAuth(res ? { loading: false, ...res } : { loading: false });
|
|
11180
|
+
} catch {
|
|
11181
|
+
if (connectorDetailIdRef.current === String(item.id)) setConnectorAuth({ loading: false, error: t('读取授权状态失败') });
|
|
11182
|
+
}
|
|
11183
|
+
};
|
|
11184
|
+
// P0:已安装但连不上的入口。鉴权失败走「重新授权」,其余走「重连」——两条路都不该让用户
|
|
11185
|
+
// 滚回 MCP 面板自己找。
|
|
11186
|
+
const reconnectConnector = async (item) => {
|
|
11187
|
+
if (!item) return;
|
|
11188
|
+
const rows = connectorServerRows(item);
|
|
11189
|
+
const authRow = rows.find((row) => row.authRequired);
|
|
11190
|
+
if (authRow) { await beginConnectorAuth(item, authRow.serverName); return; }
|
|
11191
|
+
setBusy(true); setBusyLabel(t('正在重连…')); setError('');
|
|
11192
|
+
// 与「加入 MCP」同一枚进度浮层(顶部、跨面板卸载存活):重连是逐台串行的,
|
|
11193
|
+
// 一台一行,用户切走再回来还能看到到底哪台在跑、哪台没成。
|
|
11194
|
+
const serverNames = Array.isArray(item.serverNames) ? item.serverNames : [];
|
|
11195
|
+
const taskKeys = new Map();
|
|
11196
|
+
for (const name of serverNames) {
|
|
11197
|
+
const key = 'mcp:reconnect:' + String(name);
|
|
11198
|
+
taskKeys.set(String(name), key);
|
|
11199
|
+
progressToastStart(key, String(name), PROGRESS_GROUP_MCP, MCP_RECONNECT_TASK_LABELS);
|
|
11200
|
+
}
|
|
11201
|
+
try {
|
|
11202
|
+
let lastNote = '';
|
|
11203
|
+
const failed = [];
|
|
11204
|
+
for (const name of serverNames) {
|
|
11205
|
+
const res = await call('reconnect', String(name));
|
|
11206
|
+
// call() 把宿主失败收敛成 {ok:false, error},这里必须看 ok,否则一条都没连上也会
|
|
11207
|
+
// 显示「已请求重连」。
|
|
11208
|
+
if (res && res.ok === false) {
|
|
11209
|
+
failed.push(name + ': ' + (res.error || t('调用失败')));
|
|
11210
|
+
progressToastFinish(taskKeys.get(String(name)), 'fail');
|
|
11211
|
+
continue;
|
|
11212
|
+
}
|
|
11213
|
+
lastNote = res?.note || lastNote;
|
|
11214
|
+
progressToastFinish(taskKeys.get(String(name)), 'ok');
|
|
11215
|
+
}
|
|
11216
|
+
if (failed.length) setError(t('{0}:{1}', [item.name, failed.join('; ')]));
|
|
11217
|
+
else setNote(t('{0}:{1}', [item.name, lastNote || t('已请求重连')]));
|
|
11218
|
+
await loadMcpServerRows();
|
|
11219
|
+
} catch (e) {
|
|
11220
|
+
setError(String(e?.message || e));
|
|
11221
|
+
for (const key of taskKeys.values()) progressToastFinish(key, 'fail');
|
|
11222
|
+
} finally {
|
|
11223
|
+
setBusy(false); setBusyLabel('');
|
|
11224
|
+
}
|
|
10249
11225
|
};
|
|
10250
11226
|
// ── R8:连接器 OAuth 授权 ────────────────────────────────────────────────
|
|
10251
11227
|
// 宿主探测到 401 会回传 needsAuth,这里把它变成「打开浏览器 → 等回调 → 自动重装」。
|
|
@@ -10304,6 +11280,9 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10304
11280
|
// 授权页这条路走不通(服务器不做 OAuth 发现、动态注册被拒、要求客户端认证)时,
|
|
10305
11281
|
// 不能只甩一行红字就把用户关在门外:手填令牌那条兜底路还在,把弹层开出来摊给他。
|
|
10306
11282
|
if (res?.manualTokenOnly) {
|
|
11283
|
+
// 用户要的是「点安装就给我一个能填凭据的窗口」。无论有没有 ${...} 槽位,都在这里
|
|
11284
|
+
// 直接开手填令牌弹层——有令牌的人能立刻装,没有的人也能看清需要什么,不再把卡片
|
|
11285
|
+
// 降级成「需 WorkBuddy 账号授权」把入口收走。
|
|
10307
11286
|
setAuthDialog({
|
|
10308
11287
|
item,
|
|
10309
11288
|
serverName: res.serverName || serverName || item.name,
|
|
@@ -10391,6 +11370,48 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10391
11370
|
}
|
|
10392
11371
|
};
|
|
10393
11372
|
useEffect(() => () => { stopAuthPoll(); }, []);
|
|
11373
|
+
// 「我有令牌」入口:详情里给需要授权的连接器开一扇门,已持有服务方令牌的人
|
|
11374
|
+
// 不必等浏览器授权发现失败,直接粘贴即可走同一条落盘 + 安装链路。
|
|
11375
|
+
const openManualTokenDialog = (item) => {
|
|
11376
|
+
if (!item) return;
|
|
11377
|
+
stopAuthPoll();
|
|
11378
|
+
setError('');
|
|
11379
|
+
setAuthDialog({
|
|
11380
|
+
item,
|
|
11381
|
+
serverName: (Array.isArray(item.serverNames) ? item.serverNames[0] : '') || item.name,
|
|
11382
|
+
sessionId: '',
|
|
11383
|
+
authorizeUrl: '',
|
|
11384
|
+
mode: 'manual-token',
|
|
11385
|
+
redirectUri: '',
|
|
11386
|
+
hint: '',
|
|
11387
|
+
status: 'manual',
|
|
11388
|
+
error: '',
|
|
11389
|
+
callbackUrl: '',
|
|
11390
|
+
manualToken: '',
|
|
11391
|
+
});
|
|
11392
|
+
};
|
|
11393
|
+
// P0:强制刷新已存令牌 → 再装一次把新凭据写回 headers(mcp-client 只在建连时快照 headers)。
|
|
11394
|
+
// 刷不动就回到浏览器授权,不留「刷新失败」的死胡同。
|
|
11395
|
+
const refreshConnectorAuth = async (item) => {
|
|
11396
|
+
if (!item) return;
|
|
11397
|
+
setBusy(true); setBusyLabel(t('正在刷新授权…')); setError('');
|
|
11398
|
+
try {
|
|
11399
|
+
const res = await call('wbOAuthRefresh', { connectorId: item.id });
|
|
11400
|
+
if (!res?.ok) {
|
|
11401
|
+
if (res?.needsAuth) {
|
|
11402
|
+
await beginConnectorAuth(item);
|
|
11403
|
+
return;
|
|
11404
|
+
}
|
|
11405
|
+
throw new Error(res?.error || t('刷新授权失败'));
|
|
11406
|
+
}
|
|
11407
|
+
setNote(t('{0}:授权已刷新,正在把新凭据写回配置…', [item.name]));
|
|
11408
|
+
await installConnector(item);
|
|
11409
|
+
} catch (e) {
|
|
11410
|
+
setError(String(e?.message || e));
|
|
11411
|
+
} finally {
|
|
11412
|
+
setBusy(false); setBusyLabel('');
|
|
11413
|
+
}
|
|
11414
|
+
};
|
|
10394
11415
|
const installConnector = async (item, tokens = null) => {
|
|
10395
11416
|
setBusy(true); setBusyLabel(t('正在安装 {0}…', [item.name])); setError('');
|
|
10396
11417
|
try {
|
|
@@ -10409,10 +11430,24 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10409
11430
|
await beginConnectorAuth(item, needsAuth[0]?.serverName);
|
|
10410
11431
|
return;
|
|
10411
11432
|
}
|
|
11433
|
+
// issue #3:本地客户端型连接器(如 NVIDIA App)依赖固定路径的本机程序。缺程序时
|
|
11434
|
+
// 宿主连 10 次才放弃,用户只看得到「mcp 连接失败」;这里在安装前就把缺失路径说清楚。
|
|
11435
|
+
const localMissing = Array.isArray(res?.localMissing) ? res.localMissing : [];
|
|
11436
|
+
const localUnsupported = Array.isArray(res?.localUnsupported) ? res.localUnsupported : [];
|
|
11437
|
+
if (localMissing.length) {
|
|
11438
|
+
throw new Error(t('本机没有找到该连接器依赖的程序:{0}。请先安装对应客户端并开启它的 MCP / 集成访问后重试。', [localMissing.join(t('、'))]));
|
|
11439
|
+
}
|
|
11440
|
+
if (localUnsupported.length) {
|
|
11441
|
+
throw new Error(t('该连接器依赖本机程序,但当前系统不支持:{0}', [localUnsupported.join(t('、'))]));
|
|
11442
|
+
}
|
|
10412
11443
|
throw new Error(res?.error || t('安装失败'));
|
|
10413
11444
|
}
|
|
10414
11445
|
const missing = Array.isArray(res?.missingSlots) ? res.missingSlots : [];
|
|
10415
|
-
const
|
|
11446
|
+
const localDeps = Array.isArray(res?.localDeps) ? res.localDeps : [];
|
|
11447
|
+
const baseNote = t('{0}:{1}', [item.name, res.note || t('已写入 MCP 配置')])
|
|
11448
|
+
+ (localDeps.some((dep) => dep.status === 'found')
|
|
11449
|
+
? ' ' + t('本机依赖已找到;若连接失败,请确认对应客户端正在运行并已开启 MCP / 集成访问。')
|
|
11450
|
+
: '');
|
|
10416
11451
|
setNote(missing.length
|
|
10417
11452
|
? baseNote + ' ' + t('另有 {0} 个占位符未填写,配置里保留原样,可用前请补全。', [missing.length])
|
|
10418
11453
|
: baseNote);
|
|
@@ -10420,6 +11455,8 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10420
11455
|
setConnectorDetail(null);
|
|
10421
11456
|
await loadConnectors();
|
|
10422
11457
|
await loadInstallState();
|
|
11458
|
+
// 新写入的 server 要等宿主建连才有工具:这里刷新一次投影,卡片不会停在「未连接」。
|
|
11459
|
+
await loadMcpServerRows();
|
|
10423
11460
|
} catch (e) {
|
|
10424
11461
|
setError(String(e?.message || e));
|
|
10425
11462
|
} finally {
|
|
@@ -10453,7 +11490,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10453
11490
|
const res = await call('wbSkillInstall', { slug: skill.slug, namespace: skill.namespace, name: skill.name });
|
|
10454
11491
|
if (!res?.ok) throw new Error(res?.error || t('安装失败'));
|
|
10455
11492
|
setNote(t('{0}:{1}', [skill.name, res.note || t('已安装')]));
|
|
10456
|
-
|
|
11493
|
+
closeSkillDetail();
|
|
10457
11494
|
// 磁盘上的 skill 集合已经变了:广播出去,顶部分类计数与(切回时的)Skill 看板
|
|
10458
11495
|
// 立即按新集合重算,不再等用户离开看板/重挂载。市场自己的列表照旧局部刷新。
|
|
10459
11496
|
emitSkillChange('market-install');
|
|
@@ -10472,7 +11509,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10472
11509
|
const res = await call('wbSkillUninstall', { slug: skill.slug, namespace: skill.namespace });
|
|
10473
11510
|
if (!res?.ok) throw new Error(res?.error || t('卸载失败'));
|
|
10474
11511
|
setNote(t('{0}:{1}', [skill.name, res.note || t('已卸载')]));
|
|
10475
|
-
|
|
11512
|
+
closeSkillDetail();
|
|
10476
11513
|
emitSkillChange('market-uninstall');
|
|
10477
11514
|
await loadInstalled();
|
|
10478
11515
|
await loadInstallState();
|
|
@@ -10482,15 +11519,37 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10482
11519
|
setBusy(false); setBusyLabel('');
|
|
10483
11520
|
}
|
|
10484
11521
|
};
|
|
11522
|
+
// P2-3:hover 预取技能详情。命中缓存就什么都不发;同一条结果点开时直接复用。
|
|
11523
|
+
const prefetchSkillDetail = (skill) => {
|
|
11524
|
+
const key = skillDetailMemoKey(skill);
|
|
11525
|
+
if (!skill?.slug || !skill?.namespace) return;
|
|
11526
|
+
if (readDetailMemo(hoverMemo.skillDetail, key)) return;
|
|
11527
|
+
scheduleHoverPrefetch(() => {
|
|
11528
|
+
call('wbSkillDetail', { slug: skill.slug, namespace: skill.namespace })
|
|
11529
|
+
.then((res) => { if (res?.ok) writeDetailMemo(hoverMemo.skillDetail, key, res); })
|
|
11530
|
+
.catch(() => { /* 预取失败留给点开时的正式请求报错 */ });
|
|
11531
|
+
});
|
|
11532
|
+
};
|
|
10485
11533
|
const openSkillDetail = async (skill) => {
|
|
10486
|
-
|
|
11534
|
+
const seq = ++skillDetailSeq.current;
|
|
11535
|
+
const memoKey = skillDetailMemoKey(skill);
|
|
11536
|
+
const precached = readDetailMemo(hoverMemo.skillDetail, memoKey);
|
|
10487
11537
|
setNote('');
|
|
11538
|
+
if (precached) {
|
|
11539
|
+
// 预取的就是这一条:省掉 loading 闪烁与一次往返。
|
|
11540
|
+
setSkillDetail({ ...mergeSkillDetail(skill, precached), loading: false });
|
|
11541
|
+
return;
|
|
11542
|
+
}
|
|
11543
|
+
setSkillDetail({ ...skill, loading: true });
|
|
10488
11544
|
try {
|
|
10489
11545
|
const res = await call('wbSkillDetail', { slug: skill.slug, namespace: skill.namespace });
|
|
11546
|
+
if (seq !== skillDetailSeq.current) return; // 已关闭 / 已改点另一条
|
|
10490
11547
|
if (!res?.ok) throw new Error(res?.error || t('读取详情失败'));
|
|
11548
|
+
writeDetailMemo(hoverMemo.skillDetail, memoKey, res);
|
|
10491
11549
|
// 回包里的空串不能覆盖列表项已有的中文名 / 介绍(issue #4)。
|
|
10492
11550
|
setSkillDetail({ ...mergeSkillDetail(skill, res), loading: false });
|
|
10493
11551
|
} catch (e) {
|
|
11552
|
+
if (seq !== skillDetailSeq.current) return;
|
|
10494
11553
|
setSkillDetail({ ...skill, loading: false, detailError: String(e?.message || e) });
|
|
10495
11554
|
}
|
|
10496
11555
|
};
|
|
@@ -10501,10 +11560,14 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10501
11560
|
const [restartNotice, setRestartNotice] = useState('');
|
|
10502
11561
|
// T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)状态机与数据接入。
|
|
10503
11562
|
// 插件页只有这一个来源;首屏快照见下方 awesomeSnapshotRef。
|
|
10504
|
-
//
|
|
10505
|
-
//
|
|
11563
|
+
// P1-3:快照懒读。旧实现在市场每次挂载时就把上次的快照 JSON.parse 一遍(2.98MB 目录
|
|
11564
|
+
// 约 100ms 主线程),而默认落地页是「连接器」——绝大多数会话根本不会看插件页。
|
|
11565
|
+
// 现在首帧给空数组,用户真切到插件 tab 时才 hydrate(见 switchTab / hydrateAwesomeFromSnapshot)。
|
|
11566
|
+
// 写侧也改成「签名变了才写 + idle 写」:目录没变就不必再序列化 3MB。
|
|
10506
11567
|
const awesomeSnapshotRef = useRef(undefined);
|
|
10507
|
-
if (awesomeSnapshotRef.current === undefined
|
|
11568
|
+
if (awesomeSnapshotRef.current === undefined && !MARKET_PERF.awesomeSnapshotLazy) {
|
|
11569
|
+
awesomeSnapshotRef.current = readAwesomeSnapshot();
|
|
11570
|
+
}
|
|
10508
11571
|
const [awesomePlugins, setAwesomePlugins] = useState(() => {
|
|
10509
11572
|
const snapshot = awesomeSnapshotRef.current;
|
|
10510
11573
|
return snapshot ? snapshot.plugins.map((item) => ({ ...item, marketplace: snapshot.marketplace || 'awesome-dsh-plugins' })) : [];
|
|
@@ -10522,6 +11585,60 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10522
11585
|
// 静默预取失败时用来判断「有没有快照可留」:有快照就不弹错误条。
|
|
10523
11586
|
const awesomeLenRef = useRef(awesomePlugins.length);
|
|
10524
11587
|
useEffect(() => { awesomeLenRef.current = awesomePlugins.length; }, [awesomePlugins.length]);
|
|
11588
|
+
// P1-3:切到插件页时才读快照(0 网络、0 IPC,只是把 JSON.parse 推到真正需要它的时刻)。
|
|
11589
|
+
// 只 hydrate 一次:真实目录回来后再切回来不该被旧快照盖掉(setAwesomePlugins 前判空)。
|
|
11590
|
+
const hydrateAwesomeFromSnapshot = useCallback(() => {
|
|
11591
|
+
if (!MARKET_PERF.awesomeSnapshotLazy) return;
|
|
11592
|
+
if (awesomeSnapshotRef.current !== undefined) return;
|
|
11593
|
+
const snapshot = readAwesomeSnapshot();
|
|
11594
|
+
awesomeSnapshotRef.current = snapshot || null;
|
|
11595
|
+
if (!snapshot) return;
|
|
11596
|
+
setAwesomePlugins((prev) => (prev.length ? prev : snapshot.plugins.map((item) => ({
|
|
11597
|
+
...item,
|
|
11598
|
+
marketplace: snapshot.marketplace || 'awesome-dsh-plugins',
|
|
11599
|
+
}))));
|
|
11600
|
+
}, []);
|
|
11601
|
+
// P1-3:上次写进 localStorage 的目录签名。签名没变就不重写(3MB 序列化 + 落盘)。
|
|
11602
|
+
const awesomeSnapshotSigRef = useRef('');
|
|
11603
|
+
// 所有「切到插件 tab」的入口都走这里:先补快照,再切页(React 同一批更新里渲染)。
|
|
11604
|
+
const switchTab = useCallback((next) => {
|
|
11605
|
+
if (next === 'plugin') hydrateAwesomeFromSnapshot();
|
|
11606
|
+
setTab(next);
|
|
11607
|
+
}, [hydrateAwesomeFromSnapshot]);
|
|
11608
|
+
// 「最近动静」的跳转动作:先按 marketFullScope 的口径把目标页签重置回全量,再切页签。
|
|
11609
|
+
// 只在用户点条目时执行,不影响正常的页签切换(后者保留用户的搜索 / 筛选 / 页码)。
|
|
11610
|
+
const applyFullMarket = (next) => {
|
|
11611
|
+
const patch = marketFullScope(next);
|
|
11612
|
+
if (!patch) return;
|
|
11613
|
+
if (patch.expertRemount) {
|
|
11614
|
+
// 专家页内部状态(搜索 / 分类 / 页码)只能靠重挂回到默认;不动其它三页签。
|
|
11615
|
+
setExpertEpoch((value) => value + 1);
|
|
11616
|
+
return;
|
|
11617
|
+
}
|
|
11618
|
+
if ('connectorFeed' in patch) {
|
|
11619
|
+
setConnectorFeed(patch.connectorFeed);
|
|
11620
|
+
setConnectorQuery(patch.connectorQuery);
|
|
11621
|
+
setConnectorOnlyReady(patch.connectorOnlyReady);
|
|
11622
|
+
setConnectorOnlyInstalled(patch.connectorOnlyInstalled);
|
|
11623
|
+
setConnectorSort(patch.connectorSort);
|
|
11624
|
+
setConnectorPage(patch.connectorPage);
|
|
11625
|
+
}
|
|
11626
|
+
if ('skillKeyword' in patch) {
|
|
11627
|
+
setSkillKeyword(patch.skillKeyword);
|
|
11628
|
+
setSkillKeywordInput(patch.skillKeywordInput);
|
|
11629
|
+
setSkillSort(patch.skillSort);
|
|
11630
|
+
setSkillOnlyInstalled(patch.skillOnlyInstalled);
|
|
11631
|
+
setSkillPage(patch.skillPage);
|
|
11632
|
+
}
|
|
11633
|
+
if ('awesomeQuery' in patch) {
|
|
11634
|
+
setAwesomeQuery(patch.awesomeQuery);
|
|
11635
|
+
setAwesomeCategory(patch.awesomeCategory);
|
|
11636
|
+
setAwesomeTags(patch.awesomeTags);
|
|
11637
|
+
setAwesomeSort(patch.awesomeSort);
|
|
11638
|
+
setAwesomeOnlyInstalled(patch.awesomeOnlyInstalled);
|
|
11639
|
+
setAwesomePage(patch.awesomePage);
|
|
11640
|
+
}
|
|
11641
|
+
};
|
|
10525
11642
|
const loadAwesomeCatalog = useCallback(async (refresh = false, silent = false) => {
|
|
10526
11643
|
setAwesomeLoading(true); setAwesomeError('');
|
|
10527
11644
|
try {
|
|
@@ -10538,8 +11655,13 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10538
11655
|
name: item.name,
|
|
10539
11656
|
sig: String(item.version || '') + '|' + String(item.verifiedAgainst || '') + '|' + String(item.status || ''),
|
|
10540
11657
|
})))) refreshPulse();
|
|
10541
|
-
//
|
|
10542
|
-
|
|
11658
|
+
// 顺手把这次的目录留成下次的首屏快照:签名没变就不重写(省一次 3MB 序列化 + 落盘),
|
|
11659
|
+
// 写盘也推到空闲帧,不与这次渲染抢主线程(写失败静默,不影响本次渲染)。
|
|
11660
|
+
const snapshotSig = catalog.length + '|' + (res.fetchedAt || '') + '|' + marketplace;
|
|
11661
|
+
if (snapshotSig !== awesomeSnapshotSigRef.current) {
|
|
11662
|
+
awesomeSnapshotSigRef.current = snapshotSig;
|
|
11663
|
+
scheduleIdle(() => writeAwesomeSnapshot({ plugins: catalog, marketplace, fetchedAt: res.fetchedAt }), 5000);
|
|
11664
|
+
}
|
|
10543
11665
|
} catch (e) {
|
|
10544
11666
|
// 静默预取失败且已有快照 → 保留旧目录;没有快照才让错误条接管。
|
|
10545
11667
|
if (!silent || awesomeLenRef.current === 0) setAwesomeError(String(e?.message || e));
|
|
@@ -10573,12 +11695,35 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10573
11695
|
readmeSeq.current += 1; // 作废在途请求,避免关掉之后又被写回来
|
|
10574
11696
|
setReadmeView(null);
|
|
10575
11697
|
};
|
|
11698
|
+
// P2-3:hover 预取 README。宿主侧本来就有 7 天落盘缓存,这里只负责把「点开那一拍」
|
|
11699
|
+
// 的往返提前到鼠标停留的 200ms 里;重读(refresh)永远走真实请求。
|
|
11700
|
+
const prefetchReadme = (item) => {
|
|
11701
|
+
const key = readmeMemoKey(item);
|
|
11702
|
+
if (!item?.name) return;
|
|
11703
|
+
if (readDetailMemo(hoverMemo.readme, key)) return;
|
|
11704
|
+
scheduleHoverPrefetch(() => {
|
|
11705
|
+
const npm = typeof item.npm === 'string' ? item.npm : (item.npm && item.npm.name) || '';
|
|
11706
|
+
call('wbAwesomeReadme', { name: item.name, npm, repo: item.repo || '' })
|
|
11707
|
+
.then((res) => { if (res?.ok) writeDetailMemo(hoverMemo.readme, key, res); })
|
|
11708
|
+
.catch(() => { /* 预取失败留给点开时的正式请求报错 */ });
|
|
11709
|
+
});
|
|
11710
|
+
};
|
|
10576
11711
|
const loadAwesomeReadme = useCallback(async (item, refresh = false) => {
|
|
10577
11712
|
if (!item) return;
|
|
10578
11713
|
// 弹层的排版样式随首次打开注入。放在事件回调里而不是组件顶层,是为了不引入
|
|
10579
11714
|
// 任何新的 Hook —— 这也正是 no-conditional-hooks 测试要防的那类崩溃。
|
|
10580
11715
|
ensureReadmeStyle();
|
|
10581
11716
|
const seq = ++readmeSeq.current;
|
|
11717
|
+
const memoKey = readmeMemoKey(item);
|
|
11718
|
+
const precached = refresh === true ? null : readDetailMemo(hoverMemo.readme, memoKey);
|
|
11719
|
+
if (precached) {
|
|
11720
|
+
setReadmeView({
|
|
11721
|
+
item, loading: false, ok: true,
|
|
11722
|
+
markdown: precached.markdown || '', base: precached.base || '', error: '',
|
|
11723
|
+
source: precached.source || '', reason: precached.reason || '', truncated: precached.truncated === true,
|
|
11724
|
+
});
|
|
11725
|
+
return;
|
|
11726
|
+
}
|
|
10582
11727
|
const npm = typeof item.npm === 'string' ? item.npm : (item.npm && item.npm.name) || '';
|
|
10583
11728
|
setReadmeView({ item, loading: true, ok: false, markdown: '', base: '', error: '', source: '', reason: '', truncated: false });
|
|
10584
11729
|
let res = null;
|
|
@@ -10598,40 +11743,52 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10598
11743
|
setReadmeView({ item, loading: false, ok: false, markdown: '', base: '', error: message, source: '', reason: (res && res.reason) || '', truncated: false });
|
|
10599
11744
|
return;
|
|
10600
11745
|
}
|
|
11746
|
+
writeDetailMemo(hoverMemo.readme, memoKey, res);
|
|
10601
11747
|
setReadmeView({ item, loading: false, ok: true, markdown: res.markdown || '', base: res.base || '', error: '', source: res.source || '', reason: res.reason || '', truncated: res.truncated === true });
|
|
10602
11748
|
}, [call]);
|
|
10603
|
-
//
|
|
10604
|
-
//
|
|
10605
|
-
//
|
|
10606
|
-
//
|
|
11749
|
+
// 目录预取:以前市场一挂载就在 400ms 后把 2.98MB 的 DSH 社区目录拉下来,与连接器
|
|
11750
|
+
// 首屏抢 IPC 与主线程。P1-1:等连接器首屏落地(connectorSettled),再空等 1200ms,
|
|
11751
|
+
// 然后在空闲帧里才发;「已切到插件页」的路径仍是立即发(用户要看,就不该等)。
|
|
11752
|
+
// 只跑一次,失败由列表里的错误条 / 空态提供手动重试。
|
|
10607
11753
|
const awesomeAutoLoaded = useRef(false);
|
|
10608
11754
|
useEffect(() => {
|
|
10609
|
-
if (awesomeAutoLoaded.current) return;
|
|
10610
|
-
const
|
|
11755
|
+
if (awesomeAutoLoaded.current || !connectorSettled) return undefined;
|
|
11756
|
+
const fire = () => {
|
|
10611
11757
|
if (awesomeAutoLoaded.current) return;
|
|
10612
11758
|
awesomeAutoLoaded.current = true;
|
|
10613
11759
|
loadAwesomeCatalog(false, true);
|
|
10614
|
-
}
|
|
10615
|
-
|
|
10616
|
-
|
|
11760
|
+
};
|
|
11761
|
+
if (MARKET_PERF.prefetchMode === 'legacy') {
|
|
11762
|
+
const timer = setTimeout(fire, 400);
|
|
11763
|
+
return () => clearTimeout(timer);
|
|
11764
|
+
}
|
|
11765
|
+
let cancelIdle = null;
|
|
11766
|
+
const timer = setTimeout(() => { cancelIdle = scheduleIdle(fire, 3000); }, 1200);
|
|
11767
|
+
return () => { clearTimeout(timer); cancelIdle?.(); };
|
|
11768
|
+
}, [connectorSettled, loadAwesomeCatalog]);
|
|
10617
11769
|
// 用户已经在插件页时不等预取延迟:立即发(旧行为保留,预取只负责「还没切过来」)。
|
|
10618
11770
|
useEffect(() => {
|
|
10619
11771
|
if (tab !== 'plugin' || awesomeAutoLoaded.current) return;
|
|
10620
11772
|
awesomeAutoLoaded.current = true;
|
|
10621
11773
|
loadAwesomeCatalog(false, true);
|
|
10622
11774
|
}, [tab, loadAwesomeCatalog]);
|
|
10623
|
-
//
|
|
10624
|
-
//
|
|
10625
|
-
//
|
|
11775
|
+
// 专家目录预取(只为子页签徽标取条数):P1-2 同款节奏——连接器首屏后 idle 起算
|
|
11776
|
+
// 2500ms 才拉;用户切到专家页时 ExpertMarket 自己会立即拉,不等这条预取。
|
|
11777
|
+
// loadExpertCatalogShared 带模块级 TTL 缓存,专家页挂载时复用同一份,不会二连网络;
|
|
11778
|
+
// 组件卸载后回包只作废,不写状态。
|
|
10626
11779
|
useEffect(() => {
|
|
11780
|
+
if (!connectorSettled || tab === 'expert') return undefined;
|
|
10627
11781
|
let alive = true;
|
|
11782
|
+
let cancelIdle = null;
|
|
10628
11783
|
const timer = setTimeout(() => {
|
|
10629
|
-
|
|
10630
|
-
|
|
10631
|
-
|
|
10632
|
-
|
|
10633
|
-
|
|
10634
|
-
|
|
11784
|
+
cancelIdle = scheduleIdle(() => {
|
|
11785
|
+
loadExpertCatalogShared(call).then((data) => {
|
|
11786
|
+
if (alive) setExpertTotal((data.experts || []).length || null);
|
|
11787
|
+
}).catch(() => { /* 徽标读不到就先不显示 */ });
|
|
11788
|
+
}, 3000);
|
|
11789
|
+
}, MARKET_PERF.prefetchMode === 'legacy' ? 400 : 2500);
|
|
11790
|
+
return () => { alive = false; clearTimeout(timer); cancelIdle?.(); };
|
|
11791
|
+
}, [call, connectorSettled, tab]);
|
|
10635
11792
|
useEffect(() => {
|
|
10636
11793
|
// 目录加载完成后探一次;awesomeUpdateChecked 保证组件存续期内不重复;已有提示不再探。
|
|
10637
11794
|
if (tab !== 'plugin' || awesomeLoading || !awesomePlugins.length || awesomeError || awesomeUpdateHint) return;
|
|
@@ -10720,7 +11877,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10720
11877
|
setLoadoutOpen(false);
|
|
10721
11878
|
if (row?.kind === 'mcp') {
|
|
10722
11879
|
if (source.startsWith('connector:')) {
|
|
10723
|
-
|
|
11880
|
+
switchTab('connector');
|
|
10724
11881
|
setConnectorQuery(name);
|
|
10725
11882
|
setConnectorOnlyReady(false);
|
|
10726
11883
|
setConnectorPage(1);
|
|
@@ -10734,7 +11891,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10734
11891
|
return;
|
|
10735
11892
|
}
|
|
10736
11893
|
if (row?.kind === 'skill') {
|
|
10737
|
-
|
|
11894
|
+
switchTab('skill');
|
|
10738
11895
|
setSkillPage(1);
|
|
10739
11896
|
setSkillKeywordInput(name);
|
|
10740
11897
|
setSkillKeyword(name);
|
|
@@ -10744,12 +11901,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10744
11901
|
if (row?.kind === 'expert') {
|
|
10745
11902
|
seedExpertMarketQuery(name);
|
|
10746
11903
|
setExpertEpoch((value) => value + 1);
|
|
10747
|
-
|
|
11904
|
+
switchTab('expert');
|
|
10748
11905
|
setNote(t('已定位到专家市场:{0}', [name]));
|
|
10749
11906
|
return;
|
|
10750
11907
|
}
|
|
10751
11908
|
setAwesomeQuery(name);
|
|
10752
|
-
|
|
11909
|
+
switchTab('plugin');
|
|
10753
11910
|
setNote(t('已定位到插件市场:{0}', [name]));
|
|
10754
11911
|
};
|
|
10755
11912
|
|
|
@@ -10794,10 +11951,17 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10794
11951
|
h('div', { className: 'dsh-mcp-mk-subtabs', role: 'tablist', 'aria-label': t('WorkBuddy 市场') },
|
|
10795
11952
|
MARKET_TABS.map((row) => h('button', {
|
|
10796
11953
|
key: row.key, type: 'button', role: 'tab', 'aria-selected': tab === row.key,
|
|
10797
|
-
className: 'dsh-mcp-mk-subtab', onClick: () =>
|
|
11954
|
+
className: 'dsh-mcp-mk-subtab', onClick: () => switchTab(row.key),
|
|
10798
11955
|
}, row.label, row.count ? h('i', null, row.count) : null)),
|
|
10799
|
-
//
|
|
11956
|
+
// 右侧:免责声明 + 装备清单入口 + 面包屑(面包屑 aria-hidden:当前位置已由选中页签表达)。
|
|
11957
|
+
// 声明从页签下方的一行挪到这里:不占纵向高度,窄屏自动省略(完整口径在 title 里)。
|
|
10800
11958
|
h('div', { className: 'dsh-mcp-mk-topright' },
|
|
11959
|
+
h('span', {
|
|
11960
|
+
className: 'dsh-mcp-mk-note',
|
|
11961
|
+
role: 'note',
|
|
11962
|
+
title: t('连接器来自 WorkBuddy 官方分发(static.workbuddy.cn);技能来自 SkillHub 公开 API;专家来自 WorkBuddy 专家中心分发。')
|
|
11963
|
+
+ t('第三方接口的字段可能随上游改版变化,安装前请在详情里核对传输类型与凭据要求。'),
|
|
11964
|
+
}, t('连接器 / 技能 / 专家均为第三方分发,本面板只做浏览与安装入口;内容版权归原作者所有。')),
|
|
10801
11965
|
h('button', {
|
|
10802
11966
|
type: 'button', className: 'dsh-mcp-loadout-open',
|
|
10803
11967
|
title: t('导出本机配置,或导入朋友的清单做差异对比'),
|
|
@@ -10810,23 +11974,79 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10810
11974
|
),
|
|
10811
11975
|
),
|
|
10812
11976
|
),
|
|
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
|
-
),
|
|
11977
|
+
// §6.5 免责声明已挪到子页签行右侧(.dsh-mcp-mk-note),这里不再占一行。
|
|
10821
11978
|
// 「最近动静」:本机快照对比,只报真实的目录变化;没有变化就说没有。
|
|
10822
11979
|
h(MarketPulse, {
|
|
10823
11980
|
state: pulse,
|
|
10824
11981
|
onClear: () => { clearMarketPulse(); setPulse(emptyMarketPulse()); },
|
|
10825
11982
|
onJump: (meta) => {
|
|
10826
|
-
if (meta
|
|
11983
|
+
if (!meta?.tab) return;
|
|
11984
|
+
// 直达全量市场:清掉该页签残留的搜索 / 筛选 / 排序 / 页码,再切过去。
|
|
11985
|
+
applyFullMarket(meta.tab);
|
|
11986
|
+
switchTab(meta.tab);
|
|
10827
11987
|
},
|
|
10828
11988
|
}),
|
|
11989
|
+
// 连接器来源切换:同一个列表位置两个来源,默认仍停在 WorkBuddy(行为与改造前一致)。
|
|
11990
|
+
// 用 .dsh-mcp-seg 与作用域切换同一观感;切到社区不卸载 WB 侧的搜索/筛选/页码。
|
|
10829
11991
|
tab === 'connector'
|
|
11992
|
+
? h('div', { className: 'dsh-mcp-wb-toolbar dsh-mcp-wb-feedbar' },
|
|
11993
|
+
h('div', { className: 'dsh-mcp-seg', role: 'tablist', 'aria-label': t('连接器来源') },
|
|
11994
|
+
h('button', {
|
|
11995
|
+
type: 'button', role: 'tab', 'aria-selected': connectorFeed === 'wb', className: 'dsh-mcp-seg-item',
|
|
11996
|
+
title: t('WorkBuddy 官方分发的连接器;多数需要 WorkBuddy 账号授权'),
|
|
11997
|
+
onClick: () => setConnectorFeed('wb'),
|
|
11998
|
+
},
|
|
11999
|
+
t('WorkBuddy 连接器'),
|
|
12000
|
+
// 徽标常驻:数字一隐藏就会被读成"这一页就是全部"。连接器是一次返回全量,
|
|
12001
|
+
// 所以这个数就是总数;没取到之前显示「…」而不是空着。
|
|
12002
|
+
h('span', {
|
|
12003
|
+
className: 'dsh-mcp-seg-count',
|
|
12004
|
+
title: connectors.length ? t('WorkBuddy 官方目录里的连接器总数') : t('正在读取连接器列表…'),
|
|
12005
|
+
}, connectors.length || '…'),
|
|
12006
|
+
),
|
|
12007
|
+
h('button', {
|
|
12008
|
+
type: 'button', role: 'tab', 'aria-selected': connectorFeed === 'community', className: 'dsh-mcp-seg-item',
|
|
12009
|
+
title: t('官方 MCP Registry 的社区条目,不依赖 WorkBuddy 账号'),
|
|
12010
|
+
onClick: () => setConnectorFeed('community'),
|
|
12011
|
+
},
|
|
12012
|
+
t('社区 MCP'),
|
|
12013
|
+
// 这里显示的是**全库总数**(数到底)或**下界**(还在续爬,形如 1200+),
|
|
12014
|
+
// 不再是「已读取 98 条」——那个数会被读成"社区一共只有 98 个"(2026-10-30 用户
|
|
12015
|
+
// 反馈)。查询内的已读累计仍在下面那行工具条里,两个数各有各的口径。
|
|
12016
|
+
h('span', { className: 'dsh-mcp-seg-count', title: communityCountBadgeTitle(communityCount) }, communityCountBadgeText(communityCount)),
|
|
12017
|
+
),
|
|
12018
|
+
),
|
|
12019
|
+
)
|
|
12020
|
+
: null,
|
|
12021
|
+
// 未审核声明常驻(可折叠就等于没有):社区条目由第三方提交,命令与环境变量自行确认。
|
|
12022
|
+
tab === 'connector' && connectorFeed === 'community'
|
|
12023
|
+
? h('div', { className: 'dsh-mcp-wb-note warn', role: 'note' },
|
|
12024
|
+
t('社区 MCP 来自官方 MCP Registry,由社区提交;军械库未审核其代码,加入前请自行确认命令与环境变量。'))
|
|
12025
|
+
: null,
|
|
12026
|
+
tab === 'connector' && connectorFeed === 'community'
|
|
12027
|
+
? h('div', { className: 'dsh-mcp-wb-toolbar' },
|
|
12028
|
+
h(WbSearchBox, {
|
|
12029
|
+
value: communityQueryInput, placeholder: t('搜索社区 MCP(名称 / 说明)'), busy: communityLoading,
|
|
12030
|
+
onChange: (value) => setCommunityQueryInput(value),
|
|
12031
|
+
// 回车跳过防抖立即生效(防抖本身也会触发,幂等)。
|
|
12032
|
+
onCommit: () => setCommunityQuery(communityQueryInput.trim()),
|
|
12033
|
+
}),
|
|
12034
|
+
h('label', {
|
|
12035
|
+
className: 'dsh-mcp-check',
|
|
12036
|
+
title: t('只显示带 npx / uvx 启动包的条目;远程 HTTP 条目需要自备令牌'),
|
|
12037
|
+
},
|
|
12038
|
+
h('input', { type: 'checkbox', checked: communityOnlyRunnable, onChange: (e) => setCommunityOnlyRunnable(e.target.checked) }),
|
|
12039
|
+
t('只看可直接运行(npx / uvx)'),
|
|
12040
|
+
),
|
|
12041
|
+
h('span', { className: 'dsh-mcp-builtin-count', title: t('本次查询已逐页读取的条数;上方徽标显示的是社区目录全库总数') },
|
|
12042
|
+
t('已读取 {0} 条', [communityReadTotal]) + (communityStale ? t(' · 缓存结果') : '')),
|
|
12043
|
+
communityOnlyRunnable && communityHiddenCount > 0
|
|
12044
|
+
? h('span', { className: 'dsh-mcp-builtin-count', title: t('这些条目只有远程地址,需要自备令牌') },
|
|
12045
|
+
t('已隐藏 {0} 个远程条目', [communityHiddenCount]))
|
|
12046
|
+
: null,
|
|
12047
|
+
)
|
|
12048
|
+
: null,
|
|
12049
|
+
tab === 'connector' && connectorFeed === 'wb'
|
|
10830
12050
|
? h('div', { className: 'dsh-mcp-wb-toolbar' },
|
|
10831
12051
|
connectorSearch,
|
|
10832
12052
|
h('label', { className: 'dsh-mcp-check', title: t('默认只显示可直接安装的条目;取消勾选可看到「仅展示」的不兼容条目') },
|
|
@@ -10858,7 +12078,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10858
12078
|
: null,
|
|
10859
12079
|
// T02/R2:visible_in 标签筛选 chips(只在连接器 tab 且目录里确实出现标签时显示)。
|
|
10860
12080
|
// 语义:未选任何标签 = 不过滤;选中后任一命中即保留(OR);没标 visible_in 的行不受影响。
|
|
10861
|
-
tab === 'connector' && connectorVisibleTagUniverse.length
|
|
12081
|
+
tab === 'connector' && connectorFeed === 'wb' && connectorVisibleTagUniverse.length
|
|
10862
12082
|
? h('div', { className: 'dsh-mcp-wb-toolbar dsh-mcp-wb-tagbar' },
|
|
10863
12083
|
h('span', { className: 'dsh-mcp-wb-tagbar-label' }, t('适用环境:')),
|
|
10864
12084
|
connectorVisibleTagUniverse.map(({ tag, count }) => h('button', {
|
|
@@ -10917,11 +12137,16 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10917
12137
|
onClick: () => loadSkills(skillPage, skillSort, skillKeyword),
|
|
10918
12138
|
}, t('重试')))
|
|
10919
12139
|
: null,
|
|
10920
|
-
loading && !connectors.length && tab === 'connector' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取连接器目录…')) : null,
|
|
12140
|
+
loading && !connectors.length && tab === 'connector' && connectorFeed === 'wb' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取连接器目录…')) : null,
|
|
12141
|
+
// 首次加载 / 换搜索词后的第一批:还没有这一批的结果时显示读取中(旧结果不冒充新搜索)。
|
|
12142
|
+
communityLoading && tab === 'connector' && connectorFeed === 'community'
|
|
12143
|
+
&& (!communityRows.length || communityLoadedQueryRef.current !== communityQuery)
|
|
12144
|
+
? h('div', { className: 'dsh-mcp-wb-empty', role: 'status' }, t('正在读取社区 MCP…'))
|
|
12145
|
+
: null,
|
|
10921
12146
|
skillLoading && !skills.length && tab === 'skill' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取技能目录…')) : null,
|
|
10922
12147
|
// §1.4 「只看可直接安装」把本页筛空了:这是本页最容易踩的坑(默认勾选),
|
|
10923
12148
|
// 主操作就是取消它——一句话说清「这里本来有条目,是筛选挡掉了」。
|
|
10924
|
-
!loading && tab === 'connector' && !visibleConnectors.length
|
|
12149
|
+
!loading && tab === 'connector' && connectorFeed === 'wb' && !visibleConnectors.length
|
|
10925
12150
|
? (connectors.length
|
|
10926
12151
|
? h(EmptyState, {
|
|
10927
12152
|
icon: 'store',
|
|
@@ -10943,6 +12168,30 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10943
12168
|
actions: [{ label: t('重新读取目录'), icon: 'refresh', onClick: () => { loadConnectors(); } }],
|
|
10944
12169
|
}))
|
|
10945
12170
|
: null,
|
|
12171
|
+
// 社区加载失败:错误只落在列表内(带重试),不影响 WB 连接器;上次结果还在时
|
|
12172
|
+
// 由工具栏的「缓存结果」标注说明,不在这里重复一遍。
|
|
12173
|
+
tab === 'connector' && connectorFeed === 'community' && communityError
|
|
12174
|
+
? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' },
|
|
12175
|
+
t('社区列表加载失败:{0}', [communityError]), ' ',
|
|
12176
|
+
h('button', {
|
|
12177
|
+
type: 'button', className: 'dsh-mcp-btn', disabled: communityLoading,
|
|
12178
|
+
onClick: () => loadCommunity({ cursor: '' }),
|
|
12179
|
+
}, t('重试')))
|
|
12180
|
+
: null,
|
|
12181
|
+
// 社区空结果:搜索无命中与「只看可直接运行」筛空是两种不同的空,提示分开。
|
|
12182
|
+
!communityLoading && tab === 'connector' && connectorFeed === 'community' && !communityError && !communityVisibleRows.length
|
|
12183
|
+
? h(EmptyState, {
|
|
12184
|
+
icon: 'store',
|
|
12185
|
+
tight: true,
|
|
12186
|
+
title: t('没有匹配的社区条目'),
|
|
12187
|
+
hint: communityOnlyRunnable && communityHiddenCount > 0
|
|
12188
|
+
? t('有 {0} 条只有远程地址的条目被「只看可直接运行」挡住了。', [communityHiddenCount])
|
|
12189
|
+
: t('换一个更短的关键词,或取消「只看可直接运行」。'),
|
|
12190
|
+
actions: communityOnlyRunnable && communityHiddenCount > 0
|
|
12191
|
+
? [{ label: t('显示全部条目'), icon: 'eye', onClick: () => setCommunityOnlyRunnable(false) }]
|
|
12192
|
+
: [{ label: t('重新读取'), icon: 'refresh', onClick: () => loadCommunity({ cursor: '' }) }],
|
|
12193
|
+
})
|
|
12194
|
+
: null,
|
|
10946
12195
|
// §1.4 技能:搜索与「只看已安装」是两种完全不同的空,说法必须分开——
|
|
10947
12196
|
// 否则用户会以为 SkillHub 里根本没有这个词。
|
|
10948
12197
|
!skillLoading && tab === 'skill' && !skills.length
|
|
@@ -10965,9 +12214,15 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10965
12214
|
actions: [{ label: t('取消「只看已安装」'), icon: 'x', onClick: () => setSkillOnlyInstalled(false) }],
|
|
10966
12215
|
})
|
|
10967
12216
|
: null,
|
|
10968
|
-
tab === 'connector' && visibleConnectors.length
|
|
12217
|
+
tab === 'connector' && connectorFeed === 'wb' && visibleConnectors.length
|
|
10969
12218
|
? h('div', { className: 'dsh-mcp-wb-grid' },
|
|
10970
|
-
visibleConnectors.map((item) => h('div', {
|
|
12219
|
+
visibleConnectors.map((item) => h('div', {
|
|
12220
|
+
key: item.id,
|
|
12221
|
+
className: 'dsh-mcp-card wb' + (item.installable ? '' : ' blocked'),
|
|
12222
|
+
// P2-3:停住 200ms 才预取授权概览;扫过就离开不发请求。
|
|
12223
|
+
onMouseEnter: () => prefetchConnectorDetail(item),
|
|
12224
|
+
onMouseLeave: cancelHoverPrefetch,
|
|
12225
|
+
},
|
|
10971
12226
|
// §6.2 不兼容卡片:原来只在卡片底部一行 10.5px 橙字「不兼容:…」,
|
|
10972
12227
|
// 用户要滚到卡底才知道装不了。改成右上角橙色角标,一眼可见;
|
|
10973
12228
|
// 完整原因仍留在 blockedReason,hover 卡片或角标都能看到。
|
|
@@ -10979,10 +12234,9 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
10979
12234
|
h(ConnectorAvatar, { call, connectorId: item.id, name: item.name, className: 'dsh-mcp-card-avatar' }),
|
|
10980
12235
|
h('div', null, h('div', { className: 'dsh-mcp-card-name' }, item.name)),
|
|
10981
12236
|
),
|
|
10982
|
-
// T04/R8
|
|
10983
|
-
|
|
10984
|
-
|
|
10985
|
-
: h(Badge, { cls: item.installable ? 's-on' : '' }, item.installable ? t('可安装') : t('仅展示')),
|
|
12237
|
+
// T04/R8 + P0:安装账本只说明「写进过配置」;徽标按真实连接态细分
|
|
12238
|
+
// (已连接 / 需授权 / 未连接),找不到同名 server 时退回「已安装」。
|
|
12239
|
+
connectorBadge(item),
|
|
10986
12240
|
),
|
|
10987
12241
|
h('div', { className: 'dsh-mcp-card-summary' },
|
|
10988
12242
|
// T02/R3:examples 优先(displayBody 由 host 侧 displayCopyOf 产出),
|
|
@@ -11003,6 +12257,15 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11003
12257
|
),
|
|
11004
12258
|
h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
11005
12259
|
h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: () => openConnectorDetail(item) }, t('详情')),
|
|
12260
|
+
// P0:已安装但当前没连上 → 就地给出口(鉴权失败给「重新授权」,其余给「重连」),
|
|
12261
|
+
// 不让用户为了一个红点去 MCP 面板自己找。
|
|
12262
|
+
installedConnectorKeys.has(String(item.id || '')) && connectorHealth(item) !== 'connected' && connectorHealth(item) !== 'unknown'
|
|
12263
|
+
? h('button', {
|
|
12264
|
+
type: 'button', className: 'dsh-mcp-btn', disabled: busy,
|
|
12265
|
+
title: connectorHealth(item) === 'auth' ? t('服务器拒绝当前凭据:重新走一次授权') : t('拆掉这条连接并按当前配置重建'),
|
|
12266
|
+
onClick: () => reconnectConnector(item),
|
|
12267
|
+
}, connectorHealth(item) === 'auth' ? t('重新授权') : t('重连'))
|
|
12268
|
+
: null,
|
|
11006
12269
|
item.installable
|
|
11007
12270
|
? h('button', { type: 'button', className: 'dsh-mcp-btn primary', disabled: busy, onClick: () => requestInstallConnector(item) }, t('安装'))
|
|
11008
12271
|
: h('span', {
|
|
@@ -11013,9 +12276,91 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11013
12276
|
)),
|
|
11014
12277
|
)
|
|
11015
12278
|
: null,
|
|
11016
|
-
tab === 'connector' && connectorPageCount > 1
|
|
12279
|
+
tab === 'connector' && connectorFeed === 'wb' && connectorPageCount > 1
|
|
11017
12280
|
? h(Pager, { page: safeConnectorPage, pageCount: connectorPageCount, total: sortedConnectors.length, onPage: setConnectorPage })
|
|
11018
12281
|
: null,
|
|
12282
|
+
// ── 社区 MCP 卡片 ──
|
|
12283
|
+
// 与 WB 连接器同一视觉(.dsh-mcp-card wb)。字段只填 Registry 真实有的内容:
|
|
12284
|
+
// 标题 / 完整命名空间 / 描述 / 传输类型 / 环境变量数量 / 密钥字段 / 版本。
|
|
12285
|
+
// 命令不折行塞卡片:完整 command / args / env 在点开卡片后的详情弹层里只读展示,
|
|
12286
|
+
// 要改则走「加入 MCP」的预填弹层——两层各司其职,避免卡片被长命令行撑变形。
|
|
12287
|
+
tab === 'connector' && connectorFeed === 'community' && communityVisibleRows.length
|
|
12288
|
+
&& !(communityLoading && communityLoadedQueryRef.current !== communityQuery)
|
|
12289
|
+
? h('div', { className: 'dsh-mcp-wb-grid' },
|
|
12290
|
+
communityVisibleRows.map((row) => {
|
|
12291
|
+
const joined = communityJoined(row);
|
|
12292
|
+
const detailLabel = t('查看 {0} 的详情', [row.title || row.name]);
|
|
12293
|
+
const repoHref = externalHref(row.repoUrl);
|
|
12294
|
+
// 卡片本身不再是 role=button:里面已有「加入 MCP」按钮与仓库链接,
|
|
12295
|
+
// 把整张卡声明成按钮会产生嵌套交互控件,aria-label 还会吞掉整段卡片内容。
|
|
12296
|
+
// 鼠标点卡片任意处仍可开详情;键盘用户走操作区里真正的「详情」按钮。
|
|
12297
|
+
return h('div', {
|
|
12298
|
+
key: row.key,
|
|
12299
|
+
className: 'dsh-mcp-card wb clickable',
|
|
12300
|
+
title: detailLabel,
|
|
12301
|
+
onClick: () => setCommunityDetail(row),
|
|
12302
|
+
},
|
|
12303
|
+
h('div', { className: 'dsh-mcp-card-top' },
|
|
12304
|
+
h('div', { className: 'dsh-mcp-card-person' },
|
|
12305
|
+
// 头像只在 owner 可解析时才探图(与技能卡同一代理通道);解析不出用字母占位。
|
|
12306
|
+
h(Avatar, {
|
|
12307
|
+
url: row.owner ? avatarProxyUrl('https://github.com/' + row.owner + '.png', 64) : '',
|
|
12308
|
+
name: row.title || row.name,
|
|
12309
|
+
className: 'dsh-mcp-card-avatar',
|
|
12310
|
+
}),
|
|
12311
|
+
h('div', null,
|
|
12312
|
+
h('div', { className: 'dsh-mcp-card-name' }, row.title || row.name),
|
|
12313
|
+
h('div', { className: 'dsh-mcp-card-vendor', title: row.name }, row.name),
|
|
12314
|
+
),
|
|
12315
|
+
),
|
|
12316
|
+
joined ? h(Badge, { cls: 's-on', title: t('当前 MCP 列表里已有同名服务器') }, t('已加入')) : null,
|
|
12317
|
+
),
|
|
12318
|
+
h('div', { className: 'dsh-mcp-card-summary' }, row.description || row.name),
|
|
12319
|
+
h('div', { className: 'dsh-mcp-card-tags' },
|
|
12320
|
+
h(Badge, { cls: row.transport === 'http' ? 't-http' : 't-stdio' },
|
|
12321
|
+
row.transport === 'http' ? t('远程(需自备令牌)') : 'stdio'),
|
|
12322
|
+
row.env.length ? h(Badge, { title: row.env.map((item) => item.name).join(' / ') }, t('需 {0} 个环境变量', [row.env.length])) : null,
|
|
12323
|
+
row.env.some((item) => item.secret) ? h(Badge, { cls: 's-warn' }, t('含密钥字段')) : null,
|
|
12324
|
+
row.version ? h(Badge, null, 'v' + row.version) : null,
|
|
12325
|
+
),
|
|
12326
|
+
h('div', { className: 'dsh-mcp-wb-card-actions', onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation() },
|
|
12327
|
+
h('button', {
|
|
12328
|
+
type: 'button', className: 'dsh-mcp-btn', 'aria-label': detailLabel,
|
|
12329
|
+
onClick: () => setCommunityDetail(row),
|
|
12330
|
+
}, t('详情')),
|
|
12331
|
+
h('button', {
|
|
12332
|
+
type: 'button', className: 'dsh-mcp-btn primary', disabled: busy || joined,
|
|
12333
|
+
title: joined ? t('当前 MCP 列表里已有同名服务器') : t('预填一条 MCP 配置,确认后写入全局配置'),
|
|
12334
|
+
onClick: () => openCommunityDraft(row),
|
|
12335
|
+
}, joined ? t('已加入') : t('加入 MCP')),
|
|
12336
|
+
repoHref
|
|
12337
|
+
? h('a', { className: 'dsh-mcp-btn', href: repoHref, target: '_blank', rel: 'noreferrer' }, t('仓库'))
|
|
12338
|
+
: null,
|
|
12339
|
+
),
|
|
12340
|
+
);
|
|
12341
|
+
}),
|
|
12342
|
+
)
|
|
12343
|
+
: null,
|
|
12344
|
+
// 游标翻页:Registry 不返回总页数,只提供「第 N 页 + 下一页是否有」。
|
|
12345
|
+
// 下一页只在用户点击时才请求;当前页内容原地替换,不累加。
|
|
12346
|
+
// 用「已读取 > 0」而不是「本页有条目」作为显示条件:某页刚好被筛空时,
|
|
12347
|
+
// 上一页 / 下一页仍要在,否则用户会被困在空白页里。
|
|
12348
|
+
tab === 'connector' && connectorFeed === 'community' && communityReadTotal > 0
|
|
12349
|
+
? h('div', { className: 'dsh-mcp-wb-more' },
|
|
12350
|
+
h('button', {
|
|
12351
|
+
type: 'button', className: 'dsh-mcp-btn',
|
|
12352
|
+
disabled: communityLoading || communityPrevCursor === null,
|
|
12353
|
+
onClick: () => loadCommunity({ cursor: communityPrevCursor || '', page: communityPage - 1 }),
|
|
12354
|
+
}, t('上一页')),
|
|
12355
|
+
h('span', { className: 'dsh-mcp-builtin-count' }, t('第 {0} 页', [communityPage])),
|
|
12356
|
+
h('button', {
|
|
12357
|
+
type: 'button', className: 'dsh-mcp-btn',
|
|
12358
|
+
disabled: communityLoading || !communityNextCursor,
|
|
12359
|
+
title: communityNextCursor ? undefined : t('已经到底了'),
|
|
12360
|
+
onClick: () => loadCommunity({ cursor: communityNextCursor || '', page: communityPage + 1 }),
|
|
12361
|
+
}, communityLoading ? t('正在加载…') : t('下一页')),
|
|
12362
|
+
)
|
|
12363
|
+
: null,
|
|
11019
12364
|
tab === 'skill' && shownSkills.length
|
|
11020
12365
|
? h('div', { className: 'dsh-mcp-wb-grid' },
|
|
11021
12366
|
shownSkills.map((skill) => {
|
|
@@ -11024,7 +12369,13 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11024
12369
|
// 安装时间:wbSkillInstalled 行自带(账本首次安装时间,无账本回落目录创建时间)。
|
|
11025
12370
|
const installedAt = installedSkills.get(installedKey) || 0;
|
|
11026
12371
|
const installedLabel = installedAt ? t('安装于 {0}', [wbFormatDate(installedAt)]) : '';
|
|
11027
|
-
return h('div', {
|
|
12372
|
+
return h('div', {
|
|
12373
|
+
key: skill.namespace + '/' + skill.slug,
|
|
12374
|
+
className: 'dsh-mcp-card wb',
|
|
12375
|
+
// P2-3:同上,停住才预取技能详情。
|
|
12376
|
+
onMouseEnter: () => prefetchSkillDetail(skill),
|
|
12377
|
+
onMouseLeave: cancelHoverPrefetch,
|
|
12378
|
+
},
|
|
11028
12379
|
h('div', { className: 'dsh-mcp-card-top' },
|
|
11029
12380
|
h('div', { className: 'dsh-mcp-card-person' },
|
|
11030
12381
|
h(Avatar, {
|
|
@@ -11115,7 +12466,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11115
12466
|
const sealTip = verifiedLabel ? verifiedLabel + (verifiedDate ? ' · ' + t('核验 {0}', [verifiedDate]) : '') : '';
|
|
11116
12467
|
const installed = installedPluginKeys.has(String(item.name || ''));
|
|
11117
12468
|
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); } } },
|
|
12469
|
+
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
12470
|
h('div', { className: 'dsh-mcp-phead' },
|
|
11120
12471
|
h('span', { className: 'dsh-mcp-pavatar', 'aria-hidden': true },
|
|
11121
12472
|
String(item.name || '?').slice(0, 1).toUpperCase() || '?',
|
|
@@ -11220,14 +12571,20 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11220
12571
|
seen.add(slot.placeholder);
|
|
11221
12572
|
slots.push(slot);
|
|
11222
12573
|
}
|
|
11223
|
-
return h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setTokenDialog(null) },
|
|
12574
|
+
return h('div', { className: 'dsh-mcp-overlay dsh-mcp-front', onClick: (e) => !busy && e.target === e.currentTarget && setTokenDialog(null) },
|
|
11224
12575
|
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
12576
|
h('h3', null, t('填写 {0} 的凭据', [tokenDialog.item.name])),
|
|
11226
12577
|
h('p', { className: 'dsh-mcp-wb-modal-note', role: 'note' },
|
|
11227
12578
|
t('该连接器的配置里有 {0} 个占位符,需要你提供对应凭据才能生效。没填的会保持原样,安装后仍可在 MCP 配置里手动补全。', [slots.length])),
|
|
11228
12579
|
slots.length
|
|
11229
12580
|
? 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})', [
|
|
12581
|
+
h('span', { className: 'dsh-mcp-wb-tokenlabel' }, slot.placeholder + t('({0})', [
|
|
12582
|
+
slot.container === 'header'
|
|
12583
|
+
? t('请求头 ') + slot.key
|
|
12584
|
+
: slot.container === 'url'
|
|
12585
|
+
? t('URL 参数')
|
|
12586
|
+
: t('环境变量 ') + slot.key,
|
|
12587
|
+
])),
|
|
11231
12588
|
h('input', {
|
|
11232
12589
|
type: 'password',
|
|
11233
12590
|
autoComplete: 'off',
|
|
@@ -11252,7 +12609,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11252
12609
|
tab === 'expert' ? h(ExpertMarket, { call, key: 'expert-' + expertEpoch }) : null,
|
|
11253
12610
|
// ── R8:OAuth 授权弹层(401 → 浏览器授权 → 自动重装)──
|
|
11254
12611
|
authDialog
|
|
11255
|
-
? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && closeAuthDialog() },
|
|
12612
|
+
? h('div', { className: 'dsh-mcp-overlay dsh-mcp-front', onClick: (e) => !busy && e.target === e.currentTarget && closeAuthDialog() },
|
|
11256
12613
|
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
12614
|
h('h3', null, t('授权 {0}', [authDialog.serverName])),
|
|
11258
12615
|
h('p', { className: 'dsh-mcp-wb-modal-note', role: 'note' },
|
|
@@ -11336,6 +12693,14 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11336
12693
|
item: connectorDetail, kind: 'connector',
|
|
11337
12694
|
sourceLabel: t('来源:WorkBuddy 官方分发'),
|
|
11338
12695
|
}),
|
|
12696
|
+
// issue #3:R3 让 examples 占正文,description 只在没有 examples 时才出现——于是
|
|
12697
|
+
// NVIDIA App 这类「需先装本机客户端」的前置要求被整段藏掉。examples 在场时补一行
|
|
12698
|
+
// 说明,把要求原样透出(英文界面取 descriptionEn)。
|
|
12699
|
+
connectorDetail.displayFrom === 'examples' && (connectorDetail.description || connectorDetail.descriptionEn)
|
|
12700
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note' },
|
|
12701
|
+
t('说明:{0}', [usesEnglish()
|
|
12702
|
+
? (connectorDetail.descriptionEn || connectorDetail.description)
|
|
12703
|
+
: connectorDetail.description])) : null,
|
|
11339
12704
|
// T02/R3:详情正文同样 examples 优先,description 兜底。
|
|
11340
12705
|
connectorDetail.displayBody || connectorDetail.description
|
|
11341
12706
|
? h('p', { className: 'dsh-mcp-wb-modal-desc' }, connectorDetail.displayBody || connectorDetail.description) : null,
|
|
@@ -11351,15 +12716,68 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11351
12716
|
connectorDetail.authMode ? h(Badge, null, t('凭据: ') + connectorDetail.authMode) : null,
|
|
11352
12717
|
)
|
|
11353
12718
|
: null,
|
|
12719
|
+
// P0:授权状态(只看已安装的连接器)。此前 wbOAuthOverview 声明了却从没被调用,
|
|
12720
|
+
// 用户看不到「授权还有效吗 / 什么时候过期」——token 一过期就只剩一句连接失败。
|
|
12721
|
+
installedConnectorKeys.has(String(connectorDetail.id || '')) && (connectorAuth || connectorDetail.needsOAuth)
|
|
12722
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12723
|
+
h('b', null, t('授权状态')),
|
|
12724
|
+
connectorAuth?.loading
|
|
12725
|
+
? h('p', null, t('正在读取授权状态…'))
|
|
12726
|
+
: connectorAuth?.authorized
|
|
12727
|
+
? h('p', null, connectorAuth.fresh === false
|
|
12728
|
+
? t('授权已过期({0}),点「刷新授权」重新获取;刷不动会自动回到浏览器授权。', [authExpiryText(connectorAuth.expiresAt) || t('无有效期记录')])
|
|
12729
|
+
: t('已授权{0}', [connectorAuth.expiresAt ? t(',有效期至 {0}', [authExpiryText(connectorAuth.expiresAt)]) : '']))
|
|
12730
|
+
: h('p', null, connectorDetail.needsOAuth
|
|
12731
|
+
? t('尚未授权:安装时会引导你完成浏览器授权。')
|
|
12732
|
+
: t('没有可用的授权记录。')),
|
|
12733
|
+
connectorAuth?.refreshable
|
|
12734
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note' }, t('服务器提供了 refresh_token:过期后可以在这里直接刷新,无需重走浏览器。'))
|
|
12735
|
+
: null,
|
|
12736
|
+
connectorAuth?.authorized
|
|
12737
|
+
? h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
12738
|
+
h(Btn, { disabled: busy, onClick: () => refreshConnectorAuth(connectorDetail), children: t('刷新授权') }))
|
|
12739
|
+
: connectorDetail.needsOAuth
|
|
12740
|
+
? h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
12741
|
+
h(Btn, { disabled: busy, onClick: () => beginConnectorAuth(connectorDetail), children: t('去授权') }))
|
|
12742
|
+
: null,
|
|
12743
|
+
)
|
|
12744
|
+
: null,
|
|
11354
12745
|
connectorDetail.examples && connectorDetail.examples.length
|
|
11355
12746
|
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
11356
12747
|
h('b', null, t('用法示例')),
|
|
11357
12748
|
h('ul', null, connectorDetail.examples.slice(0, 4).map((example, index) => h('li', { key: index }, example))),
|
|
11358
12749
|
)
|
|
11359
12750
|
: null,
|
|
12751
|
+
// 本机固定依赖(issue #3):本地客户端型连接器把绝对路径写死在 mcp.json 里。
|
|
12752
|
+
// 这里直接列出路径与静态检查结果;缺依赖时安装闸会拦住,不再写一条连不上的配置。
|
|
12753
|
+
connectorDetail.localDeps && connectorDetail.localDeps.length
|
|
12754
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12755
|
+
h('b', null, t('本机依赖')),
|
|
12756
|
+
h('ul', null, connectorDetail.localDeps.map((dep) => h('li', { key: dep.path },
|
|
12757
|
+
dep.status === 'found'
|
|
12758
|
+
? t('已找到:{0}', [dep.path])
|
|
12759
|
+
: dep.status === 'platform'
|
|
12760
|
+
? t('当前系统不支持:{0}', [dep.path])
|
|
12761
|
+
: t('未找到:{0}', [dep.path])))),
|
|
12762
|
+
connectorDetail.localDeps.some((dep) => dep.status !== 'found')
|
|
12763
|
+
? h('p', { className: 'dsh-mcp-wb-modal-note' },
|
|
12764
|
+
t('本地客户端缺失时安装会失败;请先安装对应客户端,并开启它的 MCP / 集成访问后再重试。'))
|
|
12765
|
+
: null,
|
|
12766
|
+
)
|
|
12767
|
+
: null,
|
|
11360
12768
|
h('p', { className: 'dsh-mcp-wb-modal-note' },
|
|
11361
12769
|
t(`安装 = 把该连接器的 mcpServers 合并写入当前作用域(与「导入 JSON」同一链路,同名跳过)。`) +
|
|
11362
12770
|
(connectorDetail.skillCount ? t('另含 {0} 个连接器技能,如需请从技能市场或手动安装。', [connectorDetail.skillCount]) : '')),
|
|
12771
|
+
// OAuth 型连接器的逃生口:浏览器授权发现失败时仍可手填令牌;入口常驻在详情里,
|
|
12772
|
+
// 不必先点一次安装失败才出现。
|
|
12773
|
+
connectorDetail.installable && connectorDetail.needsOAuth
|
|
12774
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12775
|
+
h('b', null, t('凭据来源')),
|
|
12776
|
+
h('p', null, t('该连接器需要通过浏览器完成一次授权;如果你已经从服务方拿到访问令牌,也可以在这里直接填写。')),
|
|
12777
|
+
h('div', { className: 'dsh-mcp-wb-card-actions' },
|
|
12778
|
+
h(Btn, { disabled: busy, onClick: () => openManualTokenDialog(connectorDetail), children: t('我已有令牌') })),
|
|
12779
|
+
)
|
|
12780
|
+
: null,
|
|
11363
12781
|
h('div', { className: 'dsh-mcp-modal-actions' },
|
|
11364
12782
|
h(Btn, { onClick: () => setConnectorDetail(null), disabled: busy, children: t('取消') }),
|
|
11365
12783
|
connectorDetail.installable
|
|
@@ -11371,8 +12789,8 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11371
12789
|
: null,
|
|
11372
12790
|
// ── 技能详情弹层:安全报告 + 发布方 + 安装/卸载 ──
|
|
11373
12791
|
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();
|
|
12792
|
+
? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && closeSkillDetail() },
|
|
12793
|
+
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
12794
|
h('div', { className: 'dsh-mcp-detail-person' },
|
|
11377
12795
|
h(Avatar, {
|
|
11378
12796
|
url: skillDetail.iconUrl || githubAvatarUrl(skillDetail.homepage, 88),
|
|
@@ -11421,7 +12839,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11421
12839
|
t('安装 = 逐文件下载并 sha256 校验后写入 ~/.agents/skills/,即刻被 DSH 发现(无需重启)。已装版本重装即更新。')),
|
|
11422
12840
|
),
|
|
11423
12841
|
h('div', { className: 'dsh-mcp-modal-actions' },
|
|
11424
|
-
h(Btn, { onClick: () =>
|
|
12842
|
+
h(Btn, { onClick: () => closeSkillDetail(), disabled: busy, children: t('关闭') }),
|
|
11425
12843
|
installedSkills.has(skillInstalledKey(skillDetail))
|
|
11426
12844
|
? h(Btn, { danger: true, icon: 'trash', disabled: busy, onClick: () => uninstallSkill(skillDetail), children: t('卸载') })
|
|
11427
12845
|
: h(Btn, { primary: true, icon: 'plus', disabled: busy || skillDetail.loading, onClick: () => installSkill(skillDetail), children: busy ? t('安装中…') : t('安装') }),
|
|
@@ -11429,6 +12847,92 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
11429
12847
|
),
|
|
11430
12848
|
)
|
|
11431
12849
|
: null,
|
|
12850
|
+
// ── 社区 MCP 详情弹层:整卡点开,内容与交互对齐插件 README 弹层 ──
|
|
12851
|
+
// 只展示 Registry 已给的字段:command / args / env 只读,要改走「加入 MCP」预填。
|
|
12852
|
+
communityDetail
|
|
12853
|
+
? h('div', { className: 'dsh-mcp-overlay', onClick: (e) => !busy && e.target === e.currentTarget && setCommunityDetail(null) },
|
|
12854
|
+
h('div', {
|
|
12855
|
+
ref: communityDetailRef,
|
|
12856
|
+
className: 'dsh-mcp-modal dsh-mcp-wb-modal',
|
|
12857
|
+
role: 'dialog',
|
|
12858
|
+
'aria-modal': true,
|
|
12859
|
+
'aria-label': t('社区 MCP 详情'),
|
|
12860
|
+
tabIndex: -1,
|
|
12861
|
+
onKeyDown: (e) => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setCommunityDetail(null); } },
|
|
12862
|
+
},
|
|
12863
|
+
h('div', { className: 'dsh-mcp-detail-person' },
|
|
12864
|
+
h(Avatar, {
|
|
12865
|
+
url: communityDetail.owner ? avatarProxyUrl('https://github.com/' + communityDetail.owner + '.png', 88) : '',
|
|
12866
|
+
name: communityDetail.title || communityDetail.name,
|
|
12867
|
+
className: 'dsh-mcp-detail-avatar',
|
|
12868
|
+
}),
|
|
12869
|
+
h('div', null,
|
|
12870
|
+
h('h3', null, communityDetail.title || communityDetail.name),
|
|
12871
|
+
h('div', { className: 'dsh-mcp-card-vendor', title: communityDetail.name }, communityDetail.name),
|
|
12872
|
+
),
|
|
12873
|
+
),
|
|
12874
|
+
h('div', { className: 'dsh-mcp-card-tags' },
|
|
12875
|
+
h(Badge, { cls: communityDetail.transport === 'http' ? 't-http' : 't-stdio' },
|
|
12876
|
+
communityDetail.transport === 'http' ? t('远程(需自备令牌)') : 'stdio'),
|
|
12877
|
+
communityDetail.version ? h(Badge, null, 'v' + communityDetail.version) : null,
|
|
12878
|
+
communityDetail.env.length ? h(Badge, null, t('需 {0} 个环境变量', [communityDetail.env.length])) : null,
|
|
12879
|
+
communityDetail.env.some((item) => item.secret) ? h(Badge, { cls: 's-warn' }, t('含密钥字段')) : null,
|
|
12880
|
+
communityJoined(communityDetail) ? h(Badge, { cls: 's-on' }, t('已加入')) : null,
|
|
12881
|
+
),
|
|
12882
|
+
communityDetail.description
|
|
12883
|
+
? h('p', { className: 'dsh-mcp-wb-modal-desc' }, communityDetail.description)
|
|
12884
|
+
: null,
|
|
12885
|
+
// 环境变量:默认值原样展示(可能含令牌),但绝不写进配置;是否落盘由用户在草稿里决定。
|
|
12886
|
+
communityDetail.env.length
|
|
12887
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12888
|
+
h('b', null, t('环境变量')),
|
|
12889
|
+
h('ul', null, communityDetail.env.map((item) => h('li', { key: item.name },
|
|
12890
|
+
h('code', null, item.name),
|
|
12891
|
+
' · ' + (item.required ? t('必填') : t('可选')),
|
|
12892
|
+
item.secret ? ' · ' + t('密钥') : '',
|
|
12893
|
+
item.description ? ' — ' + item.description : '',
|
|
12894
|
+
item.hasDefault ? ' · ' + t('默认值:') + item.default : ''))),
|
|
12895
|
+
)
|
|
12896
|
+
: null,
|
|
12897
|
+
communityDetail.runnable
|
|
12898
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12899
|
+
h('b', null, t('启动命令')),
|
|
12900
|
+
h('p', { className: 'dsh-mcp-cmdline' }, [communityDetail.command].concat(communityDetail.args || []).filter(Boolean).join(' ')))
|
|
12901
|
+
: communityDetail.remoteUrl
|
|
12902
|
+
? h('div', { className: 'dsh-mcp-wb-examples' },
|
|
12903
|
+
h('b', null, t('远程地址')),
|
|
12904
|
+
h('p', { className: 'dsh-mcp-cmdline' }, communityDetail.remoteUrl))
|
|
12905
|
+
: null,
|
|
12906
|
+
h('p', { className: 'dsh-mcp-wb-modal-note' },
|
|
12907
|
+
t('社区 MCP 来自官方 MCP Registry,由社区提交;军械库未审核其代码,加入前请自行确认命令与环境变量。')),
|
|
12908
|
+
h('div', { className: 'dsh-mcp-modal-actions' },
|
|
12909
|
+
h(Btn, { disabled: busy, onClick: () => setCommunityDetail(null), children: t('关闭') }),
|
|
12910
|
+
externalHref(communityDetail.repoUrl)
|
|
12911
|
+
? h('a', { className: 'dsh-mcp-btn', href: externalHref(communityDetail.repoUrl), target: '_blank', rel: 'noreferrer' }, t('仓库'))
|
|
12912
|
+
: null,
|
|
12913
|
+
communityJoined(communityDetail)
|
|
12914
|
+
? h(Btn, { disabled: true, children: t('已加入') })
|
|
12915
|
+
: h(Btn, {
|
|
12916
|
+
primary: true, icon: 'plus', disabled: busy,
|
|
12917
|
+
// 先关详情再开草稿:两层弹层不叠加,焦点陷阱也不会互抢。
|
|
12918
|
+
onClick: () => { const row = communityDetail; setCommunityDetail(null); openCommunityDraft(row); },
|
|
12919
|
+
children: t('加入 MCP'),
|
|
12920
|
+
}),
|
|
12921
|
+
),
|
|
12922
|
+
),
|
|
12923
|
+
)
|
|
12924
|
+
: null,
|
|
12925
|
+
// 社区条目的「加入 MCP」:与 MCP 页同一个表单,draft 模式(预填 + 提交走新增)。
|
|
12926
|
+
// 不静默写盘:用户必须在这层确认 command / args / env 后才落盘。
|
|
12927
|
+
h(AddEditModal, {
|
|
12928
|
+
open: !!communityDraft,
|
|
12929
|
+
initial: communityDraft,
|
|
12930
|
+
draft: true,
|
|
12931
|
+
onClose: () => setCommunityDraft(null),
|
|
12932
|
+
onSubmit: submitCommunityDraft,
|
|
12933
|
+
busy,
|
|
12934
|
+
scopeName: t('全局'),
|
|
12935
|
+
}),
|
|
11432
12936
|
// 装备清单:导出 / 导入对比。只读,不自动安装。
|
|
11433
12937
|
loadoutOpen
|
|
11434
12938
|
? h(LoadoutModal, {
|
|
@@ -12685,6 +14189,10 @@ const MCP_CONTRIBUTION = {
|
|
|
12685
14189
|
{ id: 'dsh-armory#mcpManager/skillDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'skillDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillDetailResult') },
|
|
12686
14190
|
{ id: 'dsh-armory#mcpManager/revealSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'revealSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12687
14191
|
{ id: 'dsh-armory#mcpManager/removeSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'removeSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
14192
|
+
// 禁用 / 释放 skill(工具裁缝技能账本行内按钮):写 SKILL.md frontmatter 的
|
|
14193
|
+
// disable-model-invocation 标志。与 typert.js、lib/index.js 三处同步,漏一处恒 404。
|
|
14194
|
+
{ id: 'dsh-armory#mcpManager/disableSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'disableSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
14195
|
+
{ id: 'dsh-armory#mcpManager/enableSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'enableSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12688
14196
|
{ id: 'dsh-armory#mcpManager/skillInstallInfo', service: 'mcpManager', namespace: 'mcpManager', method: 'skillInstallInfo', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillInstallInfoResult') },
|
|
12689
14197
|
// 自定义套件:读全量集合 / 整集合覆盖保存(与 typert.js、lib/index.js 三处同步,
|
|
12690
14198
|
// 漏一处这条通道恒 404 —— toolTailor、installInfo 各踩过一次)。
|
|
@@ -12705,6 +14213,7 @@ const MCP_CONTRIBUTION = {
|
|
|
12705
14213
|
{ id: 'dsh-armory#mcpManager/wbOAuthCancel', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthCancel', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12706
14214
|
{ id: 'dsh-armory#mcpManager/wbOAuthToken', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthToken', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12707
14215
|
{ id: 'dsh-armory#mcpManager/wbOAuthOverview', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthOverview', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
14216
|
+
{ id: 'dsh-armory#mcpManager/wbOAuthRefresh', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthRefresh', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12708
14217
|
{ id: 'dsh-armory#mcpManager/wbOAuthOpen', service: 'mcpManager', namespace: 'mcpManager', method: 'wbOAuthOpen', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
12709
14218
|
{ id: 'dsh-armory#mcpManager/wbSkillCatalog', service: 'mcpManager', namespace: 'mcpManager', method: 'wbSkillCatalog', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbSkillListResult') },
|
|
12710
14219
|
{ id: 'dsh-armory#mcpManager/wbSkillDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbSkillDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbSkillDetailResult') },
|
|
@@ -12723,6 +14232,12 @@ const MCP_CONTRIBUTION = {
|
|
|
12723
14232
|
{ id: 'dsh-armory#mcpManager/wbAwesomeDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeDetailResult') },
|
|
12724
14233
|
{ id: 'dsh-armory#mcpManager/wbAwesomeReadme', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeReadme', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('AwesomeReadmeResult') },
|
|
12725
14234
|
{ id: 'dsh-armory#mcpManager/wbAwesomeUpdateCheck', service: 'mcpManager', namespace: 'mcpManager', method: 'wbAwesomeUpdateCheck', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbUpdateHintResult') },
|
|
14235
|
+
// 社区 MCP(官方 MCP Registry v0,只读):连接器页第二个来源。payload {search, cursor,
|
|
14236
|
+
// limit, takenNames},返回 {ok, rows, nextCursor, fetchedAt, cached}。三处同步,漏一处恒 404。
|
|
14237
|
+
{ id: 'dsh-armory#mcpManager/wbMcpCommunityList', service: 'mcpManager', namespace: 'mcpManager', method: 'wbMcpCommunityList', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbMcpCommunityResult') },
|
|
14238
|
+
// 社区目录全库总数的有界统计(面板徽标常驻显示用)。payload {cursor, pages},
|
|
14239
|
+
// 返回 {ok, counted, nextCursor, done, pages}。漏这一行 = 面板调它恒 404。
|
|
14240
|
+
{ id: 'dsh-armory#mcpManager/wbMcpCommunityCount', service: 'mcpManager', namespace: 'mcpManager', method: 'wbMcpCommunityCount', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbMcpCommunityCountResult') },
|
|
12726
14241
|
// 社区元信息(仓库维护信号):payload {repos, refresh},返回 {repos:{repo:{...}}}。
|
|
12727
14242
|
{ id: 'dsh-armory#mcpManager/wbRepoMeta', service: 'mcpManager', namespace: 'mcpManager', method: 'wbRepoMeta', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbRepoMetaResult') },
|
|
12728
14243
|
// npm 近 30 天下载量:payload {packages, refresh},返回 {packages:{pkg:{ok,downloads,...}}}。
|
|
@@ -12747,6 +14262,8 @@ const MCP_CONTRIBUTION = {
|
|
|
12747
14262
|
};
|
|
12748
14263
|
// ── 插件入口:挂 RPC、注入样式、注册六个界面槽位 ──────────────────────────────
|
|
12749
14264
|
async function apply(ctx) {
|
|
14265
|
+
// P0-2:apply 起止(相对 bundle materialize 起点)。开关关闭时 PERF_ON 为 false,空转。
|
|
14266
|
+
if (PERF_ON) perfMark('apply', 'start', performance.now() - PERF_T0);
|
|
12750
14267
|
// OPT-25:先接宿主 locale(设置 → 通用里的语言),再注册槽位——槽位 label 是
|
|
12751
14268
|
// 注册时求值的字符串,必须等这里算完。ctx.get 是可选读取,不写进 inject:
|
|
12752
14269
|
// 老版本 DSH 没有 locale 服务时返回 undefined,自动回落 navigator/zh-CN。
|
|
@@ -12785,13 +14302,24 @@ async function apply(ctx) {
|
|
|
12785
14302
|
disposeCss = __injectCss(MCP_CSS);
|
|
12786
14303
|
timer = createTimer();
|
|
12787
14304
|
MODULE_CTX.ctx = ctx;
|
|
14305
|
+
// P0-2:首个 business RPC 的方法名与往返耗时。第一次调用之后不再计时(只付一次分支)。
|
|
14306
|
+
let firstRpcDone = !PERF_ON;
|
|
12788
14307
|
const call = async (method, args) => {
|
|
12789
|
-
const
|
|
12790
|
-
|
|
12791
|
-
|
|
12792
|
-
|
|
12793
|
-
|
|
12794
|
-
|
|
14308
|
+
const rpcStartedAt = firstRpcDone ? 0 : performance.now();
|
|
14309
|
+
try {
|
|
14310
|
+
const svc = (typeof ctx.get === 'function' ? ctx.get('remote.mcpManager') : null) || null;
|
|
14311
|
+
if (!svc || typeof svc[method] !== 'function') return { ok: false, error: t('MCP 市场服务未连接(面板仍可用)') };
|
|
14312
|
+
const r = args === undefined || args === null ? await svc[method]() : await svc[method](args);
|
|
14313
|
+
if (r && r.ok) return { ok: true, ...((r.value && typeof r.value === 'object') ? r.value : {}) };
|
|
14314
|
+
if (r && !r.ok) return { ok: false, error: (r.error && (r.error.message || r.error.code)) || t('调用失败') };
|
|
14315
|
+
return r;
|
|
14316
|
+
} finally {
|
|
14317
|
+
if (rpcStartedAt) {
|
|
14318
|
+
firstRpcDone = true;
|
|
14319
|
+
perfMark('firstRpc', 'ms', performance.now() - rpcStartedAt);
|
|
14320
|
+
if (perfBucket) perfBucket.firstRpc.method = method;
|
|
14321
|
+
}
|
|
14322
|
+
}
|
|
12795
14323
|
};
|
|
12796
14324
|
// 一键升级要用官方 pluginManager,而它只在 apply 的 ctx 上取得到(面板组件拿不到 ctx——
|
|
12797
14325
|
// 它的 props 来自 slot 的 inject)。所以在这里取好、随 inject 传进面板,与 call 同一手法;
|
|
@@ -12874,6 +14402,7 @@ async function apply(ctx) {
|
|
|
12874
14402
|
ctx.slots.inject('sidebar.footer.action', () =>
|
|
12875
14403
|
ctx.slots.register({ name: 'sidebar.footer.action', id: 'mcp-manager', order: 100, label: panelLabel() }, McpFooterAction),
|
|
12876
14404
|
);
|
|
14405
|
+
if (PERF_ON) perfMark('apply', 'end', performance.now() - PERF_T0);
|
|
12877
14406
|
return cleanup;
|
|
12878
14407
|
} catch (error) {
|
|
12879
14408
|
await cleanup();
|
|
@@ -12883,6 +14412,8 @@ async function apply(ctx) {
|
|
|
12883
14412
|
const inject = ['slots', 'remote', 'typert', 'remote.pluginManager'];
|
|
12884
14413
|
exports.apply = apply;
|
|
12885
14414
|
exports.inject = inject;
|
|
14415
|
+
// P0-2:bundle 解析 + 全部组件定义完成的耗时(开关关闭时 perfMark 空转)。
|
|
14416
|
+
if (PERF_ON) perfMark('module', 'materialize', performance.now() - PERF_T0);
|
|
12886
14417
|
|
|
12887
14418
|
return module.exports;
|
|
12888
14419
|
}
|