dsh-armory 2.1.0 → 2.2.1
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 +20 -5
- package/README.md +26 -8
- package/lib/client.js +1353 -168
- package/lib/index.js +51 -9
- package/lib/mcp-catalog-en.js +244 -0
- package/lib/mcp-config.js +15 -0
- package/lib/mcp-oauth.js +1 -1
- package/lib/skill-remove.js +125 -0
- package/lib/suites-registry.js +179 -0
- package/lib/tool-ledger.js +180 -11
- package/lib/typert.js +9 -0
- package/lib/wb-awesome-market.js +1 -1
- package/lib/wb-install-state.js +1 -1
- package/lib/wb-market.js +44 -21
- package/lib/wb-registry.js +1 -1
- package/lib/whats-new-en.js +406 -0
- package/lib/whats-new.js +45 -0
- package/lib/workspace-runtime.js +1 -1
- package/package.json +2 -2
- package/docs/chunk-split-feasibility.md +0 -153
- package/docs/host-proposal-mcp-teardown.md +0 -184
- package/docs/host-proposal-skill-toggle.md +0 -166
- package/docs/prd-awesome-dsh-plugins.md +0 -105
- package/docs/prd-workbuddy-parity.md +0 -128
- package/docs/system-design-awesome.md +0 -269
- package/docs/system_design.md +0 -530
- package/docs/workbuddy-market-gap-analysis.md +0 -197
package/lib/client.js
CHANGED
|
@@ -46,15 +46,54 @@ const { useState, useEffect, useCallback, useMemo, useRef } = React2;
|
|
|
46
46
|
// 将来加新语言可改走 require.async 外部字典文件。
|
|
47
47
|
const i18nMessages = {};
|
|
48
48
|
const setI18nMessages = (table) => { Object.assign(i18nMessages, table || {}); };
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
49
|
+
const resetI18nMessages = () => { for (const key of Object.keys(i18nMessages)) delete i18nMessages[key]; };
|
|
50
|
+
// OPT-14 · 1.1 / OPT-25 locale 判定(首个命中即返回):
|
|
51
|
+
// ① localStorage['dsh-armory.locale'] —— 显式覆盖(测试与高级用户)
|
|
52
|
+
// ② DSH 宿主 locale.active —— 设置 → 通用里的语言,用户真正看到的 UI 语言
|
|
53
|
+
// ③ navigator.language —— 宿主 locale 不可用时的浏览器回落
|
|
54
|
+
// ④ zh-CN —— 源语言兜底
|
|
55
|
+
// 只读 navigator 是 2.2.0 及以前的做法:中文系统 + DSH 设英文时 navigator 仍是
|
|
56
|
+
// zh-CN,英文表永远不装载(issue #1 的根因)。宿主服务用 ctx.get('locale') 取,
|
|
57
|
+
// **不写进 inject**——老版本 DSH 没有该服务时仍能照常加载。
|
|
58
|
+
// 所有失败路径(存储不可用、无 navigator、宿主快照抛错)都静默回落,绝不抛错。
|
|
59
|
+
const readStoredI18nLocale = () => {
|
|
52
60
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
return localStorage['dsh-armory.locale'] || null;
|
|
62
|
+
} catch { return null; }
|
|
63
|
+
};
|
|
64
|
+
const hostActiveLocale = (localeService) => {
|
|
65
|
+
try {
|
|
66
|
+
const snapshot = localeService && typeof localeService.getSnapshot === 'function' ? localeService.getSnapshot() : null;
|
|
67
|
+
return snapshot && typeof snapshot.active === 'string' && snapshot.active ? snapshot.active : null;
|
|
68
|
+
} catch { return null; }
|
|
69
|
+
};
|
|
70
|
+
const initialI18nLocale = readStoredI18nLocale()
|
|
71
|
+
|| (typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'zh-CN');
|
|
72
|
+
let I18N_LOCALE = initialI18nLocale;
|
|
73
|
+
const resolveI18nLocale = (localeService) => readStoredI18nLocale()
|
|
74
|
+
|| hostActiveLocale(localeService)
|
|
75
|
+
|| initialI18nLocale
|
|
76
|
+
|| 'zh-CN';
|
|
77
|
+
// 切换语言时必须先清空旧字典:中文(源语言)要求空表才能恒等直出,
|
|
78
|
+
// 否则从 en 切回 zh 会残留英文译文。
|
|
79
|
+
// P1:源语言是中文、唯一译文是英文,所以按「zh* → 中文,其余 → 英文」判定。
|
|
80
|
+
// 非 zh 的非英文语言(ja / de / fr…)宁可给英文,也不把中文丢给看不懂的用户;
|
|
81
|
+
// en* 之外的显式覆盖同理。新增第三种语言时才需要把这里改成精确查表。
|
|
82
|
+
const isChineseLocale = (locale = I18N_LOCALE) => String(locale || '').toLowerCase().startsWith('zh');
|
|
83
|
+
const usesEnglish = (locale = I18N_LOCALE) => !isChineseLocale(locale);
|
|
84
|
+
const applyI18nLocale = (locale) => {
|
|
85
|
+
I18N_LOCALE = locale || initialI18nLocale || 'zh-CN';
|
|
86
|
+
resetI18nMessages();
|
|
87
|
+
if (usesEnglish()) setI18nMessages(EN_US_MESSAGES);
|
|
88
|
+
return I18N_LOCALE;
|
|
89
|
+
};
|
|
90
|
+
// 宿主 locale 订阅:包一层 try/catch,服务没实现 subscribe 或抛错都不影响面板。
|
|
91
|
+
const subscribeI18nLocale = (localeService, listener) => {
|
|
92
|
+
try {
|
|
93
|
+
if (localeService && typeof localeService.subscribe === 'function') return localeService.subscribe(listener);
|
|
94
|
+
} catch { /* 订阅失败:保留启动时判定的语言 */ }
|
|
95
|
+
return null;
|
|
96
|
+
};
|
|
58
97
|
const EN_US_MESSAGES = {
|
|
59
98
|
" + 捆绑技能": " + bundled skills",
|
|
60
99
|
" · {0} 个失败": " · {0} failed",
|
|
@@ -117,6 +156,17 @@ const EN_US_MESSAGES = {
|
|
|
117
156
|
"toolTailor 调用失败": "toolTailor call failed",
|
|
118
157
|
"zstd 解压不可用:当前 Node 不支持 zlib.zstdDecompressSync(需 >= 22.15),所有调用计数都会是 0": "zstd decompression unavailable: this Node lacks zlib.zstdDecompressSync (needs >= 22.15); all call counts will read 0",
|
|
119
158
|
"{0} 个 MCP 工具在 {1} 天内零调用,合计每轮请求白缴 ~{2} tok 上下文税。可到「MCP」页或在上方行内禁用对应服务器(随时可启用,禁用只作用于新请求)。": "{0} MCP tools had zero calls in {1} days, wasting ~{2} tok of context tax per request. Disable the servers on the 'MCP' page or inline above (re-enable anytime; disabling only affects new requests).",
|
|
159
|
+
// §6.1 / §6.2 / §6.4 工具裁缝「税的时间维度与归因」。
|
|
160
|
+
"近 {0} 天每轮真实下发的工具税(左旧右新)": "Real per-request tool tax over the last {0} days (older left, newer right)",
|
|
161
|
+
"每轮下发清单在 {0} 天内一次没变": "The per-request tool list did not change once in {0} days",
|
|
162
|
+
"每轮下发清单变化 {0} 次": "The per-request tool list changed {0} times",
|
|
163
|
+
"最近一次 {0} 个工具 ~{1} tok({2})": "Latest request: {0} tools, ~{1} tok ({2})",
|
|
164
|
+
"{0} 条调用事件没有可用的 time 字段,无法判定落在哪个窗口,已全部计入当前窗口(切换时间窗对这部分不生效)。": "{0} call events had no usable time field and could not be placed in any window; all were counted into the current one (switching the window does not affect them).",
|
|
165
|
+
"窗口内真实下发清单的种类数超过上限,税趋势线只覆盖前若干种。": "The window held more distinct tool lists than the cap allows; the tax trend covers only the first few.",
|
|
166
|
+
"窗口 {0} 天,但读到的调用记录只跨 {1} 天(最早一条是 {2});更早的部分本机没有日志,加长窗口不会改变任何数字。": "The window is {0} days, but the call records read span only {1} days (earliest {2}). There are no local logs before that, so widening the window cannot change any number.",
|
|
167
|
+
"注册表快照比最近一次真实下发多 ~{0} tok:多半是改完配置还没重启宿主,或项目级工具还没在当前会话注册。": "The registry snapshot is ~{0} tok larger than the latest real request: you likely edited the config without restarting the host, or project-level tools are not registered in this session yet.",
|
|
168
|
+
"最近一次真实下发比注册表快照多 ~{0} tok:当前会话的工具集与面板读到的全局注册表不一致(可能来自项目级或子 Agent 作用域)。": "The latest real request carries ~{0} tok more than the registry snapshot: this session's tool set differs from the global registry the panel reads (likely project-level or sub-agent scope).",
|
|
169
|
+
|
|
120
170
|
"{0} 个专家正挂在 {1} 个会话上": "{0} experts are attached to {1} sessions",
|
|
121
171
|
"{0} 个会话": "{0} sessions",
|
|
122
172
|
"{0} 个共用": "{0} shared",
|
|
@@ -516,7 +566,8 @@ const EN_US_MESSAGES = {
|
|
|
516
566
|
"替换当前 Profile": "Replace current profile",
|
|
517
567
|
"替换该项目": "Replace this project",
|
|
518
568
|
"最后使用": "Last used",
|
|
519
|
-
"
|
|
569
|
+
"下载量(本页排序)": "Downloads (this page)",
|
|
570
|
+
"SkillHub 已不再提供全站下载量排序;这一项只在当前页内按下载量排序。": "SkillHub no longer offers a global download ranking; this option sorts by downloads within the current page only.",
|
|
520
571
|
"最多安装": "Most installs",
|
|
521
572
|
"最多星标": "Most stars",
|
|
522
573
|
"最大尝试次数": "Max attempts",
|
|
@@ -759,9 +810,6 @@ const EN_US_MESSAGES = {
|
|
|
759
810
|
"成员由主理人用 subagent 调度;若本会话没有 subagent 工具,请新建会话并选择「WorkBuddy 专家 · {0}」预设": "Members are dispatched by the lead via subagent; if this session has no subagent tool, create a new session and pick the \"WorkBuddy Expert · {0}\" preset",
|
|
760
811
|
"今日": "Today",
|
|
761
812
|
"账本缓存": "Ledger cache",
|
|
762
|
-
"清空缓存": "Clear cache",
|
|
763
|
-
"正在清空缓存…": "Clearing cache…",
|
|
764
|
-
"已清空缓存,释放 {0}": "Cache cleared, {0} freed",
|
|
765
813
|
" 条": " items",
|
|
766
814
|
"": "",
|
|
767
815
|
"&": "&",
|
|
@@ -822,7 +870,68 @@ const EN_US_MESSAGES = {
|
|
|
822
870
|
"跳转到指定页": "Jump to a specific page",
|
|
823
871
|
"共 {0} 条": "{0} total",
|
|
824
872
|
"免认证": "No auth",
|
|
825
|
-
"
|
|
873
|
+
"宿主只提供 skill 的读取接口;这里可以卸载用户级/项目级目录里的 skill(永久删除,不可撤销)。随 DSH 分发、运行时注册、自定义目录的 skill 不可卸载——要改内容请直接编辑 SKILL.md。": "The host only exposes read endpoints for skills. Skills in user-level/project-level directories can be uninstalled here (permanent deletion, no undo). Skills shipped with DSH, registered at runtime, or living in custom directories cannot be uninstalled — edit SKILL.md directly to change them.",
|
|
874
|
+
"将从磁盘永久删除 {0}(不可撤销),确认卸载?": "This permanently deletes {0} from disk (no undo). Uninstall?",
|
|
875
|
+
"确认卸载": "Uninstall",
|
|
876
|
+
"卸载中…": "Uninstalling…",
|
|
877
|
+
"永久删除这个 skill 所在目录(不可撤销)": "Permanently delete this skill's directory (no undo)",
|
|
878
|
+
"已卸载 {0}": "Uninstalled {0}",
|
|
879
|
+
"这个 skill 来自「{0}」,不在可卸载范围内:随 DSH 分发、运行时注册与自定义目录的 skill 只能查看。要停用它,请编辑所属预设或 SKILL.md。": "This skill comes from \"{0}\" and cannot be uninstalled: skills shipped with DSH, registered at runtime, or living in custom directories are read-only. To disable it, edit its preset or SKILL.md instead.",
|
|
880
|
+
|
|
881
|
+
// 自定义套件(Skill 看板的套件管理弹窗 + 召唤菜单技能子菜单里的套件分组)。
|
|
882
|
+
// 「套件」本身、保存中…、取消、关闭、搜索 skill… 等复用既有键,不在此重复登记。
|
|
883
|
+
"套件管理": "Suite manager",
|
|
884
|
+
"把多个相关 skill 打包成套件;在输入框的引用菜单里点一次套件,等于同时引用全部成员。": "Bundle related skills into a suite; one click in the composer's reference menu inserts every member at once.",
|
|
885
|
+
"管理自定义套件": "Manage custom suites",
|
|
886
|
+
"管理自定义套件:把多个相关 skill 打包,引用一次全部生效": "Manage custom suites: bundle related skills so one reference applies them all",
|
|
887
|
+
"管理套件…": "Manage suites…",
|
|
888
|
+
"新建或修改套件(把多个相关 skill 打包,引用一次全部生效)": "Create or edit suites (bundle related skills so one reference applies them all)",
|
|
889
|
+
"引用套件:展开为 {0} 个 /name": "Reference this suite: expands into {0} /name tokens",
|
|
890
|
+
"{0} 技能": "{0} skills",
|
|
891
|
+
"我的套件": "My suites",
|
|
892
|
+
"新建套件": "New suite",
|
|
893
|
+
"新建一个空套件": "Create an empty suite",
|
|
894
|
+
"还没有套件:点右上「新建套件」开始": "No suites yet: click \"New suite\" at the top right to start",
|
|
895
|
+
"未命名套件": "Untitled suite",
|
|
896
|
+
"套件名称": "Suite name",
|
|
897
|
+
"例如:演示视频流水线": "e.g. Demo video pipeline",
|
|
898
|
+
"说明(可选,显示在引用菜单条目右侧)": "Description (optional, shown beside the entry in the reference menu)",
|
|
899
|
+
"一句话说明这套技能干什么": "One line on what this bundle of skills does",
|
|
900
|
+
"成员技能": "Member skills",
|
|
901
|
+
"{0} 个 · 拖拽可排序": "{0} · drag to reorder",
|
|
902
|
+
"把左侧卡片拖到这里": "Drag a card from the left in here",
|
|
903
|
+
"拖不动时,悬停卡片点 + 也能加进来": "If dragging is awkward, hover a card and click + to add it",
|
|
904
|
+
"选择右上列表里的一个套件,或点「新建套件」": "Select a suite from the list at the top right, or click \"New suite\"",
|
|
905
|
+
"先在右侧选择一个套件,或点「新建套件」": "Select a suite on the right first, or click \"New suite\"",
|
|
906
|
+
"套件 = 一组相关 skill 的引用打包": "A suite = one reference that bundles related skills",
|
|
907
|
+
"拖左侧卡片到成员区即可添加;成员可上下拖拽排序,+ / × 是无拖拽的替代路径。": "Drag a card from the left into the member area to add it; drag members up or down to reorder. + / × are the no-drag alternatives.",
|
|
908
|
+
"看板没有读到 skill 列表": "The board did not return a skill list",
|
|
909
|
+
"宿主未挂载 skill 注册表(@deepseek-ai/dsh-skill)时这里就是空的;套件仍能保存,但成员名要自己保证正确。": "This is empty when the host mounts no skill registry (@deepseek-ai/dsh-skill). Suites can still be saved, but you have to get the member names right yourself.",
|
|
910
|
+
"没有符合筛选的 skill": "No skill matches the filter",
|
|
911
|
+
"已在当前套件中": "Already in the current suite",
|
|
912
|
+
"已在套件中": "In suite",
|
|
913
|
+
"拖到右侧成员区,或点 + 添加": "Drag to the member area on the right, or click + to add",
|
|
914
|
+
"添加到当前套件": "Add to the current suite",
|
|
915
|
+
"已加入「{0}」": "Added \"{0}\"",
|
|
916
|
+
"「{0}」已在这个套件里": "\"{0}\" is already in this suite",
|
|
917
|
+
"成员已达上限 {0} 个": "Member limit reached ({0})",
|
|
918
|
+
"套件数量已达上限 {0} 个": "Suite limit reached ({0})",
|
|
919
|
+
"宿主当前找不到这个 skill(可能已移动或删除),引用时会失效": "The host can no longer find this skill (it may have been moved or deleted); referencing it will fail",
|
|
920
|
+
"已失效": "Missing",
|
|
921
|
+
"移除 {0}": "Remove {0}",
|
|
922
|
+
"删除套件": "Delete suite",
|
|
923
|
+
"再点一次确认删除": "Click again to confirm deletion",
|
|
924
|
+
"确认?": "Sure?",
|
|
925
|
+
"删除": "Delete",
|
|
926
|
+
"未保存": "Unsaved",
|
|
927
|
+
"整集合覆盖保存:右侧列表就是全部套件": "Saving overwrites the whole set: the list on the right is all your suites",
|
|
928
|
+
"保存套件": "Save suites",
|
|
929
|
+
"套件已保存({0} 个)": "Suites saved ({0})",
|
|
930
|
+
"保存失败:宿主没有回包": "Save failed: no response from the host",
|
|
931
|
+
"有套件还没起名": "A suite still has no name",
|
|
932
|
+
"套件「{0}」还没有成员": "Suite \"{0}\" has no members yet",
|
|
933
|
+
"已删除套件(保存后生效)": "Suite deleted (takes effect on save)",
|
|
934
|
+
"已放弃未保存的修改": "Unsaved changes discarded",
|
|
826
935
|
|
|
827
936
|
"这 {0} 个 MCP 都需要交互取值,无法直接导入": "These {0} MCP servers all require interactive input and cannot be imported directly",
|
|
828
937
|
"(另有 {0} 个需要交互取值)": " ({0} more require interactive input)",
|
|
@@ -897,9 +1006,10 @@ const EN_US_MESSAGES = {
|
|
|
897
1006
|
|
|
898
1007
|
":": ": ",
|
|
899
1008
|
};
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
|
|
1009
|
+
// 模块加载时先用 ①/③ 判定的 locale 装载一次;apply() 拿到宿主 locale 后会再按
|
|
1010
|
+
// ② 重算(见 applyI18nLocale)。zh-CN(源语言)保持空字典恒等直出,其他语言
|
|
1011
|
+
// 缺键也原样回落,不出现 key 名。
|
|
1012
|
+
applyI18nLocale(I18N_LOCALE);
|
|
903
1013
|
const t = (s, vars) => {
|
|
904
1014
|
let text = i18nMessages[s] || s;
|
|
905
1015
|
if (vars && vars.length) {
|
|
@@ -907,6 +1017,59 @@ const t = (s, vars) => {
|
|
|
907
1017
|
}
|
|
908
1018
|
return text;
|
|
909
1019
|
};
|
|
1020
|
+
// OPT-25:宿主切换语言后通知已挂载的界面重渲染。模块级文案表已改为 getter /
|
|
1021
|
+
// 函数(见 ENTRY_LABELS / SKILL_CATS 等),重渲染时就会读到新字典,不需要重载页面。
|
|
1022
|
+
// 订阅者失败隔离:一个组件抛错不影响其它组件更新。
|
|
1023
|
+
const i18nChangeListeners = new Set();
|
|
1024
|
+
const emitI18nChange = () => {
|
|
1025
|
+
for (const listener of [...i18nChangeListeners]) {
|
|
1026
|
+
try { listener(); } catch { /* 单个订阅者失败不影响其它订阅者 */ }
|
|
1027
|
+
}
|
|
1028
|
+
};
|
|
1029
|
+
const onI18nChange = (listener) => {
|
|
1030
|
+
i18nChangeListeners.add(listener);
|
|
1031
|
+
return () => { i18nChangeListeners.delete(listener); };
|
|
1032
|
+
};
|
|
1033
|
+
// 顶层槽位组件调用一次即可:locale 变化时强制自身重渲染。
|
|
1034
|
+
const useI18nRevision = () => {
|
|
1035
|
+
const [, setRevision] = useState(0);
|
|
1036
|
+
useEffect(() => onI18nChange(() => setRevision((value) => value + 1)), []);
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
// ── P1:数据型英文覆盖(更新日志 / 内置 MCP 目录)───────────────────────────
|
|
1040
|
+
// 这两类文案不是 UI 字面量,而是宿主随 RPC 送来的数据:更新日志在 whats-new.js,
|
|
1041
|
+
// 目录在生成的 mcp-catalog.js。它们不能走 t() 字典(键集合由那两个文件决定,硬抄
|
|
1042
|
+
// 进字典会让 i18n-coverage 的「死键」断言永久变红),所以由宿主同时送中文与英文
|
|
1043
|
+
// 字段,客户端按当前 locale 选择。英文缺失时逐条回落中文,绝不渲染 undefined。
|
|
1044
|
+
//
|
|
1045
|
+
// 两个 helper 的 `english` 参数只用于测试时显式指定;运行时用当前 locale 判定。
|
|
1046
|
+
const localizeWhatsNewEntries = (entries, english = usesEnglish()) => {
|
|
1047
|
+
if (!english || !Array.isArray(entries)) return entries;
|
|
1048
|
+
return entries.map((entry) => ({
|
|
1049
|
+
...entry,
|
|
1050
|
+
items: Array.isArray(entry.items)
|
|
1051
|
+
? entry.items.map((item) => ({
|
|
1052
|
+
...item,
|
|
1053
|
+
text: item.textEn || item.text,
|
|
1054
|
+
...(item.whereEn ? { where: item.whereEn } : {}),
|
|
1055
|
+
}))
|
|
1056
|
+
: [],
|
|
1057
|
+
}));
|
|
1058
|
+
};
|
|
1059
|
+
const localizeBuiltinItem = (item, english = usesEnglish()) => {
|
|
1060
|
+
if (!english || !item) return item;
|
|
1061
|
+
const authNote = item.authNoteEn || item.authNote;
|
|
1062
|
+
// 只在英文字段真的存在时才覆盖:缺译文时保留原键(连 undefined 键都不新增),
|
|
1063
|
+
// 面板据此区分「没有说明」与「说明是空的」。
|
|
1064
|
+
return {
|
|
1065
|
+
...item,
|
|
1066
|
+
...(item.labelEn ? { label: item.labelEn } : {}),
|
|
1067
|
+
...(item.summaryEn ? { summary: item.summaryEn } : {}),
|
|
1068
|
+
...(item.accessEn ? { access: item.accessEn } : {}),
|
|
1069
|
+
...(authNote ? { authNote } : {}),
|
|
1070
|
+
...(item.categoryLabelEn ? { categoryLabel: item.categoryLabelEn } : {}),
|
|
1071
|
+
};
|
|
1072
|
+
};
|
|
910
1073
|
|
|
911
1074
|
// ── 旧插件名 → 新插件名的 localStorage 键迁移 ──────────────────────────────
|
|
912
1075
|
// 1.9.0 起本插件由 dsh-skill-mcp-board 更名为 dsh-armory(package.json 与
|
|
@@ -2027,6 +2190,142 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2027
2190
|
.dsh-mcp-skill-d-close:hover{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
|
|
2028
2191
|
@media (max-width:760px){.dsh-mcp-skill-content{overflow:visible;max-height:none}.dsh-mcp-skill-toolbar{flex-wrap:wrap}.dsh-mcp-skill-search{flex:1 1 160px}.dsh-mcp-skill-detail{position:static;max-height:none;align-self:stretch}}
|
|
2029
2192
|
|
|
2193
|
+
/* ══ 自定义套件(看板工具条入口 + 召唤菜单套件分组 + 套件管理弹窗)═══════════════════
|
|
2194
|
+
度量取自 docs/design-suites-mockup.html(局部 A/B/C),类名沿用 mockup 的 suite-* 命名,
|
|
2195
|
+
只有两处刻意改名:mockup 的 .pick-card → .suite-pick-card(看板里 pick 另有含义),
|
|
2196
|
+
mockup 的 header/.x/.sub → .suite-head/.suite-x/.suite-head-text(弹窗里要能定位到类,
|
|
2197
|
+
元素选择器太宽,会把宿主注入的任何 header/h3 一起带走)。
|
|
2198
|
+
只用宿主 --dsw-* 与本插件已有的 --mcp-* 令牌,不新增自定义属性;阴影一律带回落值
|
|
2199
|
+
(--dsw-elevation-card 在本仓库从未用过,不能假定宿主一定提供)。
|
|
2200
|
+
短类名(.nm/.cat/.tile/.grip/.idx/.rm…)一律带父级前缀,避免日后与别的组件撞名。 */
|
|
2201
|
+
.dsh-mcp-btn.suite-entry{flex:none}
|
|
2202
|
+
.dsh-mcp-btn.suite-entry .cnt{padding:0 5px;border-radius:var(--mcp-r-xs);background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-brand-text);font-size:10.5px;line-height:15px;font-variant-numeric:tabular-nums}
|
|
2203
|
+
.dsh-mcp-btn.suite-entry:hover .cnt{background:var(--mcp-accent);color:var(--dsw-alias-label-primary-inverted)}
|
|
2204
|
+
/* 召唤菜单:套件分组头 + 分隔线 + 条目前的 accent 图标(只有套件条目带图标——
|
|
2205
|
+
技能/MCP/专家条目保持原样,避免一次改动三个子菜单的既有观感)。 */
|
|
2206
|
+
.dsh-mcp-quick-group{display:flex;align-items:center;gap:6px;flex:none;padding:5px 9px 3px;color:var(--dsw-alias-label-tertiary);font-size:10.5px;line-height:14px;letter-spacing:.05em}
|
|
2207
|
+
.dsh-mcp-quick-group svg{flex:none;color:var(--mcp-accent)}
|
|
2208
|
+
.dsh-mcp-quick-sep{height:1px;flex:none;margin:4px 6px;background:var(--mcp-line)}
|
|
2209
|
+
/* 条目行是 align-items:baseline(文字与徽章按基线对齐),图标不参与基线:
|
|
2210
|
+
alignSelf:center 让它在 28px 固定行高里正中,不会被 svg 的下沿顶偏。 */
|
|
2211
|
+
.dsh-mcp-quick-item .ic{flex:none;align-self:center;display:inline-flex;align-items:center;color:var(--dsw-alias-label-tertiary)}
|
|
2212
|
+
.dsh-mcp-quick-item.suite .ic{color:var(--mcp-accent)}
|
|
2213
|
+
.dsh-mcp-quick-sub .dsh-mcp-quick-manage{gap:6px}
|
|
2214
|
+
.dsh-mcp-quick-manage .ic{flex:none;display:inline-flex;align-items:center}
|
|
2215
|
+
/* 两条页脚按钮叠着时(管理技能… / 管理套件…),第二条不再自带 4px 上边距:
|
|
2216
|
+
否则分割线会浮在空隙中间,看着像渲染错位。 */
|
|
2217
|
+
.dsh-mcp-quick-manage+.dsh-mcp-quick-manage{margin-top:0}
|
|
2218
|
+
/* 弹窗本体:把基类 .dsh-mcp-modal 的 560px/20px 内边距/纵向滚动整体换掉——
|
|
2219
|
+
套件弹窗是「左右两栏各自滚动」,外层必须 overflow:hidden 才不会出现双滚动条。 */
|
|
2220
|
+
.dsh-mcp-modal.suites{display:flex;flex-direction:column;width:min(980px,94vw);max-width:94vw;height:min(640px,88vh);max-height:88vh;padding:0;overflow:hidden}
|
|
2221
|
+
.suite-head{display:flex;align-items:flex-start;gap:10px;flex:none;padding:16px 18px 12px;border-bottom:1px solid var(--mcp-line);background:var(--mcp-bg-1)}
|
|
2222
|
+
.suite-head-text{min-width:0}
|
|
2223
|
+
.suite-head h3{display:flex;align-items:center;gap:8px;margin:0;font-size:15px;font-weight:650;line-height:20px;color:var(--dsw-alias-label-primary)}
|
|
2224
|
+
.suite-head h3::before{content:"";flex:none;width:4px;height:14px;border-radius:2px;background:var(--mcp-accent)}
|
|
2225
|
+
.suite-head .sub{margin:5px 0 0 12px;font-size:11.5px;line-height:17px;color:var(--mcp-mut)}
|
|
2226
|
+
/* 关闭按钮不用 .dsh-mcp-skill-d-close:那个是 position:absolute(贴详情面板的角),
|
|
2227
|
+
放进 flex 头部会脱离文档流、盖住副标题。 */
|
|
2228
|
+
.suite-x{display:inline-flex;align-items:center;justify-content:center;flex:none;width:26px;height:26px;margin-left:auto;padding:0;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-sm);background:var(--dsw-alias-button-elevated-fill);color:var(--mcp-mut);cursor:pointer;transition:background .15s,border-color .15s,color .15s}
|
|
2229
|
+
.suite-x:hover{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}
|
|
2230
|
+
.suite-x:disabled{opacity:.5;cursor:default}
|
|
2231
|
+
.suite-x:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
|
|
2232
|
+
.suite-body{flex:1;min-height:0;display:grid;grid-template-columns:minmax(320px,44%) 1fr}
|
|
2233
|
+
/* ── 左:技能库选择器 ──────────────────────────────────────────────── */
|
|
2234
|
+
.suite-left{display:flex;flex-direction:column;gap:9px;min-height:0;padding:13px 14px;border-right:1px solid var(--mcp-line);background:var(--mcp-bg-2)}
|
|
2235
|
+
/* 搜索框不复用 .dsh-mcp-skill-search:那条在本仓库只有 flex 尺寸、没有图标定位,
|
|
2236
|
+
看板里的放大镜其实是裸排的(既有小瑕疵,不在本次范围)。这里自带一份完整的。 */
|
|
2237
|
+
.suite-search{position:relative;display:flex;align-items:center;flex:none;height:30px}
|
|
2238
|
+
.suite-search svg{position:absolute;top:50%;left:9px;transform:translateY(-50%);pointer-events:none;color:var(--mcp-mut)}
|
|
2239
|
+
.suite-search input{box-sizing:border-box;width:100%;height:30px;padding:0 10px 0 28px;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;outline:0;transition:border-color .15s,box-shadow .15s}
|
|
2240
|
+
.suite-search input::placeholder{color:var(--mcp-mut)}
|
|
2241
|
+
.suite-search input:focus{border-color:var(--mcp-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--mcp-accent) 14%,transparent)}
|
|
2242
|
+
/* 分类轨道在弹窗里更窄,chips 收一号才放得下 6 个。 */
|
|
2243
|
+
.suite-left .dsh-mcp-skill-rail{gap:5px}
|
|
2244
|
+
.suite-left .dsh-mcp-skill-cat{padding:2px 9px;font-size:11px;line-height:17px}
|
|
2245
|
+
.suite-pick-list{flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:7px;padding:2px 2px 6px;scrollbar-width:thin}
|
|
2246
|
+
.suite-pick-card{position:relative;display:flex;align-items:flex-start;gap:9px;padding:9px 26px 9px 10px;border:.5px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);cursor:grab;transition:border-color .12s,background .12s,box-shadow .14s,opacity .12s}
|
|
2247
|
+
.suite-pick-card:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover);box-shadow:var(--dsw-elevation-overlay,0 1px 3px rgba(0,0,0,.1))}
|
|
2248
|
+
.suite-pick-card:active{cursor:grabbing}
|
|
2249
|
+
.suite-pick-card.dragging{opacity:.45;border-style:dashed}
|
|
2250
|
+
.suite-pick-card.in-suite{border-color:color-mix(in srgb,var(--mcp-accent) 45%,var(--mcp-line));background:var(--dsw-alias-interactive-bg-hover-accent)}
|
|
2251
|
+
.suite-pick-card .tile{flex:none;width:10px;height:10px;margin-top:3px;border-radius:3px}
|
|
2252
|
+
.suite-pick-card .body{min-width:0;flex:1}
|
|
2253
|
+
.suite-pick-card .nm{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:550;line-height:18px;color:var(--dsw-alias-label-primary)}
|
|
2254
|
+
.suite-pick-card .nm .cat{font-size:10.5px;font-weight:400;color:var(--dsw-alias-label-tertiary)}
|
|
2255
|
+
.suite-pick-card .ds{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;margin:2px 0 0;font-size:11px;line-height:1.5;color:var(--mcp-mut)}
|
|
2256
|
+
.suite-pick-card .grip{flex:none;margin-top:2px;color:var(--dsw-alias-label-tertiary);opacity:.55}
|
|
2257
|
+
.suite-pick-card:hover .grip{opacity:1}
|
|
2258
|
+
/* + 是无拖拽的替代路径:平时藏着(hover / 键盘聚焦才现形),已在套件里则常亮成 ✓。 */
|
|
2259
|
+
.suite-pick-card .add{position:absolute;top:7px;right:7px;display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:1px solid var(--mcp-line);border-radius:7px;background:var(--mcp-bg-1);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s,background .12s,border-color .12s,color .12s}
|
|
2260
|
+
.suite-pick-card:hover .add,.suite-pick-card:focus-within .add{opacity:1}
|
|
2261
|
+
.suite-pick-card .add:hover{background:var(--mcp-accent);border-color:var(--mcp-accent);color:var(--dsw-alias-label-primary-inverted)}
|
|
2262
|
+
.suite-pick-card .add:disabled{cursor:default}
|
|
2263
|
+
.suite-pick-card.in-suite .add{opacity:1;border-color:color-mix(in srgb,var(--mcp-accent) 40%,var(--mcp-line));color:var(--mcp-accent)}
|
|
2264
|
+
/* ── 右:套件列表 + 编辑器 ─────────────────────────────────────────── */
|
|
2265
|
+
.suite-right{display:flex;flex-direction:column;min-height:0;background:var(--mcp-bg-1)}
|
|
2266
|
+
.suite-list-bar{display:flex;align-items:center;gap:8px;flex:none;padding:11px 14px 9px;border-bottom:1px solid var(--mcp-line)}
|
|
2267
|
+
.suite-list-bar .t{font-size:12px;font-weight:650;color:var(--dsw-alias-label-secondary)}
|
|
2268
|
+
.suite-list-bar .sp{flex:1}
|
|
2269
|
+
.dsh-mcp-btn.suite-new{padding:4px 10px;font-size:11.5px}
|
|
2270
|
+
.suite-list{display:flex;flex-direction:column;gap:4px;flex:none;max-height:132px;overflow-y:auto;padding:8px 10px;border-bottom:1px solid var(--mcp-line);background:var(--mcp-bg-2);scrollbar-width:thin}
|
|
2271
|
+
/* div[role=button] 而不是 <button>:行内还要放「删除」按钮,button 套 button 是无效 HTML。
|
|
2272
|
+
所以这里得把 button 的默认外观一条条抹平(font/border/background/text-align)。 */
|
|
2273
|
+
.suite-row{display:flex;align-items:center;gap:8px;width:100%;padding:7px 9px;border:1px solid transparent;border-radius:var(--mcp-r-md);background:transparent;font-family:inherit;font-size:12.5px;text-align:left;cursor:pointer;transition:background .12s,border-color .12s}
|
|
2274
|
+
.suite-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
2275
|
+
.suite-row.on{border-color:color-mix(in srgb,var(--mcp-accent) 40%,transparent);background:var(--dsw-alias-interactive-bg-hover-accent)}
|
|
2276
|
+
.suite-row:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:1px}
|
|
2277
|
+
.suite-row .ic{flex:none;display:inline-flex;align-items:center;color:var(--mcp-accent)}
|
|
2278
|
+
.suite-row .nm{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;font-weight:550;color:var(--dsw-alias-label-primary)}
|
|
2279
|
+
/* 成员色点叠成一摞:一眼看出这套件里都是哪几类技能(最多 4 个,见 slice(0,4))。 */
|
|
2280
|
+
.suite-row .stack{display:inline-flex;flex:none}
|
|
2281
|
+
.suite-row .stack i{width:9px;height:9px;margin-left:-3px;border:1.5px solid var(--mcp-bg-2);border-radius:3px}
|
|
2282
|
+
.suite-row .stack i:first-child{margin-left:0}
|
|
2283
|
+
.suite-row .ct{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
|
|
2284
|
+
/* 删除是两步确认(点一次变红写「确认?」,2.6s 内再点才真删):平时藏起来,
|
|
2285
|
+
hover 或已武装时常显;武装态用实底红,让「再点一次就没了」这件事不可能被忽略。 */
|
|
2286
|
+
.suite-row .del{display:inline-flex;align-items:center;justify-content:center;flex:none;width:22px;height:22px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);font-family:inherit;font-size:10.5px;cursor:pointer;opacity:0;transition:opacity .12s,background .12s,color .12s}
|
|
2287
|
+
.suite-row:hover .del,.suite-row .del:focus-visible,.suite-row .del.arm{opacity:1}
|
|
2288
|
+
.suite-row .del:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--mcp-err)}
|
|
2289
|
+
.suite-row .del.arm{width:auto;padding:0 7px;background:var(--mcp-err);color:var(--dsw-alias-label-primary-inverted);font-weight:600}
|
|
2290
|
+
.suite-editor{display:flex;flex-direction:column;gap:10px;flex:1;min-height:0;padding:12px 14px 14px}
|
|
2291
|
+
.suite-editor .fields{display:grid;grid-template-columns:1fr;gap:8px;flex:none}
|
|
2292
|
+
.field label{display:block;margin-bottom:4px;font-size:10.5px;letter-spacing:.03em;color:var(--dsw-alias-label-tertiary)}
|
|
2293
|
+
.field input{box-sizing:border-box;width:100%;height:32px;padding:0 10px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-sm);background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12.5px;outline:0;transition:border-color .15s,box-shadow .15s}
|
|
2294
|
+
.field input::placeholder{color:var(--mcp-mut)}
|
|
2295
|
+
.field input:focus{border-color:var(--mcp-accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--mcp-accent) 14%,transparent)}
|
|
2296
|
+
/* 成员区:虚线框表达「往这里放东西」,拖拽悬停时整块变 accent 底。 */
|
|
2297
|
+
.member-zone{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;border:1.5px dashed var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-2);transition:border-color .14s,background .14s}
|
|
2298
|
+
.member-zone.over{border-color:var(--mcp-accent);background:var(--dsw-alias-interactive-bg-hover-accent)}
|
|
2299
|
+
.member-zone .zh{display:flex;align-items:center;gap:7px;flex:none;padding:8px 11px;border-bottom:1px dashed var(--mcp-line);font-size:11px;color:var(--dsw-alias-label-tertiary)}
|
|
2300
|
+
.member-zone .zh svg{flex:none;color:var(--mcp-accent)}
|
|
2301
|
+
.member-zone .zh b{font-weight:600;color:var(--dsw-alias-label-secondary)}
|
|
2302
|
+
.member-list{display:flex;flex-direction:column;gap:5px;flex:1;min-height:0;overflow-y:auto;padding:7px;scrollbar-width:thin}
|
|
2303
|
+
.member-row{display:flex;align-items:center;gap:8px;padding:7px 9px;border:.5px solid var(--mcp-line);border-radius:var(--mcp-r-md);background:var(--mcp-bg-1);cursor:grab;transition:border-color .12s,box-shadow .12s,opacity .12s}
|
|
2304
|
+
.member-row:hover{border-color:var(--dsw-alias-border-l3);box-shadow:var(--dsw-elevation-overlay,0 1px 3px rgba(0,0,0,.1))}
|
|
2305
|
+
.member-row.dragging{opacity:.4}
|
|
2306
|
+
.member-row .idx{flex:none;width:16px;text-align:center;font-size:10px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}
|
|
2307
|
+
.member-row .grip{flex:none;display:inline-flex;align-items:center;color:var(--dsw-alias-label-tertiary);opacity:.6}
|
|
2308
|
+
.member-row .tile{flex:none;width:9px;height:9px;border-radius:3px}
|
|
2309
|
+
.member-row .nm{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:550;color:var(--dsw-alias-label-primary)}
|
|
2310
|
+
.member-row .cat{flex:none;font-size:10px;color:var(--dsw-alias-label-tertiary)}
|
|
2311
|
+
/* 宿主注册表里查不到的成员:不删(用户可能只是切了作用域),但如实标成警示色。 */
|
|
2312
|
+
.member-row .cat.gone{color:var(--mcp-warn)}
|
|
2313
|
+
.member-row .rm{display:inline-flex;align-items:center;justify-content:center;flex:none;width:20px;height:20px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;transition:background .12s,color .12s}
|
|
2314
|
+
.member-row .rm:hover{background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--mcp-err)}
|
|
2315
|
+
/* 拖拽插入指示线:普通行元素而不是独立浮层,成员区因此始终只有一个滚动容器。 */
|
|
2316
|
+
.drop-line{flex:none;height:2px;margin:1px 4px;border-radius:1px;background:var(--mcp-accent)}
|
|
2317
|
+
.member-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;flex:1;padding:22px;color:var(--dsw-alias-label-tertiary);font-size:11.5px;line-height:17px;text-align:center}
|
|
2318
|
+
.member-empty .big{display:inline-flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:14px;background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--mcp-accent)}
|
|
2319
|
+
/* ── 弹窗底栏 ─────────────────────────────────────────────────────── */
|
|
2320
|
+
.suite-foot{display:flex;align-items:center;gap:10px;flex:none;padding:11px 16px;border-top:1px solid var(--mcp-line);background:var(--mcp-bg-1)}
|
|
2321
|
+
.suite-foot .hint{display:flex;align-items:center;gap:6px;min-width:0;flex:1;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}
|
|
2322
|
+
.suite-foot .hint svg{flex:none}
|
|
2323
|
+
/* 保存失败与「未保存」互斥显示:失败是阻断信息(红),未保存只是状态提示(橙)。 */
|
|
2324
|
+
.suite-foot .err{flex:none;max-width:44%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:var(--mcp-err)}
|
|
2325
|
+
.suite-foot .dirty{display:inline-flex;align-items:center;gap:5px;flex:none;font-size:11px;color:var(--mcp-warn)}
|
|
2326
|
+
.suite-foot .dirty i{width:6px;height:6px;border-radius:50%;background:var(--mcp-warn)}
|
|
2327
|
+
@media (max-width:900px){.suite-body{grid-template-columns:1fr}.suite-left{max-height:260px;border-right:0;border-bottom:1px solid var(--mcp-line)}.suite-foot .hint{display:none}}
|
|
2328
|
+
|
|
2030
2329
|
/* 工具裁缝(Tool Tailor):账本表格 + 税单卡 + 死工具清单。 */
|
|
2031
2330
|
.dsh-mcp-tailor{display:flex;flex-direction:column;gap:12px;min-height:0}
|
|
2032
2331
|
.dsh-mcp-tailor-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px;flex:none}
|
|
@@ -2054,10 +2353,10 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
|
|
|
2054
2353
|
.dsh-mcp-tailor-empty{color:var(--mcp-mut);font-size:13px;padding:18px 14px}
|
|
2055
2354
|
.dsh-mcp-tailor-note{border:0.5px solid var(--mcp-line);border-radius:var(--mcp-r-md);corner-shape:round;background:var(--mcp-bg-2);padding:10px 14px;font-size:12.5px;line-height:19px;color:var(--mcp-mut);flex:none}
|
|
2056
2355
|
.dsh-mcp-tailor-note b{color:var(--dsw-alias-label-primary)}
|
|
2057
|
-
/*
|
|
2356
|
+
/* 账本缓存占用行(只读读数,贴在工具条下方)。行内清空按钮已于 2026-10-23 删除,见
|
|
2357
|
+
lib/client.js 里 cacheLine 的注释;这条只剩文本,所以配套的 .cache-clear 规则一并删掉。 */
|
|
2058
2358
|
.dsh-mcp-tailor-cache{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11.5px;line-height:16px;color:var(--mcp-mut);padding:2px 2px 0;flex:none}
|
|
2059
2359
|
.dsh-mcp-tailor-cache-label{color:var(--dsw-alias-label-tertiary)}
|
|
2060
|
-
.dsh-mcp-tailor-cache .dsh-mcp-tailor-cache-clear{height:22px;padding:0 10px;font-size:11px;line-height:20px;flex:none}
|
|
2061
2360
|
/* 解码告警(OPT-02):正常运行时整块不渲染,所以这里可以给足视觉权重。 */
|
|
2062
2361
|
.dsh-mcp-tailor-warn{border:0.5px solid var(--mcp-warn);border-radius:var(--mcp-r-md);corner-shape:round;background:color-mix(in srgb,var(--mcp-warn) 8%,transparent);padding:10px 14px;font-size:12.5px;line-height:19px;color:var(--dsw-alias-label-primary);flex:none;display:flex;flex-direction:column;gap:4px}
|
|
2063
2362
|
.dsh-mcp-tailor-warn b{color:var(--mcp-warn)}
|
|
@@ -2455,10 +2754,16 @@ const useFocusTrap = (ref, active) => {
|
|
|
2455
2754
|
|
|
2456
2755
|
const MAX_TIMER_DELAY_MS = 2147483647;
|
|
2457
2756
|
const REDACTED_VALUE = '__DSH_MCP_REDACTED__';
|
|
2458
|
-
|
|
2757
|
+
// 屏蔽值在表单里需要**稳定哨兵**:locale 切换后译文会变,但已填进 state 的旧
|
|
2758
|
+
// 字符串不能随译文一起变,否则 toFormValue/fromFormValue 的相等判定会失配。
|
|
2759
|
+
// 因此内部只存 ASCII 哨兵,展示层再映射成当前语言的文案。
|
|
2760
|
+
const PRESERVED_VALUE_TOKEN = '__DSH_MCP_PRESERVED__';
|
|
2761
|
+
const preservedValueLabel = () => t('••••••(保留原值)');
|
|
2459
2762
|
const MASKED_VALUE = '••••••';
|
|
2460
|
-
const toFormValue = (value) => value === REDACTED_VALUE ?
|
|
2461
|
-
const fromFormValue = (value) => value ===
|
|
2763
|
+
const toFormValue = (value) => value === REDACTED_VALUE ? PRESERVED_VALUE_TOKEN : String(value || '');
|
|
2764
|
+
const fromFormValue = (value) => value === PRESERVED_VALUE_TOKEN ? REDACTED_VALUE : value;
|
|
2765
|
+
// args 是「每行一个」的多行串,哨兵可能夹在中间,所以按 token 全量替换而不是只比整串。
|
|
2766
|
+
const displayFormValue = (value) => String(value ?? '').split(PRESERVED_VALUE_TOKEN).join(preservedValueLabel());
|
|
2462
2767
|
const displayValue = (value) => value === REDACTED_VALUE ? MASKED_VALUE : value;
|
|
2463
2768
|
function setOwn(target, key, value) {
|
|
2464
2769
|
Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true });
|
|
@@ -2515,6 +2820,11 @@ const ICONS = {
|
|
|
2515
2820
|
// grid:网格视图(2×2 方块);list:列表视图(三行点线),Skill 看板视图切换用
|
|
2516
2821
|
grid: 'M2.5 2.5h4.5v4.5H2.5zM9 2.5h4.5v4.5H9zM2.5 9h4.5v4.5H2.5zM9 9h4.5v4.5H9z',
|
|
2517
2822
|
list: 'M2 3.5h2M6.5 3.5h7.5M2 8h2M6.5 8h7.5M2 12.5h2M6.5 12.5h7.5',
|
|
2823
|
+
// layers:三层堆叠 = 自定义套件(看板入口按钮、召唤菜单套件分组、弹窗空态共用);
|
|
2824
|
+
// grip:六点拖拽手柄,套件弹窗里「这张卡 / 这一行可以拖」的可见线索(16×16 网格,
|
|
2825
|
+
// 用 .2 长的短线段而不是零长路径——零长路径在部分渲染器里画不出圆点)。
|
|
2826
|
+
layers: 'M8 2l6 3.3-6 3.4-6-3.4zM2.4 8.4L8 11.5l5.6-3.1M2.4 11.1L8 14.2l5.6-3.1',
|
|
2827
|
+
grip: 'M6 3.4v.2M10 3.4v.2M6 7.9v.2M10 7.9v.2M6 12.4v.2M10 12.4v.2',
|
|
2518
2828
|
};
|
|
2519
2829
|
|
|
2520
2830
|
// ── 常用 MCP 预设模板(主流管理器常见的“浏览即添加”能力)────────────
|
|
@@ -3031,8 +3341,9 @@ const Pager = ({ page, pageCount, total, onPage, disabled }) => {
|
|
|
3031
3341
|
// oauth-or-pat)+ 有则挂 authNote。**这与市场连接器 tab 的 authMode 不是一回事**:
|
|
3032
3342
|
// authMode 是上游 connectors 清单里的自由文本,只能原样显示;auth 是枚举,可以映射
|
|
3033
3343
|
// 成人能读的标签。未知枚举值原样显示,绝不猜。
|
|
3034
|
-
const AUTH_KIND_LABELS = {
|
|
3035
|
-
|
|
3344
|
+
const AUTH_KIND_LABELS = { apiKey: 'API Key', oauth: 'OAuth', 'oauth-or-pat': 'OAuth / PAT' };
|
|
3345
|
+
// 惰性求值:模块加载时宿主 locale 还没接进来,只有 none 需要翻译。
|
|
3346
|
+
const authKindLabel = (kind) => (kind === 'none' ? t('免认证') : AUTH_KIND_LABELS[kind] || kind);
|
|
3036
3347
|
// 色语沿用 §1.3:oauth 系装完还得去浏览器授权,属「还需注意」→ 橙;apiKey 有既有
|
|
3037
3348
|
// 「需密钥」徽章兜住,这里只作信息性标注,用默认灰。
|
|
3038
3349
|
const authBadgeCls = (kind) => (kind === 'oauth' || kind === 'oauth-or-pat' ? 's-warn' : '');
|
|
@@ -3567,7 +3878,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
|
|
|
3567
3878
|
const kv = (rows, setRows, i, field, v) => {
|
|
3568
3879
|
const next = rows.slice();
|
|
3569
3880
|
const row = { ...next[i], [field]: v };
|
|
3570
|
-
if (field === 'k' && row.v ===
|
|
3881
|
+
if (field === 'k' && row.v === PRESERVED_VALUE_TOKEN && v !== row.originalKey) row.v = '';
|
|
3571
3882
|
next[i] = row;
|
|
3572
3883
|
setRows(next);
|
|
3573
3884
|
};
|
|
@@ -3579,7 +3890,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
|
|
|
3579
3890
|
: value && typeof value === 'object'
|
|
3580
3891
|
? Object.entries(value).map(([key, item]) => `${key}=${typeof item === 'object' ? JSON.stringify(item) : String(item)}`).join('\n')
|
|
3581
3892
|
: String(value ?? '');
|
|
3582
|
-
const visibleEditValue = (field, key, fallback) => Object.hasOwn(editRevealed, editRevealId(field, key)) ? editRevealed[editRevealId(field, key)] : fallback;
|
|
3893
|
+
const visibleEditValue = (field, key, fallback) => Object.hasOwn(editRevealed, editRevealId(field, key)) ? editRevealed[editRevealId(field, key)] : displayFormValue(fallback);
|
|
3583
3894
|
const hideEditReveal = (field, key) => setEditRevealed((prev) => {
|
|
3584
3895
|
const next = { ...prev };
|
|
3585
3896
|
delete next[editRevealId(field, key)];
|
|
@@ -3671,7 +3982,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
|
|
|
3671
3982
|
h('label', null, 'URL'),
|
|
3672
3983
|
h('div', { className: 'dsh-mcp-input-action' },
|
|
3673
3984
|
h('input', { value: visibleEditValue('url', undefined, url), onChange: (e) => { hideEditReveal('url'); setUrl(e.target.value); }, placeholder: 'https://example.com/mcp' }),
|
|
3674
|
-
editing && url ===
|
|
3985
|
+
editing && url === PRESERVED_VALUE_TOKEN ? revealIcon('url', 'URL', () => toggleEditReveal('url')) : null,
|
|
3675
3986
|
),
|
|
3676
3987
|
)
|
|
3677
3988
|
: h('div', null,
|
|
@@ -3680,7 +3991,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
|
|
|
3680
3991
|
h('label', null, t('参数(每行一个,原样传给进程)')),
|
|
3681
3992
|
h('div', { className: 'dsh-mcp-input-action' },
|
|
3682
3993
|
h('textarea', { rows: 4, value: visibleEditValue('args', undefined, args), onChange: (e) => { hideEditReveal('args'); setArgs(e.target.value); }, placeholder: '-y\nmcp-server@latest' }),
|
|
3683
|
-
editing && args ===
|
|
3994
|
+
editing && args === PRESERVED_VALUE_TOKEN ? revealIcon('args', t('参数'), () => toggleEditReveal('args')) : null,
|
|
3684
3995
|
),
|
|
3685
3996
|
),
|
|
3686
3997
|
h('div', { className: 'dsh-mcp-field' }, h('label', null, t('工作目录 cwd(可选)')), h('input', { value: cwd, onChange: (e) => setCwd(e.target.value), placeholder: 'C:\\path\\to\\workspace' })),
|
|
@@ -3695,7 +4006,7 @@ const AddEditModal = ({ open, onClose, onSubmit, busy, initial, onRevealValue, r
|
|
|
3695
4006
|
h('input', { placeholder: transport === 'http' ? 'Authorization' : 'API_KEY', value: row.k, onChange: (e) => { hideEditReveal(field, originalKey); kv(kvRows, setKvRows, i, 'k', e.target.value); } }),
|
|
3696
4007
|
h('input', { placeholder: transport === 'http' ? t('Bearer … 或 !!js process.env.KEY') : 'value', value: visibleEditValue(field, originalKey, row.v), onChange: (e) => { hideEditReveal(field, originalKey); kv(kvRows, setKvRows, i, 'v', e.target.value); } }),
|
|
3697
4008
|
h('div', { className: 'dsh-mcp-kv-row-actions' },
|
|
3698
|
-
editing && row.v ===
|
|
4009
|
+
editing && row.v === PRESERVED_VALUE_TOKEN ? revealIcon(id, row.k, () => toggleEditReveal(field, originalKey)) : null,
|
|
3699
4010
|
h('button', { type: 'button', className: 'dsh-mcp-kv-del', onClick: () => delRow(kvRows, setKvRows, i), title: t('删除此项'), 'aria-label': t('删除 ') + (row.k || t('配置项')) }, '✕'),
|
|
3700
4011
|
),
|
|
3701
4012
|
);
|
|
@@ -4658,12 +4969,14 @@ const ENTRY_VISIBILITY = {
|
|
|
4658
4969
|
sidebar: { panel: false, fab: false, sidebar: true },
|
|
4659
4970
|
all: { panel: true, fab: true, sidebar: true },
|
|
4660
4971
|
};
|
|
4972
|
+
// getter 而不是模块加载时求值:apply() 拿到宿主 locale 后文案才正确,
|
|
4973
|
+
// 且切语言后重渲染能直接读到新译文。
|
|
4661
4974
|
const ENTRY_LABELS = {
|
|
4662
|
-
both
|
|
4663
|
-
panel
|
|
4664
|
-
fab
|
|
4665
|
-
sidebar
|
|
4666
|
-
all
|
|
4975
|
+
get both() { return t('全局面板 + 悬浮按钮(推荐)'); },
|
|
4976
|
+
get panel() { return t('仅全局面板(左上角,"插件"旁)'); },
|
|
4977
|
+
get fab() { return t('仅悬浮按钮'); },
|
|
4978
|
+
get sidebar() { return t('仅侧栏底部入口'); },
|
|
4979
|
+
get all() { return t('三个入口全开'); },
|
|
4667
4980
|
};
|
|
4668
4981
|
const ENTRY_DEFAULT = 'both';
|
|
4669
4982
|
const ENTRY_MODES = Object.keys(ENTRY_VISIBILITY);
|
|
@@ -4735,49 +5048,57 @@ const PANEL_ID = 'mcp-manager';
|
|
|
4735
5048
|
// 入口排序:插件管理器用 0,定时任务用 10;取 5 让本入口正好落在「插件」旁边。
|
|
4736
5049
|
const PANEL_ORDER = 5;
|
|
4737
5050
|
// 看板五分类(MCP / Skill / 专家 / 工具裁缝 / 市场)随名称一并示人。
|
|
4738
|
-
|
|
4739
|
-
|
|
5051
|
+
// 函数而非常量:槽位注册发生在 apply() 里,那时才拿到宿主 locale。
|
|
5052
|
+
const panelLabel = () => t('技能 · MCP · 专家');
|
|
5053
|
+
// 左上角侧栏入口(sidebar.panellist)单独命名,与 panelLabel() 解耦:
|
|
4740
5054
|
// 该行的 label 同时是可见文字、无障碍名称和折叠提示,改动不牵动输入框快捷菜单、
|
|
4741
5055
|
// 会话头专家徽章、悬浮按钮和侧栏底部入口的文案。
|
|
4742
|
-
const
|
|
5056
|
+
const sidebarEntryLabel = () => t('大肥鱼军械库');
|
|
4743
5057
|
|
|
4744
5058
|
// skill 的 source 取值域来自官方 dsh-skill-filesystem 的 roots()(rank 顺序即优先级),
|
|
4745
5059
|
// 与 skill-registry 的 runtime。这里只做展示文案,不参与判定。
|
|
5060
|
+
// getter 保证每次读取都按当前 locale 翻译(切语言后重渲染即生效)。
|
|
4746
5061
|
const SKILL_SOURCE_LABELS = {
|
|
4747
|
-
'project-dsh'
|
|
4748
|
-
'project-agents'
|
|
4749
|
-
custom
|
|
4750
|
-
'user-dsh'
|
|
4751
|
-
'user-agents'
|
|
4752
|
-
bundled
|
|
4753
|
-
runtime
|
|
5062
|
+
get 'project-dsh'() { return t('项目 .dsh/skills'); },
|
|
5063
|
+
get 'project-agents'() { return t('项目 .agents/skills'); },
|
|
5064
|
+
get custom() { return t('自定义目录'); },
|
|
5065
|
+
get 'user-dsh'() { return t('用户 ~/.dsh/skills'); },
|
|
5066
|
+
get 'user-agents'() { return t('用户 ~/.agents/skills'); },
|
|
5067
|
+
get bundled() { return t('随 DSH 分发'); },
|
|
5068
|
+
get runtime() { return t('运行时注册'); },
|
|
4754
5069
|
};
|
|
4755
5070
|
const skillSourceLabel = (source) => SKILL_SOURCE_LABELS[source] || (source || t('未知来源'));
|
|
4756
5071
|
|
|
5072
|
+
// 可卸载来源白名单:与宿主 lib/skill-remove.js 的 REMOVABLE_SKILL_SOURCES 一一对应,
|
|
5073
|
+
// 改一处必须改两处。bundled / runtime / custom 不在这里——前三者的去处不可信或不该由
|
|
5074
|
+
// 面板删(宿主侧仍会二次校验,客户端判定只决定「按不按钮」)。
|
|
5075
|
+
const SKILL_REMOVABLE_SOURCES = new Set(['user-agents', 'user-dsh', 'project-agents', 'project-dsh']);
|
|
5076
|
+
const skillRemovable = (source) => SKILL_REMOVABLE_SOURCES.has(String(source || ''));
|
|
5077
|
+
|
|
4757
5078
|
// ── Skill 分类升级(决策 6/10):6 类 + 未分类兜底,两段式强/弱特征词匹配 ─────────
|
|
4758
5079
|
// 为什么两段式:中英混排描述里 code/image/frontend 这类词跨类污染严重,单层表会把
|
|
4759
5080
|
// imagegen-frontend-* 误判成「编程开发」。先按 SKILL_CAT_MATCH_ORDER 整体扫强特征词
|
|
4760
5081
|
// (跨类污染最小者优先),未命中再扫弱特征词,仍未命中落「未分类」——保证不丢条目。
|
|
4761
5082
|
// 匹配对象 = name + ' ' + description 的小写串;首个命中即定类。关键词表允许实现方微调。
|
|
4762
5083
|
const SKILL_CATS = [
|
|
4763
|
-
{ key: 'all', name
|
|
4764
|
-
{ key: 'dev', name
|
|
5084
|
+
{ key: 'all', get name() { return t('全部'); }, color: 'var(--mcp-cat-all)' },
|
|
5085
|
+
{ key: 'dev', get name() { return t('编程开发'); }, color: 'var(--mcp-cat-dev)',
|
|
4765
5086
|
strong: ['skill p8', 'arkts', 'arkui', '微信小程序', '小程序', '后端', '数据库', 'mysql', 'mariadb', 'api 设计', 'api设计', 'bug 诊断', 'bug诊断', '代码审查', '代码生成', '代码可读', '重构', '测试用例', '全栈', 'karpathy', 'spec 驱动', 'image-to-code', 'mcp server', 'sdk', 'monorepo', '源码'],
|
|
4766
5087
|
weak: ['code', 'coding', 'developer', 'typescript', 'javascript', 'python', 'rust', 'sql', '编程', '开发'] },
|
|
4767
|
-
{ key: 'design', name
|
|
5088
|
+
{ key: 'design', get name() { return t('设计与创作'); }, color: 'var(--mcp-cat-design)',
|
|
4768
5089
|
strong: ['imagegen', '品牌', 'brandkit', 'logo', '海报', 'stitch', 'mockup', '图标', '插画', '视频生成', '视频编辑', '视频翻译', '素材分析', '内容生产', '内容合成', '设计稿', '设计系统', 'landing page', '配图', '设计'],
|
|
4769
5090
|
weak: ['design', '创作', 'image', 'video', '视觉', '前端', 'frontend', '页面'] },
|
|
4770
|
-
{ key: 'office', name
|
|
5091
|
+
{ key: 'office', get name() { return t('文档办公'); }, color: 'var(--mcp-cat-office)',
|
|
4771
5092
|
strong: ['docx', 'xlsx', 'pptx', 'pdf', 'word 文档', 'excel', 'powerpoint', '文档', '公文', '报告', '表格', '幻灯', '公式缓存', 'libreoffice'],
|
|
4772
5093
|
weak: ['office', '写作', 'markdown', '笔记'] },
|
|
4773
|
-
{ key: 'auto', name
|
|
5094
|
+
{ key: 'auto', get name() { return t('效率与自动化'); }, color: 'var(--mcp-cat-auto)',
|
|
4774
5095
|
strong: ['browser', '浏览器', '定时', 'schedule', 'workflow', '工作流', '自动化', '批量', '表单填写', '数据提取', '提醒'],
|
|
4775
5096
|
weak: ['自动', '效率', '抓取', '搜索'] },
|
|
4776
|
-
{ key: 'ops', name
|
|
5097
|
+
{ key: 'ops', get name() { return t('开发运维'); }, color: 'var(--mcp-cat-ops)',
|
|
4777
5098
|
strong: ['sandbox', '沙箱', 'acl', '运维', '部署', 'deploy', 'release', '发布', '排查', '诊断', '环境', '崩溃', '代理下载', '签名', '供应链'],
|
|
4778
5099
|
weak: ['debug', 'windows', '安装', '升级', '更新'] },
|
|
4779
5100
|
];
|
|
4780
|
-
const SKILL_UNCAT = { key: 'uncat', name
|
|
5101
|
+
const SKILL_UNCAT = { key: 'uncat', get name() { return t('未分类'); }, color: 'var(--mcp-cat-uncat)' };
|
|
4781
5102
|
// skillcat hash 值合法性校验用。
|
|
4782
5103
|
const SKILL_CAT_KEYS = new Set(SKILL_CATS.map((c) => c.key));
|
|
4783
5104
|
const skillCatByKey = (key) => SKILL_CATS.find((c) => c.key === key) || null;
|
|
@@ -4809,10 +5130,10 @@ const skillScopeArg = (scope) => (scope === 'global' ? {} : { cwd: scope });
|
|
|
4809
5130
|
// OPT-16 · 3.2:专家详情弹层 persona 预览截断阈值(展开全文后渲染完整原文)。
|
|
4810
5131
|
const PERSONA_PREVIEW_MAX = 4000;
|
|
4811
5132
|
const TAILOR_WINDOWS = [
|
|
4812
|
-
{ key: '1', label
|
|
4813
|
-
{ key: '7', label
|
|
4814
|
-
{ key: '30', label
|
|
4815
|
-
{ key: '90', label
|
|
5133
|
+
{ key: '1', get label() { return t('今日'); }, days: 1 }, // OPT-24b · 4.1:今日档,回答「刚配置完它到底被调了几次」
|
|
5134
|
+
{ key: '7', get label() { return t('近 7 天'); }, days: 7 },
|
|
5135
|
+
{ key: '30', get label() { return t('近 30 天'); }, days: 30 },
|
|
5136
|
+
{ key: '90', get label() { return t('近 90 天'); }, days: 90 },
|
|
4816
5137
|
];
|
|
4817
5138
|
const fmtInt = (n) => (typeof n === 'number' ? n.toLocaleString(I18N_LOCALE || 'zh-CN') : '—'); // OPT-14 · 1.2:跟随 locale 回落
|
|
4818
5139
|
// OPT-18 · 4.2 零值显性化:zstd 不可用时所有调用计数恒为 0(解不出帧),这个 0 是「测不到」
|
|
@@ -4862,8 +5183,6 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
4862
5183
|
// 零调用的候选项。旧版默认打开「只看零调用」,屏幕上必然整列 0,看上去像「检测
|
|
4863
5184
|
// 全坏」——那是筛选口径而不是数据问题。默认关掉,筛选交给用户显式开启。
|
|
4864
5185
|
const [onlyDead, setOnlyDead] = useState(false);
|
|
4865
|
-
// OPT-19 · 4.3:清空缓存是独立动作,busy 态与全局扫描分开,避免清空期间误开「重扫」。
|
|
4866
|
-
const [clearing, setClearing] = useState(false);
|
|
4867
5186
|
const flashCancel = useRef(null);
|
|
4868
5187
|
const days = Number(windowKey) || 30;
|
|
4869
5188
|
// 父层先把 30 天窗口扫好了(角标要用)。窗口一致且已有报告时,首次挂载**不必**再扫;
|
|
@@ -4929,43 +5248,23 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
4929
5248
|
}
|
|
4930
5249
|
};
|
|
4931
5250
|
|
|
4932
|
-
//
|
|
4933
|
-
//
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
commitReport(res);
|
|
4943
|
-
setActionError('');
|
|
4944
|
-
flash(freed !== null ? t('已清空缓存,释放 {0}', [fmtBytes(freed)]) : t('正在清空缓存…'));
|
|
4945
|
-
} else {
|
|
4946
|
-
setActionError((res && res.error) || t('toolTailor 调用失败'));
|
|
4947
|
-
}
|
|
4948
|
-
} catch (e) {
|
|
4949
|
-
setActionError(String((e && e.message) || e));
|
|
4950
|
-
} finally {
|
|
4951
|
-
setClearing(false);
|
|
4952
|
-
}
|
|
4953
|
-
};
|
|
4954
|
-
|
|
4955
|
-
// OPT-19:只有报告带了 textCache 快照(Wave 1-A 起宿主才有)才渲染整行;旧报告整行缺省。
|
|
5251
|
+
// 解压文本缓存只留读数,「清空缓存」按钮与它的整条动作链已删除(2026-10-23 实测):
|
|
5252
|
+
// · 反馈永远错:宿主 toolTailor 分支把 clearTextCache() 的返回值丢了,报告里的
|
|
5253
|
+
// textCache 来自 textCacheStats(),根本没有 clearedBytes 这个键 → 面板成功分支只能落到
|
|
5254
|
+
// 兜底文案,点完之后浮层恒显示「正在清空缓存…」,看着像还在忙;
|
|
5255
|
+
// · 占用不会降:那是「先清后扫」,快照取在扫描**之后**,同一发调用里 92 个日志重新解压
|
|
5256
|
+
// 回填,实测按下去前后都是 32 条 / 65.8 MB;
|
|
5257
|
+
// · 而且这台机器上缓存本来就没在起作用:槽位 32、日志 92 个,淘汰按插入序而每轮按同序
|
|
5258
|
+
// 遍历全量,命中率 = max(0, 2×槽位 − 文件数) ÷ 文件数 = 0%(冷扫 1797ms / 热扫 1881ms)。
|
|
5259
|
+
// 结论是这个动作既不回收内存也不修任何东西,删掉。合成分 utilization(取字节与条数占比的
|
|
5260
|
+
// 较大者,本机恒 100%)一并从这行去掉 —— 它原本就是用来把用户推向这个已经不存在的按钮。
|
|
4956
5261
|
const cacheLine = report && report.textCache && typeof report.textCache.entries === 'number'
|
|
4957
5262
|
? (() => {
|
|
4958
5263
|
const cache = report.textCache;
|
|
4959
5264
|
const fmtI = (n) => (typeof n === 'number' ? n.toLocaleString(I18N_LOCALE || 'zh-CN') : '—');
|
|
4960
5265
|
return h('div', { className: 'dsh-mcp-tailor-cache' },
|
|
4961
5266
|
h('span', { className: 'dsh-mcp-tailor-cache-label' }, t('账本缓存')),
|
|
4962
|
-
h('span', null, fmtBytes(cache.bytes) + ' · ' + fmtI(cache.entries) + '/' + fmtI(cache.maxEntries) + t(' 条')
|
|
4963
|
-
h('button', {
|
|
4964
|
-
type: 'button',
|
|
4965
|
-
className: 'dsh-mcp-btn dsh-mcp-tailor-cache-clear',
|
|
4966
|
-
onClick: clearCache,
|
|
4967
|
-
disabled: clearing || busy,
|
|
4968
|
-
}, clearing ? t('正在清空缓存…') : t('清空缓存')),
|
|
5267
|
+
h('span', null, fmtBytes(cache.bytes) + ' · ' + fmtI(cache.entries) + '/' + fmtI(cache.maxEntries) + t(' 条')),
|
|
4969
5268
|
);
|
|
4970
5269
|
})()
|
|
4971
5270
|
: null;
|
|
@@ -4995,13 +5294,24 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
4995
5294
|
? t('{0} 个日志帧解码失败,调用计数可能偏低', [tailorNum(report.failedFrames, report)])
|
|
4996
5295
|
: '';
|
|
4997
5296
|
const scanFailures = report && Array.isArray(report.failedFileSamples) ? report.failedFileSamples : [];
|
|
5297
|
+
// §6.2 无 time 的调用事件:账本对这类事件的口径是「计进当前窗口」(见 tool-ledger.js 头注)。
|
|
5298
|
+
// 现在恒为 0,所以这行**不渲染**;等宿主日志格式一改、它变成非 0,界面必须当场说清楚
|
|
5299
|
+
// 「切窗口对这部分不生效」,否则症状又会退化成这次这种「数字看着不动但没人知道为什么」。
|
|
5300
|
+
const undatedWarning = report && report.undatedCalls > 0
|
|
5301
|
+
? t('{0} 条调用事件没有可用的 time 字段,无法判定落在哪个窗口,已全部计入当前窗口(切换时间窗对这部分不生效)。', [fmtInt(report.undatedCalls)])
|
|
5302
|
+
: '';
|
|
5303
|
+
const truncatedWarning = report && report.wireTaxTruncated === true
|
|
5304
|
+
? t('窗口内真实下发清单的种类数超过上限,税趋势线只覆盖前若干种。')
|
|
5305
|
+
: '';
|
|
4998
5306
|
// 正常运行时这一段**完全不渲染**(无噪音),只有真出问题才占位。
|
|
4999
|
-
const decodeIssue = decodeWarning || scanFailures.length
|
|
5307
|
+
const decodeIssue = decodeWarning || scanFailures.length || undatedWarning || truncatedWarning
|
|
5000
5308
|
? h('div', { className: 'dsh-mcp-tailor-warn' },
|
|
5001
5309
|
decodeWarning ? h('div', null, h('b', null, t('⚠ 数据可能不完整:')), decodeWarning) : null,
|
|
5002
5310
|
report && report.zstdSupported === false
|
|
5003
5311
|
? h('div', null, t('当前运行在 Node {0},请升级到 22.15 以上后重启宿主。', [report.nodeVersion || t('(未知版本)')]))
|
|
5004
5312
|
: null,
|
|
5313
|
+
undatedWarning ? h('div', null, undatedWarning) : null,
|
|
5314
|
+
truncatedWarning ? h('div', null, truncatedWarning) : null,
|
|
5005
5315
|
scanFailures.length
|
|
5006
5316
|
? h('details', { className: 'dsh-mcp-tailor-details' },
|
|
5007
5317
|
h('summary', null, t('{0} 个日志文件未能读取(含超限跳过),展开看原因', [tailorNum(report.failedFiles, report)])),
|
|
@@ -5011,6 +5321,31 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5011
5321
|
)
|
|
5012
5322
|
: null;
|
|
5013
5323
|
|
|
5324
|
+
// §6.4 两条「为什么这个数字不动」的归因,合成一条 note(无话可说时整块不渲染):
|
|
5325
|
+
// ① 窗口比本机日志还长 → 加长窗口必然无变化(实测这台机器只有 14 天日志,30 天与 90 天
|
|
5326
|
+
// 输出一字不差,而界面此前对此毫无提示);
|
|
5327
|
+
// ② 注册表快照与最近一次真实下发差得多 → 改了配置没生效 / 工具挂在别的作用域里读不到。
|
|
5328
|
+
// 阈值取「>500 tok 且 >5%」:Schema 长度的口径差有几十 tok 的正常噪声,压在阈值里就不会
|
|
5329
|
+
// 变成一条常驻假告警(假告警会把真告警教成背景噪音)。
|
|
5330
|
+
const attribution = (() => {
|
|
5331
|
+
if (!report) return null;
|
|
5332
|
+
const lines = [];
|
|
5333
|
+
if (typeof report.scannedSpanDays === 'number' && report.scannedSpanDays > 0 && report.windowDays > report.scannedSpanDays) {
|
|
5334
|
+
lines.push(t('窗口 {0} 天,但读到的调用记录只跨 {1} 天(最早一条是 {2});更早的部分本机没有日志,加长窗口不会改变任何数字。', [
|
|
5335
|
+
fmtInt(report.windowDays), fmtInt(report.scannedSpanDays), fmtLast(report.scannedEventsOldestMs, report.generatedAt),
|
|
5336
|
+
]));
|
|
5337
|
+
}
|
|
5338
|
+
const divergence = report.wireTaxDivergenceTokens;
|
|
5339
|
+
if (typeof divergence === 'number' && report.taxTotalTokens > 0 && Math.abs(divergence) > Math.max(500, report.taxTotalTokens * 0.05)) {
|
|
5340
|
+
lines.push(divergence > 0
|
|
5341
|
+
? t('注册表快照比最近一次真实下发多 ~{0} tok:多半是改完配置还没重启宿主,或项目级工具还没在当前会话注册。', [fmtInt(divergence)])
|
|
5342
|
+
: t('最近一次真实下发比注册表快照多 ~{0} tok:当前会话的工具集与面板读到的全局注册表不一致(可能来自项目级或子 Agent 作用域)。', [fmtInt(-divergence)]));
|
|
5343
|
+
}
|
|
5344
|
+
if (!lines.length) return null;
|
|
5345
|
+
return h('div', { className: 'dsh-mcp-tailor-note' }, lines.map((line, i) => h('div', { key: i }, line)));
|
|
5346
|
+
})();
|
|
5347
|
+
|
|
5348
|
+
|
|
5014
5349
|
// §5.2 头部指标分层:原来四张同权重卡把「上下文税」和「调用总数」摆成一样大,
|
|
5015
5350
|
// 于是用户读完四个数字仍然不知道最该动什么。真正需要行动的是「每轮白缴多少税」,
|
|
5016
5351
|
// 它才是本页存在的理由;调用数只是背景。因此税单独放大居首,其余三个降为次级 KPI 行。
|
|
@@ -5018,6 +5353,15 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5018
5353
|
// 画一排等高灰条假装有数据,比不画更误导。
|
|
5019
5354
|
const daily = report && Array.isArray(report.dailyCalls) ? report.dailyCalls : [];
|
|
5020
5355
|
const hasTrend = daily.length > 0 && daily.some((v) => v > 0);
|
|
5356
|
+
// §6.1 真实下发税。上面那个主数字(上下文税)是「当前注册表 Schema 长度」的纯函数,与时间
|
|
5357
|
+
// 无关 —— 于是「连着几天一模一样」既可能是真的没改过,也可能是界面读不出变化,两者在屏幕上
|
|
5358
|
+
// 长得完全一样(这正是本次排查的起因)。日志里每次请求都落了一份真实下发的工具清单,按天
|
|
5359
|
+
// 采样后税值第一次有了时间形状:变化 0 次 = 数字不动是事实,不是裁缝坏了。
|
|
5360
|
+
const wireDaily = report && Array.isArray(report.wireTaxDaily) ? report.wireTaxDaily : [];
|
|
5361
|
+
const hasWireTax = wireDaily.length > 0 && wireDaily.some((v) => v > 0);
|
|
5362
|
+
const wireLatest = report ? report.wireTaxLatest : null;
|
|
5363
|
+
const wireChanges = report && typeof report.wireTaxChangeCount === 'number' ? report.wireTaxChangeCount : null;
|
|
5364
|
+
|
|
5021
5365
|
const errRate = report && report.totalCalls > 0 ? report.totalErrors / report.totalCalls : 0;
|
|
5022
5366
|
// 错误率阈值按文档口径:<5% 是常态(绿),≥5% 才值得红着显示。
|
|
5023
5367
|
const errTone = errRate >= 0.05 ? 'err' : 'ok';
|
|
@@ -5035,6 +5379,14 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5035
5379
|
: t('扫描后给出'),
|
|
5036
5380
|
),
|
|
5037
5381
|
hasTrend ? h('div', { className: 'dsh-mcp-tailor-trend' }, h(Spark, { series: daily, title: t('近 {0} 天每日调用量(左旧右新)', [days]) }), h('span', null, t('近 {0} 天每日调用', [days]))) : null,
|
|
5382
|
+
// §6.1 第二行趋势:真实下发税 + 清单变化次数 + 最近一次下发的规模。三件事合成一行,
|
|
5383
|
+
// 免得「每日调用」和「每轮税」两张 sparkline 各自带一句标签,把 hero 卡撑散。
|
|
5384
|
+
hasWireTax ? h('div', { className: 'dsh-mcp-tailor-trend' },
|
|
5385
|
+
h(Spark, { series: wireDaily, title: t('近 {0} 天每轮真实下发的工具税(左旧右新)', [days]) }),
|
|
5386
|
+
h('span', null, wireChanges === 1 ? t('每轮下发清单在 {0} 天内一次没变', [days]) : t('每轮下发清单变化 {0} 次', [wireChanges])),
|
|
5387
|
+
wireLatest ? h('span', null, t('最近一次 {0} 个工具 ~{1} tok({2})', [fmtInt(wireLatest.toolCount), fmtInt(wireLatest.tokens), fmtLast(wireLatest.atMs, report.generatedAt)])) : null,
|
|
5388
|
+
) : null,
|
|
5389
|
+
|
|
5038
5390
|
),
|
|
5039
5391
|
h('div', { className: 'dsh-mcp-tailor-kpis' },
|
|
5040
5392
|
// OPT-18:四张 KPI 的数字都随报告来,zstd 不可用时 0 是「测不到」→ 统一 tailorNum。
|
|
@@ -5063,11 +5415,13 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5063
5415
|
h('button', { type: 'button', className: 'dsh-mcp-btn', onClick: load, disabled: busy }, t('重扫')),
|
|
5064
5416
|
h('span', { className: 'dsh-mcp-tailor-meta' }, metaText),
|
|
5065
5417
|
),
|
|
5066
|
-
//
|
|
5418
|
+
// 账本缓存读数(只读,动作已删;见 cacheLine 上方注释)。
|
|
5067
5419
|
cacheLine,
|
|
5068
5420
|
error ? h('div', { className: 'dsh-mcp-tailor-note' }, t('账本加载失败:'), fmtScanError(error)) : null,
|
|
5069
5421
|
// 解码告警放在数据和表格**上方**:调用数整列 0 时,用户第一眼要能看到「为什么」。
|
|
5070
5422
|
decodeIssue,
|
|
5423
|
+
attribution,
|
|
5424
|
+
|
|
5071
5425
|
actionError ? h('div', { className: 'dsh-mcp-tailor-note' }, actionError) : null,
|
|
5072
5426
|
flashMsg ? h('div', { className: 'dsh-mcp-tailor-note' }, flashMsg) : null,
|
|
5073
5427
|
h('div', { className: 'dsh-mcp-tailor-table' },
|
|
@@ -5130,19 +5484,19 @@ const ToolTailor = ({ call, timer, report: reportProp, onReport, seedPending })
|
|
|
5130
5484
|
const BOARD_CATEGORIES = [
|
|
5131
5485
|
{ key: 'mcp', label: 'MCP', icon: 'plug' },
|
|
5132
5486
|
{ key: 'skill', label: 'Skill', icon: 'skill' },
|
|
5133
|
-
{ key: 'expert', label
|
|
5134
|
-
{ key: 'tailor', label
|
|
5135
|
-
{ key: 'wb', label
|
|
5487
|
+
{ key: 'expert', get label() { return t('专家'); }, icon: 'expert' },
|
|
5488
|
+
{ key: 'tailor', get label() { return t('工具裁缝'); }, icon: 'scissors' },
|
|
5489
|
+
{ key: 'wb', get label() { return t('市场'); }, icon: 'store' },
|
|
5136
5490
|
];
|
|
5137
5491
|
// 分类摘要文案表:把「这一页到底有什么」压成一句话 + 两三行事实。
|
|
5138
5492
|
// 拆成纯数据而不是写在渲染里,是因为它同时被 hover 浮层和键盘焦点复用,
|
|
5139
5493
|
// 且后面加分类时只要补一条,不必改渲染逻辑。
|
|
5140
5494
|
const CATEGORY_BLURB = {
|
|
5141
|
-
mcp
|
|
5142
|
-
skill
|
|
5143
|
-
expert
|
|
5144
|
-
tailor
|
|
5145
|
-
wb
|
|
5495
|
+
get mcp() { return t('按服务器看工具清单、连接状态与参数;可增删、重连、导入。'); },
|
|
5496
|
+
get skill() { return t('本地 Skill 清单:来源、是否启用、是否可被模型调用。'); },
|
|
5497
|
+
get expert() { return t('已召唤的专家:人设、捆绑技能、当前在场状态,可送还。'); },
|
|
5498
|
+
get tailor() { return t('工具使用账本:谁在被调用、谁在白缴上下文税。'); },
|
|
5499
|
+
get wb() { return t('市场:连接器、内置 MCP、SkillHub 技能与专家召唤。'); },
|
|
5146
5500
|
};
|
|
5147
5501
|
// §1.1 分类 hover 摘要。定位由父层算好(left),这里只负责内容与外观。
|
|
5148
5502
|
// 只用 pointer-events:none 的只读浮层:不承载交互,因此不需要焦点陷阱,
|
|
@@ -5169,6 +5523,503 @@ const CatHintPopover = ({ hint, category, counts }) => {
|
|
|
5169
5523
|
category !== hint.key ? h('div', { className: 'dsh-mcp-cat-pop-foot' }, t('点击切换到此分类')) : null,
|
|
5170
5524
|
);
|
|
5171
5525
|
};
|
|
5526
|
+
// ── 自定义套件(Skill 看板):管理弹窗 + 跨组件打开总线 ─────────────────────
|
|
5527
|
+
// 一个套件 = 一组相关 skill 的**有序**命名组合;在输入框的引用菜单里点一次套件,等于把
|
|
5528
|
+
// 成员的 /name 逐个插进草稿(纯插件侧展开,宿主零改动)。数据层 lib/suites-registry.js
|
|
5529
|
+
// (~/.dsh/armory/suites.json),通道 listSkillSuites / saveSkillSuites。
|
|
5530
|
+
// 两条必须遵守的分工:
|
|
5531
|
+
// ① 保存是**整集合覆盖**:右侧列表就是全部真相,删除 = 从列表里拿掉再保存;
|
|
5532
|
+
// ② id 由**数据层**分配:新建条目在本地只有 `local-N` 临时 id(只为 React key 与选中态
|
|
5533
|
+
// 服务),提交前置空,保存成功后以返回值刷新看板副本——拿旧副本再存一次,同一套件
|
|
5534
|
+
// 会分裂成两条。
|
|
5535
|
+
// save 的传输错误翻译复用紧随其后的 skillTransportHint(404 = 宿主还没重启)。
|
|
5536
|
+
const SUITE_LOCAL_ID_PREFIX = 'local-';
|
|
5537
|
+
// 与 lib/suites-registry.js 的同名常量同值:写侧对超长是「放行 + 截断」,这里在输入时就
|
|
5538
|
+
// 限住,免得用户写满两百字、保存后才发现被截断。改一处请同时改另一处。
|
|
5539
|
+
const SUITE_NAME_MAX = 40;
|
|
5540
|
+
const SUITE_DESC_MAX = 120;
|
|
5541
|
+
const SUITE_MEMBERS_MAX = 40;
|
|
5542
|
+
const SUITES_MAX = 100;
|
|
5543
|
+
|
|
5544
|
+
// 打开弹窗的两段式总线(与 pendingSkillFocus / pendingBoardCategory 完全同一套语义):
|
|
5545
|
+
// 召唤菜单的「管理套件…」也要能开这个弹窗,而弹窗挂在 SkillBoard 里。看板已挂载 → 直接
|
|
5546
|
+
// 广播;没挂载(用户在会话界面点的)→ 意图先记在模块里,SkillBoard 挂载后自领,随后
|
|
5547
|
+
// openBoardCategory('skill') 把看板切到 Skill 分类(它自己会 selectPanel,不必再开一次)。
|
|
5548
|
+
let pendingSuiteOpen = false;
|
|
5549
|
+
const suiteOpenListeners = new Set();
|
|
5550
|
+
function openSuiteManager() {
|
|
5551
|
+
pendingSuiteOpen = true;
|
|
5552
|
+
for (const listener of [...suiteOpenListeners]) {
|
|
5553
|
+
try { listener(); } catch { /* 单个订阅者失败不影响弹窗本身 */ }
|
|
5554
|
+
}
|
|
5555
|
+
openBoardCategory('skill');
|
|
5556
|
+
}
|
|
5557
|
+
const onSuiteOpenRequest = (listener) => {
|
|
5558
|
+
suiteOpenListeners.add(listener);
|
|
5559
|
+
return () => { suiteOpenListeners.delete(listener); };
|
|
5560
|
+
};
|
|
5561
|
+
|
|
5562
|
+
// 提交给数据层的形状:本地临时 id 置空(写侧分配真 id),已有的 id / 时间戳原样带上
|
|
5563
|
+
// (updatedAt 由调用方决定 → 同一 payload 重放得到同一结果,写侧幂等)。
|
|
5564
|
+
const suitePayloadOf = (rows) => (Array.isArray(rows) ? rows : []).map((suite) => ({
|
|
5565
|
+
id: String(suite.id || '').startsWith(SUITE_LOCAL_ID_PREFIX) ? '' : String(suite.id || ''),
|
|
5566
|
+
name: String(suite.name || ''),
|
|
5567
|
+
desc: String(suite.desc || ''),
|
|
5568
|
+
members: Array.isArray(suite.members) ? [...suite.members] : [],
|
|
5569
|
+
...(suite.createdAt ? { createdAt: suite.createdAt } : {}),
|
|
5570
|
+
...(suite.updatedAt ? { updatedAt: suite.updatedAt } : {}),
|
|
5571
|
+
}));
|
|
5572
|
+
// 工作副本的一条:字段全部拉成确定形状,编辑时不必再逐处判空。
|
|
5573
|
+
const cloneSuiteRow = (suite) => ({
|
|
5574
|
+
id: String(suite?.id || ''),
|
|
5575
|
+
name: String(suite?.name || ''),
|
|
5576
|
+
desc: String(suite?.desc || ''),
|
|
5577
|
+
members: Array.isArray(suite?.members) ? suite.members.map((name) => String(name)) : [],
|
|
5578
|
+
createdAt: Number(suite?.createdAt) || 0,
|
|
5579
|
+
updatedAt: Number(suite?.updatedAt) || 0,
|
|
5580
|
+
});
|
|
5581
|
+
|
|
5582
|
+
const SuiteManager = ({ call, skills, catOf, suites, onClose, onSaved, notify }) => {
|
|
5583
|
+
const skillRows = Array.isArray(skills) ? skills : [];
|
|
5584
|
+
// 弹窗内所有编辑都落在 draft 上,「保存」才整集合提交;取消 = 直接丢弃 draft。
|
|
5585
|
+
// 只在挂载时从 props 拷一次:弹窗打开期间唯一的写入者就是它自己,不需要反向同步。
|
|
5586
|
+
const [draft, setDraft] = useState(() => (Array.isArray(suites) ? suites.map(cloneSuiteRow) : []));
|
|
5587
|
+
const [selId, setSelId] = useState(() => (Array.isArray(suites) && suites.length ? String(suites[0].id || '') : ''));
|
|
5588
|
+
const [pickCat, setPickCat] = useState('all');
|
|
5589
|
+
const [pickQuery, setPickQuery] = useState('');
|
|
5590
|
+
const [dirty, setDirty] = useState(false);
|
|
5591
|
+
const [saving, setSaving] = useState(false);
|
|
5592
|
+
const [saveError, setSaveError] = useState('');
|
|
5593
|
+
const [flashMsg, setFlashMsg] = useState(null); // { msg, ok }:弹窗内的小反馈
|
|
5594
|
+
const [armDel, setArmDel] = useState(''); // 两段式删除:已 arm 的套件 id
|
|
5595
|
+
const [dropIndex, setDropIndex] = useState(null); // 拖拽插入位(null = 不画指示线)
|
|
5596
|
+
const [zoneOver, setZoneOver] = useState(false);
|
|
5597
|
+
const [dragging, setDragging] = useState(''); // 拖动中的来源标记(半透明视觉)
|
|
5598
|
+
const dragRef = useRef(null); // { kind: 'skill', name } | { kind: 'member', index }
|
|
5599
|
+
const localSeq = useRef(0);
|
|
5600
|
+
const flashTimer = useRef(null);
|
|
5601
|
+
const armTimer = useRef(null);
|
|
5602
|
+
const nameRef = useRef(null);
|
|
5603
|
+
const modalRef = useRef(null);
|
|
5604
|
+
useFocusTrap(modalRef, true);
|
|
5605
|
+
useEffect(() => () => {
|
|
5606
|
+
if (flashTimer.current) clearTimeout(flashTimer.current);
|
|
5607
|
+
if (armTimer.current) clearTimeout(armTimer.current);
|
|
5608
|
+
}, []);
|
|
5609
|
+
|
|
5610
|
+
const flash = (msg, ok = true) => {
|
|
5611
|
+
setFlashMsg({ msg, ok });
|
|
5612
|
+
if (flashTimer.current) clearTimeout(flashTimer.current);
|
|
5613
|
+
flashTimer.current = setTimeout(() => setFlashMsg(null), 2400);
|
|
5614
|
+
};
|
|
5615
|
+
|
|
5616
|
+
// 分类:优先用看板已经算好的 catOf(同一份映射,两处判定不会漂移),缺席时现算。
|
|
5617
|
+
const skillByName = useMemo(() => {
|
|
5618
|
+
const map = new Map();
|
|
5619
|
+
for (const s of skillRows) map.set(s.name, s);
|
|
5620
|
+
return map;
|
|
5621
|
+
}, [skills]);
|
|
5622
|
+
const catFor = (skill) => (skill ? ((catOf && catOf.get(skill.name)) || skillCategoryOf(skill)) : SKILL_UNCAT);
|
|
5623
|
+
const catOfName = (name) => catFor(skillByName.get(name));
|
|
5624
|
+
|
|
5625
|
+
// 左栏:分类轨道计数固定全量(与看板决策 9 同口径,不随搜索联动),列表随搜索/分类筛。
|
|
5626
|
+
const pickCounts = useMemo(() => {
|
|
5627
|
+
const list = Array.isArray(skills) ? skills : [];
|
|
5628
|
+
const map = new Map([['all', list.length]]);
|
|
5629
|
+
for (const s of list) {
|
|
5630
|
+
const cat = (catOf && catOf.get(s.name)) || skillCategoryOf(s);
|
|
5631
|
+
map.set(cat.key, (map.get(cat.key) || 0) + 1);
|
|
5632
|
+
}
|
|
5633
|
+
return map;
|
|
5634
|
+
}, [skills, catOf]);
|
|
5635
|
+
const pickList = useMemo(() => {
|
|
5636
|
+
const list = Array.isArray(skills) ? skills : [];
|
|
5637
|
+
const q = pickQuery.trim().toLowerCase();
|
|
5638
|
+
return list.filter((s) => {
|
|
5639
|
+
if (pickCat !== 'all') {
|
|
5640
|
+
const cat = (catOf && catOf.get(s.name)) || skillCategoryOf(s);
|
|
5641
|
+
if (cat.key !== pickCat) return false;
|
|
5642
|
+
}
|
|
5643
|
+
if (q && !(String(s.name || '') + ' ' + String(s.description || '')).toLowerCase().includes(q)) return false;
|
|
5644
|
+
return true;
|
|
5645
|
+
});
|
|
5646
|
+
}, [skills, catOf, pickCat, pickQuery]);
|
|
5647
|
+
|
|
5648
|
+
const cur = draft.find((s) => s.id === selId) || null;
|
|
5649
|
+
const curMembers = cur && Array.isArray(cur.members) ? cur.members : [];
|
|
5650
|
+
|
|
5651
|
+
const patchCur = (patch) => {
|
|
5652
|
+
if (!cur) return;
|
|
5653
|
+
setDraft((prev) => prev.map((s) => (s.id === cur.id ? { ...s, ...patch } : s)));
|
|
5654
|
+
setDirty(true);
|
|
5655
|
+
};
|
|
5656
|
+
|
|
5657
|
+
// 无拖拽替代路径(也是唯一的键盘路径):点卡片上的 + 把 skill 加进当前套件。
|
|
5658
|
+
const addMember = (name, at) => {
|
|
5659
|
+
if (!cur) { flash(t('先在右侧选择一个套件,或点「新建套件」'), false); return; }
|
|
5660
|
+
if (curMembers.includes(name)) { flash(t('「{0}」已在这个套件里', [name]), false); return; }
|
|
5661
|
+
if (curMembers.length >= SUITE_MEMBERS_MAX) { flash(t('成员已达上限 {0} 个', [SUITE_MEMBERS_MAX]), false); return; }
|
|
5662
|
+
const members = [...curMembers];
|
|
5663
|
+
members.splice(typeof at === 'number' && at >= 0 && at <= members.length ? at : members.length, 0, name);
|
|
5664
|
+
patchCur({ members });
|
|
5665
|
+
flash(t('已加入「{0}」', [name]));
|
|
5666
|
+
};
|
|
5667
|
+
const removeMember = (index) => {
|
|
5668
|
+
if (!cur) return;
|
|
5669
|
+
patchCur({ members: curMembers.filter((_, i) => i !== index) });
|
|
5670
|
+
};
|
|
5671
|
+
const moveMember = (from, to) => {
|
|
5672
|
+
if (!cur || from < 0 || from >= curMembers.length) return;
|
|
5673
|
+
const members = [...curMembers];
|
|
5674
|
+
const [moved] = members.splice(from, 1);
|
|
5675
|
+
// 指示线下标是按**移动前**的数组算的:来源在目标之前时,抽出来源后目标整体前移一位。
|
|
5676
|
+
const target = Math.max(0, Math.min(from < to ? to - 1 : to, members.length));
|
|
5677
|
+
members.splice(target, 0, moved);
|
|
5678
|
+
patchCur({ members });
|
|
5679
|
+
};
|
|
5680
|
+
|
|
5681
|
+
// ── 拖拽(HTML5 DnD):载荷真源是 dragRef,dataTransfer 只作跨容器兜底 ─────────
|
|
5682
|
+
const startSkillDrag = (e, name) => {
|
|
5683
|
+
dragRef.current = { kind: 'skill', name };
|
|
5684
|
+
setDragging('skill:' + name);
|
|
5685
|
+
try {
|
|
5686
|
+
if (e.dataTransfer) { e.dataTransfer.setData('text/plain', name); e.dataTransfer.effectAllowed = 'copy'; }
|
|
5687
|
+
} catch { /* 个别浏览器限制 setData 时机;本组件不依赖它 */ }
|
|
5688
|
+
};
|
|
5689
|
+
const startMemberDrag = (e, index) => {
|
|
5690
|
+
dragRef.current = { kind: 'member', index };
|
|
5691
|
+
setDragging('member:' + index);
|
|
5692
|
+
try {
|
|
5693
|
+
if (e.dataTransfer) { e.dataTransfer.setData('text/plain', String(index)); e.dataTransfer.effectAllowed = 'move'; }
|
|
5694
|
+
} catch { /* 同上:dragRef 是真源 */ }
|
|
5695
|
+
};
|
|
5696
|
+
const endDrag = () => { dragRef.current = null; setDragging(''); setDropIndex(null); setZoneOver(false); };
|
|
5697
|
+
const onRowDragOver = (e, index) => {
|
|
5698
|
+
e.preventDefault();
|
|
5699
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
5700
|
+
setZoneOver(true);
|
|
5701
|
+
setDropIndex(e.clientY < rect.top + rect.height / 2 ? index : index + 1);
|
|
5702
|
+
};
|
|
5703
|
+
const onZoneDrop = (e) => {
|
|
5704
|
+
e.preventDefault();
|
|
5705
|
+
const payload = dragRef.current;
|
|
5706
|
+
const at = dropIndex === null ? curMembers.length : dropIndex;
|
|
5707
|
+
endDrag();
|
|
5708
|
+
if (!payload || !cur) return;
|
|
5709
|
+
if (payload.kind === 'skill') addMember(payload.name, at);
|
|
5710
|
+
else moveMember(payload.index, at);
|
|
5711
|
+
};
|
|
5712
|
+
|
|
5713
|
+
const newSuite = () => {
|
|
5714
|
+
if (draft.length >= SUITES_MAX) { flash(t('套件数量已达上限 {0} 个', [SUITES_MAX]), false); return; }
|
|
5715
|
+
localSeq.current += 1;
|
|
5716
|
+
const id = SUITE_LOCAL_ID_PREFIX + localSeq.current;
|
|
5717
|
+
setDraft((prev) => [...prev, { id, name: '', desc: '', members: [], createdAt: 0, updatedAt: 0 }]);
|
|
5718
|
+
setSelId(id);
|
|
5719
|
+
setDirty(true);
|
|
5720
|
+
setArmDel('');
|
|
5721
|
+
setSaveError('');
|
|
5722
|
+
// 名称框要等这一帧渲染完才存在(编辑器随 selId 重渲染),聚焦放到下一帧。
|
|
5723
|
+
requestAnimationFrame(() => { if (nameRef.current) nameRef.current.focus(); });
|
|
5724
|
+
};
|
|
5725
|
+
// 两段式删除:第一次点变「确认?」(2.6s 后自动收回),第二次点才真删。删除只改 draft,
|
|
5726
|
+
// 落盘要等「保存」——所以文案是「保存后生效」,误删可以在关窗前反悔(取消即回滚)。
|
|
5727
|
+
const armDelete = (id) => {
|
|
5728
|
+
if (armDel !== id) {
|
|
5729
|
+
setArmDel(id);
|
|
5730
|
+
if (armTimer.current) clearTimeout(armTimer.current);
|
|
5731
|
+
armTimer.current = setTimeout(() => setArmDel(''), 2600);
|
|
5732
|
+
return;
|
|
5733
|
+
}
|
|
5734
|
+
if (armTimer.current) clearTimeout(armTimer.current);
|
|
5735
|
+
setArmDel('');
|
|
5736
|
+
const next = draft.filter((s) => s.id !== id);
|
|
5737
|
+
setDraft(next);
|
|
5738
|
+
if (selId === id) setSelId(next.length ? next[0].id : '');
|
|
5739
|
+
setDirty(true);
|
|
5740
|
+
flash(t('已删除套件(保存后生效)'), false);
|
|
5741
|
+
};
|
|
5742
|
+
|
|
5743
|
+
const attemptClose = () => {
|
|
5744
|
+
if (saving) return;
|
|
5745
|
+
// 「已放弃未保存的修改」必须由看板来说:弹窗一关自己就卸载了,反馈留在弹窗里等于没有。
|
|
5746
|
+
if (dirty && typeof notify === 'function') notify(t('已放弃未保存的修改'), false);
|
|
5747
|
+
onClose();
|
|
5748
|
+
};
|
|
5749
|
+
|
|
5750
|
+
const save = async () => {
|
|
5751
|
+
// 前置校验与写侧同口径(无名 / 空成员都属「拒绝」档),先在界面上挡住并跳到那一条:
|
|
5752
|
+
// 写侧的错误消息里只有序号,用户还得自己数是第几条。
|
|
5753
|
+
const unnamed = draft.find((s) => !String(s.name || '').trim());
|
|
5754
|
+
if (unnamed) {
|
|
5755
|
+
setSelId(unnamed.id);
|
|
5756
|
+
flash(t('有套件还没起名'), false);
|
|
5757
|
+
requestAnimationFrame(() => { if (nameRef.current) nameRef.current.focus(); });
|
|
5758
|
+
return;
|
|
5759
|
+
}
|
|
5760
|
+
const hollow = draft.find((s) => !Array.isArray(s.members) || !s.members.length);
|
|
5761
|
+
if (hollow) {
|
|
5762
|
+
setSelId(hollow.id);
|
|
5763
|
+
flash(t('套件「{0}」还没有成员', [hollow.name]), false);
|
|
5764
|
+
return;
|
|
5765
|
+
}
|
|
5766
|
+
setSaving(true);
|
|
5767
|
+
setSaveError('');
|
|
5768
|
+
let res = null;
|
|
5769
|
+
try {
|
|
5770
|
+
res = await call('saveSkillSuites', { suites: suitePayloadOf(draft) });
|
|
5771
|
+
} catch (e) {
|
|
5772
|
+
setSaving(false);
|
|
5773
|
+
setSaveError(skillTransportHint(String((e && e.message) || e)));
|
|
5774
|
+
return;
|
|
5775
|
+
}
|
|
5776
|
+
if (res && res.ok) {
|
|
5777
|
+
const saved = Array.isArray(res.suites) ? res.suites : [];
|
|
5778
|
+
setSaving(false);
|
|
5779
|
+
setDirty(false);
|
|
5780
|
+
// 先把返回值交出去再关窗:新分配的 id 只在这里出现,看板用它刷新副本与计数徽标。
|
|
5781
|
+
if (typeof onSaved === 'function') onSaved(saved);
|
|
5782
|
+
if (typeof notify === 'function') notify(t('套件已保存({0} 个)', [saved.length]), true);
|
|
5783
|
+
onClose();
|
|
5784
|
+
return;
|
|
5785
|
+
}
|
|
5786
|
+
setSaving(false);
|
|
5787
|
+
setSaveError(skillTransportHint((res && res.error) || t('保存失败:宿主没有回包')));
|
|
5788
|
+
};
|
|
5789
|
+
|
|
5790
|
+
// 成员行 + 拖拽插入指示线:指示线是普通行元素(不是独立容器),成员区始终只有一个滚动区。
|
|
5791
|
+
const memberCells = [];
|
|
5792
|
+
curMembers.forEach((name, index) => {
|
|
5793
|
+
if (dropIndex === index) memberCells.push(h('div', { key: '__dl__' + index, className: 'drop-line' }));
|
|
5794
|
+
const cat = catOfName(name);
|
|
5795
|
+
// 注册表里查不到 = 该 skill 已被移动 / 删除 / 不在当前作用域。成员保留(用户可能只是
|
|
5796
|
+
// 暂时切了作用域),但如实标「已失效」——引用时宿主插进去的 /name 命中不了任何技能。
|
|
5797
|
+
const missing = !skillByName.has(name);
|
|
5798
|
+
memberCells.push(h('div', {
|
|
5799
|
+
key: name,
|
|
5800
|
+
className: 'member-row' + (dragging === 'member:' + index ? ' dragging' : ''),
|
|
5801
|
+
draggable: true,
|
|
5802
|
+
title: missing ? t('宿主当前找不到这个 skill(可能已移动或删除),引用时会失效') : name,
|
|
5803
|
+
onDragStart: (e) => startMemberDrag(e, index),
|
|
5804
|
+
onDragEnd: endDrag,
|
|
5805
|
+
onDragOver: (e) => onRowDragOver(e, index),
|
|
5806
|
+
},
|
|
5807
|
+
h('span', { className: 'idx' }, index + 1),
|
|
5808
|
+
h('span', { className: 'grip', 'aria-hidden': true }, h(Icon, { d: ICONS.grip, size: 11 })),
|
|
5809
|
+
h('span', { className: 'tile', style: { background: cat.color } }),
|
|
5810
|
+
h('span', { className: 'nm' }, name),
|
|
5811
|
+
h('span', { className: 'cat' + (missing ? ' gone' : '') }, missing ? t('已失效') : cat.name),
|
|
5812
|
+
h('button', {
|
|
5813
|
+
type: 'button', className: 'rm', title: t('移除'), 'aria-label': t('移除 {0}', [name]),
|
|
5814
|
+
onClick: () => removeMember(index),
|
|
5815
|
+
}, h(Icon, { d: ICONS.x, size: 10 })),
|
|
5816
|
+
));
|
|
5817
|
+
});
|
|
5818
|
+
if (cur && dropIndex === curMembers.length) memberCells.push(h('div', { key: '__dl__end', className: 'drop-line' }));
|
|
5819
|
+
|
|
5820
|
+
return h('div', {
|
|
5821
|
+
className: 'dsh-mcp-overlay',
|
|
5822
|
+
onMouseDown: (e) => { if (e.target === e.currentTarget) attemptClose(); },
|
|
5823
|
+
},
|
|
5824
|
+
h('div', {
|
|
5825
|
+
ref: modalRef,
|
|
5826
|
+
className: 'dsh-mcp-modal suites',
|
|
5827
|
+
role: 'dialog',
|
|
5828
|
+
'aria-modal': true,
|
|
5829
|
+
'aria-label': t('套件管理'),
|
|
5830
|
+
tabIndex: -1,
|
|
5831
|
+
onKeyDown: (e) => { if (e.key === 'Escape') { e.stopPropagation(); attemptClose(); } },
|
|
5832
|
+
},
|
|
5833
|
+
h('header', { className: 'suite-head' },
|
|
5834
|
+
h('div', { className: 'suite-head-text' },
|
|
5835
|
+
h('h3', null, t('套件管理')),
|
|
5836
|
+
h('p', { className: 'sub' }, t('把多个相关 skill 打包成套件;在输入框的引用菜单里点一次套件,等于同时引用全部成员。')),
|
|
5837
|
+
),
|
|
5838
|
+
h('button', {
|
|
5839
|
+
type: 'button', className: 'suite-x', title: t('关闭'), 'aria-label': t('关闭'),
|
|
5840
|
+
disabled: saving, onClick: attemptClose,
|
|
5841
|
+
}, h(Icon, { d: ICONS.x, size: 14 })),
|
|
5842
|
+
),
|
|
5843
|
+
h('div', { className: 'suite-body' },
|
|
5844
|
+
// ── 左:技能库选择器(分类轨道 + 搜索 + 可拖卡片)──
|
|
5845
|
+
h('div', { className: 'suite-left' },
|
|
5846
|
+
h('label', { className: 'suite-search' },
|
|
5847
|
+
h(Icon, { d: ICONS.search, size: 13 }),
|
|
5848
|
+
h('input', {
|
|
5849
|
+
value: pickQuery,
|
|
5850
|
+
placeholder: t('搜索 skill…'),
|
|
5851
|
+
'aria-label': t('搜索 skill'),
|
|
5852
|
+
onChange: (e) => setPickQuery(e.target.value),
|
|
5853
|
+
}),
|
|
5854
|
+
),
|
|
5855
|
+
h('div', { className: 'dsh-mcp-skill-rail', role: 'tablist', 'aria-label': t('按分类过滤 skill') },
|
|
5856
|
+
SKILL_CATS.map((c) => h('button', {
|
|
5857
|
+
key: c.key,
|
|
5858
|
+
type: 'button',
|
|
5859
|
+
role: 'tab',
|
|
5860
|
+
'aria-selected': pickCat === c.key,
|
|
5861
|
+
className: 'dsh-mcp-skill-cat' + (pickCat === c.key ? ' on' : ''),
|
|
5862
|
+
onClick: () => setPickCat(c.key),
|
|
5863
|
+
},
|
|
5864
|
+
h('span', { className: 'dsh-mcp-skill-cat-dot', style: { background: c.color } }),
|
|
5865
|
+
c.name,
|
|
5866
|
+
h('span', { className: 'dsh-mcp-skill-cat-count' }, pickCounts.get(c.key) || 0),
|
|
5867
|
+
))),
|
|
5868
|
+
skillRows.length === 0
|
|
5869
|
+
? h('div', { className: 'member-empty' },
|
|
5870
|
+
h('span', { className: 'big' }, h(Icon, { d: ICONS.skill, size: 20 })),
|
|
5871
|
+
t('看板没有读到 skill 列表'),
|
|
5872
|
+
t('宿主未挂载 skill 注册表(@deepseek-ai/dsh-skill)时这里就是空的;套件仍能保存,但成员名要自己保证正确。'))
|
|
5873
|
+
: h('div', { className: 'suite-pick-list' },
|
|
5874
|
+
pickList.length === 0
|
|
5875
|
+
? h('div', { className: 'member-empty' }, t('没有符合筛选的 skill'))
|
|
5876
|
+
: pickList.map((s) => {
|
|
5877
|
+
const cat = catFor(s);
|
|
5878
|
+
const inSuite = curMembers.includes(s.name);
|
|
5879
|
+
return h('div', {
|
|
5880
|
+
key: s.name,
|
|
5881
|
+
className: 'suite-pick-card' + (inSuite ? ' in-suite' : '') + (dragging === 'skill:' + s.name ? ' dragging' : ''),
|
|
5882
|
+
draggable: !!cur,
|
|
5883
|
+
title: inSuite ? t('已在当前套件中') : t('拖到右侧成员区,或点 + 添加'),
|
|
5884
|
+
onDragStart: (e) => startSkillDrag(e, s.name),
|
|
5885
|
+
onDragEnd: endDrag,
|
|
5886
|
+
},
|
|
5887
|
+
h('span', { className: 'tile', style: { background: cat.color } }),
|
|
5888
|
+
h('span', { className: 'body' },
|
|
5889
|
+
h('span', { className: 'nm' }, s.name, h('span', { className: 'cat' }, cat.name)),
|
|
5890
|
+
h('span', { className: 'ds' }, s.description || t('(无描述)')),
|
|
5891
|
+
),
|
|
5892
|
+
h('span', { className: 'grip', 'aria-hidden': true }, h(Icon, { d: ICONS.grip, size: 12 })),
|
|
5893
|
+
h('button', {
|
|
5894
|
+
type: 'button',
|
|
5895
|
+
className: 'add',
|
|
5896
|
+
disabled: !cur,
|
|
5897
|
+
title: inSuite ? t('已在套件中') : t('添加到当前套件'),
|
|
5898
|
+
'aria-label': t('添加到当前套件'),
|
|
5899
|
+
onClick: (e) => { e.stopPropagation(); addMember(s.name); },
|
|
5900
|
+
}, inSuite ? '✓' : h(Icon, { d: ICONS.plus, size: 11 })),
|
|
5901
|
+
);
|
|
5902
|
+
})),
|
|
5903
|
+
),
|
|
5904
|
+
// ── 右:套件列表 + 编辑器 ──
|
|
5905
|
+
h('div', { className: 'suite-right' },
|
|
5906
|
+
h('div', { className: 'suite-list-bar' },
|
|
5907
|
+
h('span', { className: 't' }, t('我的套件')),
|
|
5908
|
+
h('span', { className: 'sp' }),
|
|
5909
|
+
h(Btn, {
|
|
5910
|
+
cls: 'suite-new', icon: 'plus', disabled: saving, onClick: newSuite,
|
|
5911
|
+
title: t('新建一个空套件'), children: t('新建套件'),
|
|
5912
|
+
}),
|
|
5913
|
+
),
|
|
5914
|
+
h('div', { className: 'suite-list' },
|
|
5915
|
+
draft.length === 0
|
|
5916
|
+
? h('div', { className: 'member-empty' }, t('还没有套件:点右上「新建套件」开始'))
|
|
5917
|
+
: draft.map((s) => h('div', {
|
|
5918
|
+
key: s.id,
|
|
5919
|
+
className: 'suite-row' + (s.id === selId ? ' on' : ''),
|
|
5920
|
+
// 行本身是 div[role=button] 而不是 <button>:里面还要放「删除」按钮,
|
|
5921
|
+
// button 套 button 是无效 HTML(浏览器会拆节点,React 的树跟着错位)。
|
|
5922
|
+
role: 'button',
|
|
5923
|
+
tabIndex: 0,
|
|
5924
|
+
'aria-pressed': s.id === selId,
|
|
5925
|
+
onClick: () => { setSelId(s.id); setArmDel(''); },
|
|
5926
|
+
onKeyDown: (e) => {
|
|
5927
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelId(s.id); setArmDel(''); }
|
|
5928
|
+
},
|
|
5929
|
+
},
|
|
5930
|
+
h('span', { className: 'ic' }, h(Icon, { d: ICONS.layers, size: 13 })),
|
|
5931
|
+
h('span', { className: 'nm' }, s.name || t('未命名套件')),
|
|
5932
|
+
h('span', { className: 'stack', 'aria-hidden': true },
|
|
5933
|
+
s.members.slice(0, 4).map((name) => h('i', { key: name, style: { background: catOfName(name).color } }))),
|
|
5934
|
+
h('span', { className: 'ct' }, s.members.length),
|
|
5935
|
+
h('button', {
|
|
5936
|
+
type: 'button',
|
|
5937
|
+
className: 'del' + (armDel === s.id ? ' arm' : ''),
|
|
5938
|
+
disabled: saving,
|
|
5939
|
+
title: armDel === s.id ? t('再点一次确认删除') : t('删除套件'),
|
|
5940
|
+
onClick: (e) => { e.stopPropagation(); armDelete(s.id); },
|
|
5941
|
+
}, armDel === s.id ? t('确认?') : t('删除')),
|
|
5942
|
+
))),
|
|
5943
|
+
cur
|
|
5944
|
+
? h('div', { className: 'suite-editor' },
|
|
5945
|
+
h('div', { className: 'fields' },
|
|
5946
|
+
h('div', { className: 'field' },
|
|
5947
|
+
h('label', { htmlFor: 'dsh-suite-name' }, t('套件名称')),
|
|
5948
|
+
h('input', {
|
|
5949
|
+
id: 'dsh-suite-name',
|
|
5950
|
+
ref: nameRef,
|
|
5951
|
+
value: cur.name,
|
|
5952
|
+
maxLength: SUITE_NAME_MAX,
|
|
5953
|
+
placeholder: t('例如:演示视频流水线'),
|
|
5954
|
+
onChange: (e) => patchCur({ name: e.target.value }),
|
|
5955
|
+
}),
|
|
5956
|
+
),
|
|
5957
|
+
h('div', { className: 'field' },
|
|
5958
|
+
h('label', { htmlFor: 'dsh-suite-desc' }, t('说明(可选,显示在引用菜单条目右侧)')),
|
|
5959
|
+
h('input', {
|
|
5960
|
+
id: 'dsh-suite-desc',
|
|
5961
|
+
value: cur.desc,
|
|
5962
|
+
maxLength: SUITE_DESC_MAX,
|
|
5963
|
+
placeholder: t('一句话说明这套技能干什么'),
|
|
5964
|
+
onChange: (e) => patchCur({ desc: e.target.value }),
|
|
5965
|
+
}),
|
|
5966
|
+
),
|
|
5967
|
+
),
|
|
5968
|
+
h('div', {
|
|
5969
|
+
className: 'member-zone' + (zoneOver ? ' over' : ''),
|
|
5970
|
+
onDragOver: (e) => {
|
|
5971
|
+
e.preventDefault();
|
|
5972
|
+
setZoneOver(true);
|
|
5973
|
+
// 空成员区没有行可测几何:整个区域就是一个放置点(追加到末尾)。
|
|
5974
|
+
if (!curMembers.length) setDropIndex(0);
|
|
5975
|
+
},
|
|
5976
|
+
onDragLeave: (e) => {
|
|
5977
|
+
if (!e.currentTarget.contains(e.relatedTarget)) { setZoneOver(false); setDropIndex(null); }
|
|
5978
|
+
},
|
|
5979
|
+
onDrop: onZoneDrop,
|
|
5980
|
+
},
|
|
5981
|
+
h('div', { className: 'zh' },
|
|
5982
|
+
h(Icon, { d: ICONS.layers, size: 12 }),
|
|
5983
|
+
h('b', null, t('成员技能')),
|
|
5984
|
+
h('span', null, t('{0} 个 · 拖拽可排序', [curMembers.length])),
|
|
5985
|
+
),
|
|
5986
|
+
h('div', { className: 'member-list' },
|
|
5987
|
+
curMembers.length === 0
|
|
5988
|
+
? h('div', { className: 'member-empty' },
|
|
5989
|
+
h('span', { className: 'big' }, h(Icon, { d: ICONS.grip, size: 20 })),
|
|
5990
|
+
t('把左侧卡片拖到这里'),
|
|
5991
|
+
t('拖不动时,悬停卡片点 + 也能加进来'))
|
|
5992
|
+
: memberCells),
|
|
5993
|
+
),
|
|
5994
|
+
)
|
|
5995
|
+
: h('div', { className: 'suite-editor' },
|
|
5996
|
+
h('div', { className: 'member-empty' },
|
|
5997
|
+
h('span', { className: 'big' }, h(Icon, { d: ICONS.layers, size: 20 })),
|
|
5998
|
+
t('选择右上列表里的一个套件,或点「新建套件」'),
|
|
5999
|
+
t('套件 = 一组相关 skill 的引用打包')),
|
|
6000
|
+
),
|
|
6001
|
+
),
|
|
6002
|
+
),
|
|
6003
|
+
h('div', { className: 'suite-foot' },
|
|
6004
|
+
h('span', { className: 'hint' },
|
|
6005
|
+
h(Icon, { d: ICONS.grip, size: 12 }),
|
|
6006
|
+
h('span', null, t('拖左侧卡片到成员区即可添加;成员可上下拖拽排序,+ / × 是无拖拽的替代路径。')),
|
|
6007
|
+
),
|
|
6008
|
+
saveError
|
|
6009
|
+
? h('span', { className: 'err', role: 'alert', title: saveError }, saveError)
|
|
6010
|
+
: (dirty ? h('span', { className: 'dirty' }, h('i', null), t('未保存')) : null),
|
|
6011
|
+
h(Btn, { onClick: attemptClose, disabled: saving, children: t('取消') }),
|
|
6012
|
+
h(Btn, {
|
|
6013
|
+
cls: 'primary', onClick: save, disabled: saving,
|
|
6014
|
+
title: t('整集合覆盖保存:右侧列表就是全部套件'),
|
|
6015
|
+
children: saving ? t('保存中…') : t('保存套件'),
|
|
6016
|
+
}),
|
|
6017
|
+
),
|
|
6018
|
+
flashMsg ? h('div', { className: 'dsh-mcp-toast ' + (flashMsg.ok ? 'ok' : 'err') }, flashMsg.msg) : null,
|
|
6019
|
+
),
|
|
6020
|
+
);
|
|
6021
|
+
};
|
|
6022
|
+
|
|
5172
6023
|
// 宿主侧接口不存在(HTTP 404 / transport failure)几乎总是「宿主还没重启」:
|
|
5173
6024
|
// 客户端 bundle 从磁盘现读、改完刷新即生效,而宿主的 Remote 方法在启动时才注册。
|
|
5174
6025
|
// 这里把生硬的传输错误翻译成可操作的提示,而不是让用户对着 404 猜。
|
|
@@ -5177,6 +6028,22 @@ const skillTransportHint = (message) =>
|
|
|
5177
6028
|
? String(message) + t(' ——宿主还没注册 skill 接口:重启 DeepSeek Harness 让宿主侧生效后即可')
|
|
5178
6029
|
: String(message);
|
|
5179
6030
|
|
|
6031
|
+
// Skill 集合变更总线:市场安装/卸载、看板卸载都会改磁盘上的 skill 集合,但这些入口
|
|
6032
|
+
// 与消费方不在一棵子树里——顶部数量角标在 McpBoardPage,列表在 SkillBoard,二者还
|
|
6033
|
+
// 不同时挂载(分类是单选渲染)。没有总线时角标只在 McpBoardPage 首次挂载读一次,
|
|
6034
|
+
// 装完必须离开整个看板再回来才更新。与 expertChangeListeners 同构:模块级 Set、
|
|
6035
|
+
// 逐个 try(单个订阅者失败不影响其它)、返回退订函数;reason 只用于排查日志/测试。
|
|
6036
|
+
const skillChangeListeners = new Set();
|
|
6037
|
+
const emitSkillChange = (reason = '') => {
|
|
6038
|
+
for (const listener of [...skillChangeListeners]) {
|
|
6039
|
+
try { listener(reason); } catch { /* 单个订阅者失败不影响其它订阅者 */ }
|
|
6040
|
+
}
|
|
6041
|
+
};
|
|
6042
|
+
const onSkillChange = (listener) => {
|
|
6043
|
+
skillChangeListeners.add(listener);
|
|
6044
|
+
return () => { skillChangeListeners.delete(listener); };
|
|
6045
|
+
};
|
|
6046
|
+
|
|
5180
6047
|
// ── Skill 看板:列表 + 详情 ──────────────────────────────────────────
|
|
5181
6048
|
// 数据来自宿主新增的 listSkills / skillDetail(走同一个 mcpManager Remote 命名空间,
|
|
5182
6049
|
// 复用既有的 call 信封 {ok, ...}),不引入第二条 Remote 通道。
|
|
@@ -5195,14 +6062,26 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5195
6062
|
const [detail, setDetail] = useState(null);
|
|
5196
6063
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
5197
6064
|
const [detailError, setDetailError] = useState('');
|
|
6065
|
+
// 卸载三态:confirmRemove = 详情里已展开二次确认;removing = 请求在途;
|
|
6066
|
+
// removeError = 宿主拒绝或网络失败的原因(成功即关详情,不在详情里留错误)。
|
|
6067
|
+
const [confirmRemove, setConfirmRemove] = useState(false);
|
|
6068
|
+
const [removing, setRemoving] = useState(false);
|
|
6069
|
+
const [removeError, setRemoveError] = useState('');
|
|
5198
6070
|
// 分类升级(决策 3/4/7):分类单选 + 网格/列表双视图,全部记忆到 URL hash
|
|
5199
6071
|
// (skillcat/skillview/skillpick),刷新与分享都能回到原处;'' = 默认态不写键。
|
|
5200
6072
|
const [cat, setCat] = useState('');
|
|
5201
6073
|
const [view, setView] = useState('');
|
|
5202
6074
|
const [toast, setToast] = useState(null); // { msg, ok }
|
|
6075
|
+
// 自定义套件三态:suites = 宿主 ~/.dsh/armory/suites.json 的本地镜像,suiteOpen = 管理弹窗开关,
|
|
6076
|
+
// notice = 看板根级浮层提示。notice 必须与 toast 分开——toast 渲染在详情体内(dsh-mcp-skill-d-flash),
|
|
6077
|
+
// 而「套件已保存 / 已放弃未保存的修改」恰恰发生在弹窗关闭的一刻,挂在详情里根本看不见。
|
|
6078
|
+
const [suites, setSuites] = useState([]);
|
|
6079
|
+
const [suiteOpen, setSuiteOpen] = useState(false);
|
|
6080
|
+
const [notice, setNotice] = useState(null); // { msg, ok }
|
|
5203
6081
|
const loadSeq = useRef(0);
|
|
5204
6082
|
const detailSeq = useRef(0);
|
|
5205
6083
|
const toastCancel = useRef(null);
|
|
6084
|
+
const noticeCancel = useRef(null);
|
|
5206
6085
|
// 作用域切换检测(skillpick 只在真换作用域时清)+ skillpick 恢复只做一次的哨兵。
|
|
5207
6086
|
const mountedScope = useRef(null);
|
|
5208
6087
|
const seededPick = useRef(false);
|
|
@@ -5231,6 +6110,17 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5231
6110
|
}
|
|
5232
6111
|
}, [call, scope]);
|
|
5233
6112
|
|
|
6113
|
+
// 套件集合读取。与 listSkills 不同:它读的是本插件自己的数据层(不依赖宿主 skill 注册表),
|
|
6114
|
+
// 失败也不该把整个看板拖进错误态——读不到就当「还没有套件」,与 wb-registry 读侧同一口径。
|
|
6115
|
+
const loadSuites = useCallback(async () => {
|
|
6116
|
+
try {
|
|
6117
|
+
const res = await call('listSkillSuites');
|
|
6118
|
+
if (res && res.ok && Array.isArray(res.suites)) setSuites(res.suites);
|
|
6119
|
+
} catch {
|
|
6120
|
+
// 宿主未重启 / 接口缺失:静默降级为空集合,看板与召唤菜单都还能用。
|
|
6121
|
+
}
|
|
6122
|
+
}, [call]);
|
|
6123
|
+
|
|
5234
6124
|
useEffect(() => {
|
|
5235
6125
|
// cat/view 从 hash 恢复(作用域切换时 hash 仍是唯一真值源,直接重读)。
|
|
5236
6126
|
const seededCat = readHashParam('skillcat');
|
|
@@ -5246,7 +6136,12 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5246
6136
|
setDetailError('');
|
|
5247
6137
|
setQuery('');
|
|
5248
6138
|
load(false);
|
|
5249
|
-
|
|
6139
|
+
loadSuites();
|
|
6140
|
+
}, [load, loadSuites]);
|
|
6141
|
+
|
|
6142
|
+
// 本看板挂着时收到变更(目前主要是自己卸载成功,以及将来其它入口)立即重读真值。
|
|
6143
|
+
// 不挂着时不用补——上面的挂载 effect 每次都会 load(false),切回本页天然是新数据。
|
|
6144
|
+
useEffect(() => onSkillChange(() => { load(true); }), [load]);
|
|
5250
6145
|
|
|
5251
6146
|
const queryKey = (query || '').toLowerCase();
|
|
5252
6147
|
// 分类判定只依赖 name+description、与筛选无关 → 一次 memo 全量映射;
|
|
@@ -5306,7 +6201,10 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5306
6201
|
|
|
5307
6202
|
|
|
5308
6203
|
const openDetail = useCallback(async (name) => {
|
|
6204
|
+
if (removing) return; // 卸载在途时不切换选中,避免成功回调关掉另一条详情
|
|
5309
6205
|
setSelected(name);
|
|
6206
|
+
setConfirmRemove(false);
|
|
6207
|
+
setRemoveError('');
|
|
5310
6208
|
// 选中态记忆到 hash(决策 4),刷新/分享可回到同一技能详情。
|
|
5311
6209
|
writeHashParams({ skillpick: name });
|
|
5312
6210
|
const seq = ++detailSeq.current;
|
|
@@ -5324,7 +6222,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5324
6222
|
} finally {
|
|
5325
6223
|
if (seq === detailSeq.current) setDetailLoading(false);
|
|
5326
6224
|
}
|
|
5327
|
-
}, [call, scope]);
|
|
6225
|
+
}, [call, scope, removing]);
|
|
5328
6226
|
|
|
5329
6227
|
// 关掉网格视图的详情弹窗:选中态、正文、报错、toast 一起清,并把 hash 里的 skillpick 撤回。
|
|
5330
6228
|
// 只清 UI 不清 hash 的话,刷新/分享链接会把弹窗又弹回来,看起来像「关不掉」。
|
|
@@ -5334,10 +6232,19 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5334
6232
|
setDetail(null);
|
|
5335
6233
|
setDetailError('');
|
|
5336
6234
|
setDetailLoading(false);
|
|
6235
|
+
setConfirmRemove(false);
|
|
6236
|
+
setRemoveError('');
|
|
5337
6237
|
setToast(null);
|
|
5338
6238
|
writeHashParams({ skillpick: '' });
|
|
5339
6239
|
}, []);
|
|
5340
6240
|
|
|
6241
|
+
// 卸载在途时拒绝关闭详情:请求不可取消,关掉后失败原因就无处可显示。
|
|
6242
|
+
// 所有「用户主动关」的入口都走这里;removeSelected 成功后用 closeDetail 强关。
|
|
6243
|
+
const requestCloseDetail = useCallback(() => {
|
|
6244
|
+
if (removing) return;
|
|
6245
|
+
closeDetail();
|
|
6246
|
+
}, [removing, closeDetail]);
|
|
6247
|
+
|
|
5341
6248
|
// 弹窗要能被 Escape 关掉,前提是焦点在它里面。ref 回调在节点挂载时触发,
|
|
5342
6249
|
// 比再挂一个 useEffect 更贴近「出现即聚焦」,也省掉一轮渲染。
|
|
5343
6250
|
const focusModal = useCallback((el) => {
|
|
@@ -5359,6 +6266,19 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5359
6266
|
return off;
|
|
5360
6267
|
}, [openDetail]);
|
|
5361
6268
|
|
|
6269
|
+
// 套件弹窗的跨组件入口:召唤菜单底部「管理套件…」发广播 → 这里领走并打开弹窗。
|
|
6270
|
+
// 与 pendingSkillFocus 同构:订阅覆盖「看板已挂载」,自领 pending 覆盖「从别的页签跳过来时
|
|
6271
|
+
// 广播早已发完」;领走即清标志,否则下次再进看板会莫名其妙自己弹一次。
|
|
6272
|
+
useEffect(() => {
|
|
6273
|
+
const open = () => {
|
|
6274
|
+
pendingSuiteOpen = false;
|
|
6275
|
+
setSuiteOpen(true);
|
|
6276
|
+
};
|
|
6277
|
+
const off = onSuiteOpenRequest(open);
|
|
6278
|
+
if (pendingSuiteOpen) open();
|
|
6279
|
+
return off;
|
|
6280
|
+
}, []);
|
|
6281
|
+
|
|
5362
6282
|
// skillpick 恢复(决策 4):列表就绪后按 hash 打开详情,只做一次;scope 切换时哨兵复位。
|
|
5363
6283
|
useEffect(() => {
|
|
5364
6284
|
if (seededPick.current || loading || !skills.length) return;
|
|
@@ -5369,6 +6289,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5369
6289
|
|
|
5370
6290
|
const summary = selected ? skills.find((s) => s.name === selected) : null;
|
|
5371
6291
|
const summaryCat = summary ? (catOf.get(summary.name) || SKILL_UNCAT) : null;
|
|
6292
|
+
|
|
5372
6293
|
// 网格视图不再占右侧详情栏:有选中就浮成居中弹窗;列表视图仍走右侧栏(决策见 §12)。
|
|
5373
6294
|
// 注意 view 的取值是 'list' | ''(网格是空串,见 setView(readHashParam('skillview') === 'list' ...)),
|
|
5374
6295
|
// 所以判据必须是 !== 'list',写成 === 'grid' 会让弹窗永远不出现(卡片点了像没反应)。
|
|
@@ -5395,6 +6316,14 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5395
6316
|
toastCancel.current = timer.timeout(() => setToast(null), 2600);
|
|
5396
6317
|
}, [timer]);
|
|
5397
6318
|
useEffect(() => () => { if (toastCancel.current) toastCancel.current(); }, []);
|
|
6319
|
+
// 看板根级提示(约 2.6s 自动消失):套件弹窗一关就卸载,自己的 toast 活不过那一帧,
|
|
6320
|
+
// 所以「已保存 / 已放弃修改」这类消息一律交给这里,挂在看板根节点上。
|
|
6321
|
+
const showNotice = useCallback((msg, ok) => {
|
|
6322
|
+
setNotice({ msg, ok });
|
|
6323
|
+
if (noticeCancel.current) noticeCancel.current();
|
|
6324
|
+
noticeCancel.current = timer.timeout(() => setNotice(null), 2600);
|
|
6325
|
+
}, [timer]);
|
|
6326
|
+
useEffect(() => () => { if (noticeCancel.current) noticeCancel.current(); }, []);
|
|
5398
6327
|
const copySkillMdPath = () => {
|
|
5399
6328
|
const dir = String((summary && summary.path) || '').replace(/[\\/]+$/, '');
|
|
5400
6329
|
if (!dir) return;
|
|
@@ -5405,12 +6334,36 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5405
6334
|
copyText('@' + summary.name).then((ok) => showToast(ok ? t('已复制到剪贴板') : t('复制失败'), ok));
|
|
5406
6335
|
};
|
|
5407
6336
|
|
|
6337
|
+
// 卸载:宿主按注册表记录的真路径永久删除。成功后先本地收敛列表(注册表可能有扫描缓存,
|
|
6338
|
+
// 等 listSkills 回来才消失会让用户以为没删掉),再广播变更——本看板的订阅者会 load(true)
|
|
6339
|
+
// 让真值覆盖,顶部数量角标也同帧重算;刷新失败也不回滚——文件已经没了,回滚只会制造假象。
|
|
6340
|
+
// 注意定义位置:必须排在 showNotice 之后(依赖数组在渲染期求值,放前面会 TDZ)。
|
|
6341
|
+
const removeSelected = useCallback(async () => {
|
|
6342
|
+
if (!summary || !summary.path || removing) return;
|
|
6343
|
+
const name = summary.name;
|
|
6344
|
+
setConfirmRemove(false);
|
|
6345
|
+
setRemoving(true);
|
|
6346
|
+
setRemoveError('');
|
|
6347
|
+
try {
|
|
6348
|
+
const res = await call('removeSkill', { ...skillScopeArg(scope), name });
|
|
6349
|
+
if (!res || !res.ok) throw new Error((res && res.error) || t('卸载失败'));
|
|
6350
|
+
setSkills((rows) => rows.filter((s) => s.name !== name));
|
|
6351
|
+
closeDetail();
|
|
6352
|
+
showNotice(t('已卸载 {0}', [name]), true);
|
|
6353
|
+
emitSkillChange('skill-board-remove');
|
|
6354
|
+
} catch (e) {
|
|
6355
|
+
setRemoveError(skillTransportHint(String((e && e.message) || e)));
|
|
6356
|
+
} finally {
|
|
6357
|
+
setRemoving(false);
|
|
6358
|
+
}
|
|
6359
|
+
}, [summary, removing, call, scope, closeDetail, showNotice]);
|
|
6360
|
+
|
|
5408
6361
|
// §1.4 空状态四件套:两种空况共用一份 JSX;「清除全部筛选」现在连同分类轨道一起清。
|
|
5409
6362
|
const emptyBoard = skills.length === 0
|
|
5410
6363
|
? h(EmptyState, {
|
|
5411
6364
|
icon: 'skill', title: t('这个作用域下没有发现 skill'),
|
|
5412
6365
|
hint: t('skill 由 skill-filesystem 从项目 .dsh/skills、用户 ~/.dsh/skills 等目录发现;放一个 SKILL.md 进去就会出现在这里。'),
|
|
5413
|
-
actions: [{ label: t('重新扫描'), icon: 'refresh', onClick: () => load(true) }],
|
|
6366
|
+
actions: [{ label: t('重新扫描'), icon: 'refresh', onClick: () => { load(true); loadSuites(); } }],
|
|
5414
6367
|
tight: true,
|
|
5415
6368
|
})
|
|
5416
6369
|
: h(EmptyState, {
|
|
@@ -5447,6 +6400,18 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5447
6400
|
title: t('列表视图'), onClick: () => setViewHash('list'),
|
|
5448
6401
|
}, h(Icon, { d: ICONS.list, size: 13 }), t('列表视图')),
|
|
5449
6402
|
),
|
|
6403
|
+
// 自定义套件入口(决策:按钮放在 skill 看板工具条上,与视图分段控件同一行)。
|
|
6404
|
+
// 徽章显示已保存的套件数——0 时不渲染,避免空看板挂一个「0」误导成有东西可点。
|
|
6405
|
+
h('button', {
|
|
6406
|
+
type: 'button', className: 'dsh-mcp-btn suite-entry',
|
|
6407
|
+
title: t('管理自定义套件:把多个相关 skill 打包,引用一次全部生效'),
|
|
6408
|
+
onClick: () => setSuiteOpen(true),
|
|
6409
|
+
'aria-label': t('管理自定义套件'),
|
|
6410
|
+
},
|
|
6411
|
+
h(Icon, { d: ICONS.layers, size: 13 }),
|
|
6412
|
+
t('套件'),
|
|
6413
|
+
suites.length ? h('span', { className: 'cnt' }, suites.length) : null,
|
|
6414
|
+
),
|
|
5450
6415
|
),
|
|
5451
6416
|
// 分类轨道(决策 5:轨道在搜索下、chips 上;计数固定全量,决策 9)。
|
|
5452
6417
|
// 分类与来源/权限是正交维度——分类筛「它是什么」,来源/权限筛「它从哪来/谁能调用」。
|
|
@@ -5468,13 +6433,14 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5468
6433
|
h('span', null, (loading && !refreshing) ? t('读取中…') : t('显示 {0} / {1} 个 skill', [filtered.length, skills.length])
|
|
5469
6434
|
+ (filtered.length !== skills.length ? t('(共 {0},已筛选)', [skills.length]) : '')),
|
|
5470
6435
|
refreshing ? h('span', null, t(' · 刷新中…')) : null,
|
|
5471
|
-
// OPT-31 · 5.1
|
|
5472
|
-
// listSkills / skillDetail
|
|
5473
|
-
//
|
|
5474
|
-
//
|
|
6436
|
+
// OPT-31 · 5.1:把「能做什么、不能做什么」从代码注释提到界面上。
|
|
6437
|
+
// 宿主注册表本身只有 listSkills / skillDetail 两个读接口;卸载是本插件补的
|
|
6438
|
+
// removeSkill(仅限用户级/项目级目录)。这层边界不写明,用户会把「随包分发 /
|
|
6439
|
+
// 运行时注册不可卸载」当成缺陷反复找开关。
|
|
6440
|
+
// 位置:筛选条之后、列表之前——读列表前先知道能力边界,比滚到底才发现划算。
|
|
5475
6441
|
h('div', { className: 'dsh-mcp-readonly-note' },
|
|
5476
6442
|
h(Icon, { d: ICONS.eye, size: 12 }),
|
|
5477
|
-
h('span', null, t('
|
|
6443
|
+
h('span', null, t('宿主只提供 skill 的读取接口;这里可以卸载用户级/项目级目录里的 skill(永久删除,不可撤销)。随 DSH 分发、运行时注册、自定义目录的 skill 不可卸载——要改内容请直接编辑 SKILL.md。')),
|
|
5478
6444
|
),
|
|
5479
6445
|
),
|
|
5480
6446
|
h('div', { className: 'dsh-mcp-skill-filters' },
|
|
@@ -5580,7 +6546,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5580
6546
|
// 网格视图的详情以弹窗呈现(与市场卡片详情同一套观感),列表视图仍走右侧栏。
|
|
5581
6547
|
// 遮罩单独做一层兄弟节点而不是包住弹窗:点遮罩即关,点弹窗内部不会冒泡到它。
|
|
5582
6548
|
isModal
|
|
5583
|
-
? h('div', { className: 'dsh-mcp-skill-modal-hit', onClick:
|
|
6549
|
+
? h('div', { className: 'dsh-mcp-skill-modal-hit', onClick: requestCloseDetail })
|
|
5584
6550
|
: null,
|
|
5585
6551
|
h('div', {
|
|
5586
6552
|
className: 'dsh-mcp-skill-detail' + (isModal ? ' is-modal' : ''),
|
|
@@ -5593,7 +6559,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5593
6559
|
? (e) => {
|
|
5594
6560
|
if (e.key === 'Escape') {
|
|
5595
6561
|
e.stopPropagation();
|
|
5596
|
-
|
|
6562
|
+
requestCloseDetail();
|
|
5597
6563
|
}
|
|
5598
6564
|
}
|
|
5599
6565
|
: undefined,
|
|
@@ -5605,7 +6571,7 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5605
6571
|
className: 'dsh-mcp-skill-d-close',
|
|
5606
6572
|
title: t('关闭'),
|
|
5607
6573
|
'aria-label': t('关闭'),
|
|
5608
|
-
onClick:
|
|
6574
|
+
onClick: requestCloseDetail,
|
|
5609
6575
|
}, h(Icon, { d: ICONS.x, size: 14 }))
|
|
5610
6576
|
: null,
|
|
5611
6577
|
!summary && !detailLoading
|
|
@@ -5655,7 +6621,31 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5655
6621
|
h('button', {
|
|
5656
6622
|
type: 'button', className: 'dsh-mcp-btn', onClick: copySessionRef,
|
|
5657
6623
|
}, h(Icon, { d: ICONS.copy, size: 13 }), t('复制 @会话引用')),
|
|
6624
|
+
// 卸载入口只在「来源可定位到用户级/项目级 skills 根」且确有磁盘路径时出现。
|
|
6625
|
+
// 二次确认就地展开(不弹窗、不移位),确认按钮为危险色,取消在左侧。
|
|
6626
|
+
skillRemovable(summary.source) && summary.path
|
|
6627
|
+
? (confirmRemove
|
|
6628
|
+
? h(InlineConfirm, {
|
|
6629
|
+
question: t('将从磁盘永久删除 {0}(不可撤销),确认卸载?', [summary.name]),
|
|
6630
|
+
confirmLabel: removing ? t('卸载中…') : t('确认卸载'),
|
|
6631
|
+
busy: removing,
|
|
6632
|
+
onConfirm: removeSelected,
|
|
6633
|
+
onCancel: () => setConfirmRemove(false),
|
|
6634
|
+
})
|
|
6635
|
+
: h('button', {
|
|
6636
|
+
type: 'button', className: 'dsh-mcp-btn danger', disabled: removing,
|
|
6637
|
+
title: t('永久删除这个 skill 所在目录(不可撤销)'),
|
|
6638
|
+
onClick: () => { setRemoveError(''); setConfirmRemove(true); },
|
|
6639
|
+
}, h(Icon, { d: ICONS.trash, size: 13 }), t('卸载')))
|
|
6640
|
+
: null,
|
|
5658
6641
|
),
|
|
6642
|
+
!skillRemovable(summary.source)
|
|
6643
|
+
? h('div', { className: 'dsh-mcp-readonly-note' },
|
|
6644
|
+
h(Icon, { d: ICONS.eye, size: 12 }),
|
|
6645
|
+
h('span', null, t('这个 skill 来自「{0}」,不在可卸载范围内:随 DSH 分发、运行时注册与自定义目录的 skill 只能查看。要停用它,请编辑所属预设或 SKILL.md。', [skillSourceLabel(summary.source)])),
|
|
6646
|
+
)
|
|
6647
|
+
: null,
|
|
6648
|
+
removeError ? h('div', { className: 'dsh-mcp-d-err' }, removeError) : null,
|
|
5659
6649
|
detailLoading ? h('div', { className: 'dsh-mcp-skill-empty' }, t('读取 skill 内容…')) : null,
|
|
5660
6650
|
detailError ? h('div', { className: 'dsh-mcp-d-err' }, detailError) : null,
|
|
5661
6651
|
detail && detail.content !== undefined ? h('div', { className: 'dsh-mcp-skill-d-sec' },
|
|
@@ -5666,6 +6656,23 @@ const SkillBoard = ({ call, scope, timer }) => {
|
|
|
5666
6656
|
) : null,
|
|
5667
6657
|
),
|
|
5668
6658
|
) : null,
|
|
6659
|
+
// 套件管理弹窗:挂在「看板可用」判据**之外**——它读的是本插件自己的数据层,
|
|
6660
|
+
// 就算宿主没挂载 skill 注册表(error 态、工具条不渲染),召唤菜单里的「管理套件…」
|
|
6661
|
+
// 也仍应能把它打开,只是左侧候选列表是空的。
|
|
6662
|
+
suiteOpen
|
|
6663
|
+
? h(SuiteManager, {
|
|
6664
|
+
call,
|
|
6665
|
+
skills,
|
|
6666
|
+
catOf,
|
|
6667
|
+
suites,
|
|
6668
|
+
onClose: () => setSuiteOpen(false),
|
|
6669
|
+
// 保存成功后一律以宿主返回的集合为真值:新分配的 id 只出现在返回值里,
|
|
6670
|
+
// 拿本地副本回填会让同一条套件在下次保存时被当成「新建」再存一份。
|
|
6671
|
+
onSaved: setSuites,
|
|
6672
|
+
notify: showNotice,
|
|
6673
|
+
})
|
|
6674
|
+
: null,
|
|
6675
|
+
notice ? h('div', { className: 'dsh-mcp-toast ' + (notice.ok ? 'ok' : 'err'), role: 'status' }, notice.msg) : null,
|
|
5669
6676
|
);
|
|
5670
6677
|
};
|
|
5671
6678
|
|
|
@@ -5684,32 +6691,32 @@ const connectorMatchesVisibleIn = (rowVisibleTags, selectedTags) => {
|
|
|
5684
6691
|
return rowVisibleTags.some((tag) => selectedTags.includes(tag));
|
|
5685
6692
|
};
|
|
5686
6693
|
const WB_SKILLHUB_SORTS = [
|
|
5687
|
-
{ key: 'downloads', label
|
|
5688
|
-
{ key: 'installs', label
|
|
5689
|
-
{ key: 'stars', label
|
|
5690
|
-
{ key: 'score', label
|
|
6694
|
+
{ key: 'downloads', get label() { return t('下载量(本页排序)'); } },
|
|
6695
|
+
{ key: 'installs', get label() { return t('最多安装'); } },
|
|
6696
|
+
{ key: 'stars', get label() { return t('最多星标'); } },
|
|
6697
|
+
{ key: 'score', get label() { return t('综合评分'); } },
|
|
5691
6698
|
];
|
|
5692
6699
|
// T05/R10:连接器排序(全量本地排序,不受分页影响)。
|
|
5693
6700
|
// 目录自带 skillCount(捆绑技能数)与 visibleIn(适用环境标签数),够做有意义的排序;
|
|
5694
6701
|
// upstream 无下载量/评分字段,不臆造。
|
|
5695
6702
|
const WB_CONNECTOR_SORTS = [
|
|
5696
|
-
{ key: 'default', label
|
|
5697
|
-
{ key: 'skills', label
|
|
5698
|
-
{ key: 'name', label
|
|
6703
|
+
{ key: 'default', get label() { return t('目录默认顺序'); } },
|
|
6704
|
+
{ key: 'skills', get label() { return t('技能数由多到少'); } },
|
|
6705
|
+
{ key: 'name', get label() { return t('名称 A→Z'); } },
|
|
5699
6706
|
];
|
|
5700
6707
|
// T06/Awesome:DSH 社区目录排序(全量本地排序,不受分页影响)。
|
|
5701
6708
|
// upstream 自带 stars 与 lastVerified,无下载量/评分字段,不臆造。
|
|
5702
6709
|
const AWESOME_SORTS = [
|
|
5703
|
-
{ key: 'stars', label
|
|
5704
|
-
{ key: 'recent', label
|
|
5705
|
-
{ key: 'name', label
|
|
6710
|
+
{ key: 'stars', get label() { return t('最多星标'); } },
|
|
6711
|
+
{ key: 'recent', get label() { return t('最新收录'); } },
|
|
6712
|
+
{ key: 'name', get label() { return t('名称 A→Z'); } },
|
|
5706
6713
|
];
|
|
5707
6714
|
const AWESOME_CATEGORIES = [
|
|
5708
|
-
{ key: 'all', label
|
|
5709
|
-
{ key: 'plugin', label
|
|
5710
|
-
{ key: 'skill', label
|
|
5711
|
-
{ key: 'bundle', label
|
|
5712
|
-
{ key: 'tool', label
|
|
6715
|
+
{ key: 'all', get label() { return t('全部'); } },
|
|
6716
|
+
{ key: 'plugin', get label() { return t('插件'); } },
|
|
6717
|
+
{ key: 'skill', get label() { return t('技能'); } },
|
|
6718
|
+
{ key: 'bundle', get label() { return t('套件'); } },
|
|
6719
|
+
{ key: 'tool', get label() { return t('工具'); } },
|
|
5713
6720
|
];
|
|
5714
6721
|
const AWESOME_TAGS = ['ui', 'terminal', 'agents', 'memory', 'models', 'vision', 'capabilities', 'channels', 'notifications', 'usage', 'observability', 'marketplace', 'devtools', 'safety', 'knowledge', 'interop', 'fun'];
|
|
5715
6722
|
const AWESOME_PAGE_SIZE = 50;
|
|
@@ -6392,15 +7399,18 @@ const expertCardActions = ({ summoned, expertType, personaText }) => ({
|
|
|
6392
7399
|
// 也不要让一条读不出来的旧行悄悄用坏人设开新会话)。
|
|
6393
7400
|
const personaTextOf = (rows, plugin) => (Array.isArray(rows) ? rows.find((row) => row?.plugin === plugin)?.personaText : '') || '';
|
|
6394
7401
|
const QUICK_MENU_KINDS = [
|
|
6395
|
-
{ key: 'skill', label
|
|
6396
|
-
{ key: 'mcp', label: 'MCP', icon: 'plug', hint
|
|
6397
|
-
{ key: 'expert', label
|
|
7402
|
+
{ key: 'skill', get label() { return t('技能'); }, icon: 'skill', get hint() { return t('技能按需触发:选择后以 /技能名 插入输入框'); } },
|
|
7403
|
+
{ key: 'mcp', label: 'MCP', icon: 'plug', get hint() { return t('MCP 工具按 server 生效:选择后点名 mcp__服务__* 工具'); } },
|
|
7404
|
+
{ key: 'expert', get label() { return t('专家'); }, icon: 'expert', get hint() { return t('专家随时挂/换/摘:点一个即接管本会话,再点别的直接换,点 chip 的 × 取消'); } },
|
|
6398
7405
|
];
|
|
6399
7406
|
// 子菜单列表每屏固定 6 行(6 × 28px),更多条目在 .dsh-mcp-quick-list 里纵向滚动;
|
|
6400
7407
|
// 「最近」徽章只标最近池里的前 6 名(首屏可见的那批),避免整列都是徽章。
|
|
6401
7408
|
// 专家预设显示名前缀:召唤时写死的 `WorkBuddy 专家 · <显示名>`(见 expert-market.js
|
|
6402
7409
|
// buildExpertPresetRow),宿主的会话头预设标签原样显示它——用来识别「那条会过期的标签」。
|
|
6403
|
-
|
|
7410
|
+
// 这是**匹配用**前缀,不是展示文案:宿主 expert-market.js 写入预设定名时永远用
|
|
7411
|
+
// 中文 `WorkBuddy 专家 · `(见 buildExpertPresetRow),这里必须逐字相同。
|
|
7412
|
+
// 不能包 t()——英文环境下翻译成 "WorkBuddy Expert · " 会永远匹配不上宿主标签。
|
|
7413
|
+
const EXPERT_PRESET_LABEL_PREFIX = 'WorkBuddy 专家 · ';
|
|
6404
7414
|
// 专家挂载状态的最小事件总线:快速菜单挂/换/摘专家后立刻通知会话头徽章与菜单 chip,
|
|
6405
7415
|
// 三处显示同一帧内对齐(不再各自等重挂载)。低频轮询只作兜底,覆盖「别的入口挂的专家」。
|
|
6406
7416
|
const expertChangeListeners = new Set();
|
|
@@ -6430,6 +7440,7 @@ const quickMentionText = (kind, item) => {
|
|
|
6430
7440
|
return /^[\w-]+$/.test(token) ? `/${token} ` : t('{0}:', [token]);
|
|
6431
7441
|
};
|
|
6432
7442
|
const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
7443
|
+
useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
|
|
6433
7444
|
const [open, setOpen] = useState(false);
|
|
6434
7445
|
const [hoverKind, setHoverKind] = useState(null);
|
|
6435
7446
|
const [recent, setRecent] = useState(null); // { skill: [], mcp: [], expert: [] }
|
|
@@ -6449,20 +7460,34 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
6449
7460
|
// MCP 行字段是 serverName(gateway list() 的行形状,与面板列表同一来源);禁用的不进菜单。
|
|
6450
7461
|
const loadRecent = useCallback(async () => {
|
|
6451
7462
|
setListError('');
|
|
6452
|
-
const [recentRes, skillsRes, mcpRes, expertRes] = await Promise.all([
|
|
7463
|
+
const [recentRes, skillsRes, mcpRes, expertRes, suitesRes] = await Promise.all([
|
|
6453
7464
|
call('wbExpertRecent'),
|
|
6454
7465
|
call('listSkills', skillScopeArg('global')),
|
|
6455
7466
|
call('list'),
|
|
6456
7467
|
call('wbExpertInstalled'),
|
|
7468
|
+
// 套件读本插件自己的数据层(不是宿主接口),失败不该连累整个菜单——Promise.all 是
|
|
7469
|
+
// 「一败全败」,所以单独兜住:宿主还没重启(接口 404)时菜单照常出技能/MCP/专家。
|
|
7470
|
+
call('listSkillSuites').catch(() => ({ ok: false })),
|
|
6457
7471
|
]);
|
|
6458
7472
|
const kinds = recentRes?.ok && recentRes.kinds ? recentRes.kinds : {};
|
|
6459
7473
|
const skillRows = skillsRes?.ok && Array.isArray(skillsRes.skills) ? skillsRes.skills : [];
|
|
6460
7474
|
const mcpRows = mcpRes?.ok && Array.isArray(mcpRes.servers) ? mcpRes.servers.filter((server) => !server.disabled && server.serverName) : [];
|
|
6461
7475
|
const expertRows = expertRes?.ok && Array.isArray(expertRes.presets) ? expertRes.presets : [];
|
|
7476
|
+
const suiteRows = suitesRes?.ok && Array.isArray(suitesRes.suites) ? suitesRes.suites : [];
|
|
6462
7477
|
setRecent({
|
|
6463
7478
|
skill: skillRows.map((row) => ({ name: row.name, description: row.description || row.whenToUse || '' })),
|
|
6464
7479
|
mcp: mcpRows.map((row) => ({ name: row.serverName, description: row.url || row.command || row.transport || '' })),
|
|
6465
7480
|
expert: expertRows.map((row) => ({ plugin: row.plugin, name: row.plugin, presetId: row.presetId })),
|
|
7481
|
+
// 数据层读侧已经滤掉「无名/无成员」的残缺条目,这里再兜一次手改文件的形状:
|
|
7482
|
+
// 空套件展开后什么都插不进草稿,留在菜单里只是个点了没反应的死条目。
|
|
7483
|
+
suite: suiteRows
|
|
7484
|
+
.filter((row) => row && row.name && Array.isArray(row.members) && row.members.length)
|
|
7485
|
+
.map((row) => ({
|
|
7486
|
+
id: String(row.id || ''),
|
|
7487
|
+
name: String(row.name),
|
|
7488
|
+
desc: String(row.desc || ''),
|
|
7489
|
+
members: row.members.map((name) => String(name)),
|
|
7490
|
+
})),
|
|
6466
7491
|
recentSkill: Array.isArray(kinds.skill) ? kinds.skill : [],
|
|
6467
7492
|
recentMcp: Array.isArray(kinds.mcp) ? kinds.mcp : [],
|
|
6468
7493
|
recentExpert: Array.isArray(kinds.expert) ? kinds.expert : [],
|
|
@@ -6534,17 +7559,15 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
6534
7559
|
return () => { document.removeEventListener('click', onDocClick); document.removeEventListener('keydown', onKey); };
|
|
6535
7560
|
}, [open]);
|
|
6536
7561
|
|
|
6537
|
-
//
|
|
7562
|
+
// 插入动作(技能 / MCP / 套件共用):把引用插进输入框光标处。
|
|
6538
7563
|
// 写通道(按优先级):
|
|
6539
7564
|
// ① inputActions.insertText(text, captureInsertion()) —— composer 的 standard provide channel
|
|
6540
7565
|
// (InputBar 同款 useInput/inputActions),在光标处插入并保留现有草稿,最贴近 WorkBuddy;
|
|
6541
7566
|
// ② inputActions.setDraft(现草稿 + 引用) —— insertText 不可用时的整草稿兜底;
|
|
6542
7567
|
// ③ 复制进剪贴板 + 按钮上闪现「已复制」(绝不跳走:跳页是上一版的行为缺陷)。
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6546
|
-
const row = kind === 'mcp' ? { serverName: item.name } : { name: item.name };
|
|
6547
|
-
call('wbExpertRecordUse', { kind, row }).catch(() => {});
|
|
7568
|
+
// 套件展开出来是一整串 `/a /b /c`,走的正是同一条通道——所以「引用套件」不需要
|
|
7569
|
+
// 宿主认识套件这个概念,宿主看到的仍然只是普通草稿文本。
|
|
7570
|
+
const insertRef = async (text) => {
|
|
6548
7571
|
const insertion = typeof text === 'string' ? (text.endsWith(' ') ? text : text + ' ') : String(text);
|
|
6549
7572
|
if (inputActions && typeof inputActions.captureInsertion === 'function' && typeof inputActions.insertText === 'function') {
|
|
6550
7573
|
try {
|
|
@@ -6566,6 +7589,25 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
6566
7589
|
setTimeout(() => setCopied(''), 1600);
|
|
6567
7590
|
} catch { /* 剪贴板不可用:静默 */ }
|
|
6568
7591
|
};
|
|
7592
|
+
const pick = async (kind, item, text) => {
|
|
7593
|
+
setOpen(false); setHoverKind(null);
|
|
7594
|
+
// 上报最近使用(失败静默:纯增值数据)。expert 不经 pick(点击分支直接 attachExpert)。
|
|
7595
|
+
const row = kind === 'mcp' ? { serverName: item.name } : { name: item.name };
|
|
7596
|
+
call('wbExpertRecordUse', { kind, row }).catch(() => {});
|
|
7597
|
+
await insertRef(text);
|
|
7598
|
+
};
|
|
7599
|
+
// 引用一个套件 = 把成员逐个展开成引用文本拼成一段插入草稿(纯插件侧展开,宿主零改动)。
|
|
7600
|
+
// 文本用 quickMentionText('skill', …) 逐成员生成,与单个技能的引用格式完全同源:
|
|
7601
|
+
// 合法标识符走 `/name`,中文名等走 `名字:`——两处各写一套正则必然漂移。
|
|
7602
|
+
// 最近使用按**成员**上报而不是按套件:技能最近池里只该出现真实的 skill 名,
|
|
7603
|
+
// 记套件名会污染池子(子菜单的「最近」徽章拿套件名去匹技能,永远匹配不上)。
|
|
7604
|
+
const pickSuite = async (suite) => {
|
|
7605
|
+
setOpen(false); setHoverKind(null);
|
|
7606
|
+
const members = (Array.isArray(suite?.members) ? suite.members : []).map((name) => String(name).trim()).filter(Boolean);
|
|
7607
|
+
if (!members.length) return;
|
|
7608
|
+
for (const name of members) call('wbExpertRecordUse', { kind: 'skill', row: { name } }).catch(() => {});
|
|
7609
|
+
await insertRef(members.map((name) => quickMentionText('skill', { name }).trim()).join(' ') + ' ');
|
|
7610
|
+
};
|
|
6569
7611
|
|
|
6570
7612
|
// 子菜单排序:最近使用的条目按 usedAt 新→旧置顶(带「最近」标记,只标首屏 6 个),
|
|
6571
7613
|
// 未命中最近记录的排在后面保持注册表原序。主键 per kind:skill/mcp=条目名,expert=plugin。
|
|
@@ -6734,6 +7776,13 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
6734
7776
|
QUICK_MENU_KINDS.map((kind) => {
|
|
6735
7777
|
const items = subItems(kind.key);
|
|
6736
7778
|
const expanded = hoverKind === kind.key;
|
|
7779
|
+
// 套件只在「技能」子菜单里出现,且作为独立分组置顶(决策:与单个技能同级但分组,
|
|
7780
|
+
// 让用户一眼看出「这一条点下去是同时引用好几个 skill」)。
|
|
7781
|
+
const suites = kind.key === 'skill' && recent && Array.isArray(recent.suite) ? recent.suite : [];
|
|
7782
|
+
const skillRows = Array.isArray(items) ? items : [];
|
|
7783
|
+
// 子菜单的空态判据要把套件算进来:只有套件、一个技能都没有时也是「有内容」,
|
|
7784
|
+
// 否则 .empty 会把分组头和条目一起按空态样式压掉。
|
|
7785
|
+
const hasAny = skillRows.length > 0 || suites.length > 0;
|
|
6737
7786
|
return h('div', {
|
|
6738
7787
|
key: kind.key,
|
|
6739
7788
|
className: 'dsh-mcp-quick-row' + (expanded && flipKinds.has(kind.key) ? ' flip' : ''),
|
|
@@ -6755,14 +7804,35 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
6755
7804
|
},
|
|
6756
7805
|
h(Icon, { d: ICONS[kind.icon], size: 13 }),
|
|
6757
7806
|
h('span', null, kind.label),
|
|
6758
|
-
|
|
7807
|
+
// 计数只报技能本身(与主菜单「这一类有多少条」的语义一致),不把套件数混进去。
|
|
7808
|
+
skillRows.length ? h('span', { className: 'dsh-mcp-quick-count' }, skillRows.length) : null,
|
|
6759
7809
|
h('span', { className: 'dsh-mcp-quick-arrow', 'aria-hidden': true }, '›'),
|
|
6760
7810
|
),
|
|
6761
7811
|
hoverKind === kind.key
|
|
6762
|
-
? h('div', { className: 'dsh-mcp-quick-sub' + (
|
|
6763
|
-
|
|
7812
|
+
? h('div', { className: 'dsh-mcp-quick-sub' + (hasAny ? '' : ' empty'), role: 'menu', 'aria-label': kind.label + t('可选项') },
|
|
7813
|
+
hasAny
|
|
6764
7814
|
? h('div', { className: 'dsh-mcp-quick-list', role: 'group', 'aria-label': kind.label + t('列表(最多显示 6 行,滚动查看更多)') },
|
|
6765
|
-
|
|
7815
|
+
// ── 套件分组(仅技能子菜单)─────────────────────────────
|
|
7816
|
+
suites.length ? h('div', { className: 'dsh-mcp-quick-group', 'aria-hidden': true },
|
|
7817
|
+
h(Icon, { d: ICONS.layers, size: 11 }),
|
|
7818
|
+
t('套件'),
|
|
7819
|
+
) : null,
|
|
7820
|
+
suites.map((suite, index) => h('button', {
|
|
7821
|
+
key: suite.id || suite.name || index,
|
|
7822
|
+
type: 'button',
|
|
7823
|
+
role: 'menuitem',
|
|
7824
|
+
className: 'dsh-mcp-quick-item suite',
|
|
7825
|
+
title: t('引用套件:展开为 {0} 个 /name', [suite.members.length]),
|
|
7826
|
+
onClick: (e) => { e.stopPropagation(); pickSuite(suite); },
|
|
7827
|
+
},
|
|
7828
|
+
h('span', { className: 'ic', 'aria-hidden': true }, h(Icon, { d: ICONS.layers, size: 12 })),
|
|
7829
|
+
h('span', { className: 'dsh-mcp-quick-item-name' }, suite.name),
|
|
7830
|
+
h('span', { className: 'dsh-mcp-quick-item-badge' }, t('{0} 技能', [suite.members.length])),
|
|
7831
|
+
suite.desc ? h('span', { className: 'dsh-mcp-quick-item-desc', title: suite.desc }, suite.desc) : null,
|
|
7832
|
+
)),
|
|
7833
|
+
// 分隔线只在两段都有内容时画:只有一段时它是条多余的尾巴。
|
|
7834
|
+
suites.length && skillRows.length ? h('div', { className: 'dsh-mcp-quick-sep', role: 'separator' }) : null,
|
|
7835
|
+
skillRows.map((item, index) => h('button', {
|
|
6766
7836
|
key: item.name || item.plugin || index,
|
|
6767
7837
|
type: 'button',
|
|
6768
7838
|
role: 'menuitem',
|
|
@@ -6789,6 +7859,21 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
6789
7859
|
},
|
|
6790
7860
|
h('span', { className: 'dsh-mcp-quick-item-name' }, t('管理') + kind.label + '…'),
|
|
6791
7861
|
),
|
|
7862
|
+
// 套件管理入口只在技能子菜单里给(套件就是技能的组合,放在别处会找不到语境)。
|
|
7863
|
+
// 走 openSuiteManager():它广播 + 切到 skill 看板,弹窗由 SkillBoard 那边挂出来——
|
|
7864
|
+
// 菜单自己是输入框旁的临时浮层,不适合承载一个模态对话框。
|
|
7865
|
+
kind.key === 'skill'
|
|
7866
|
+
? h('button', {
|
|
7867
|
+
type: 'button',
|
|
7868
|
+
role: 'menuitem',
|
|
7869
|
+
className: 'dsh-mcp-quick-manage',
|
|
7870
|
+
title: t('新建或修改套件(把多个相关 skill 打包,引用一次全部生效)'),
|
|
7871
|
+
onClick: (e) => { e.stopPropagation(); setOpen(false); setHoverKind(null); openSuiteManager(); },
|
|
7872
|
+
},
|
|
7873
|
+
h('span', { className: 'ic', 'aria-hidden': true }, h(Icon, { d: ICONS.layers, size: 11 })),
|
|
7874
|
+
h('span', { className: 'dsh-mcp-quick-item-name' }, t('管理套件…')),
|
|
7875
|
+
)
|
|
7876
|
+
: null,
|
|
6792
7877
|
)
|
|
6793
7878
|
: null,
|
|
6794
7879
|
);
|
|
@@ -6807,6 +7892,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
|
|
|
6807
7892
|
// 宿主标签在 540px 以下隐藏,本徽章跟随同一断点(container query 无法跨包复用,
|
|
6808
7893
|
// 用媒体查询近似窄窗行为)。
|
|
6809
7894
|
const SessionExpertBadge = ({ call, sessionId }) => {
|
|
7895
|
+
useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
|
|
6810
7896
|
const [attached, setAttached] = useState(null);
|
|
6811
7897
|
const [ready, setReady] = useState(false);
|
|
6812
7898
|
const badgeRef = useRef(null);
|
|
@@ -6969,6 +8055,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
6969
8055
|
const [skillKeywordInput, setSkillKeywordInput] = useState('');
|
|
6970
8056
|
const [skills, setSkills] = useState([]);
|
|
6971
8057
|
const [skillTotal, setSkillTotal] = useState(0);
|
|
8058
|
+
// 技能侧自己的加载/错误态:此前与连接器共用 `loading`/`error`,两种请求谁先回来谁就
|
|
8059
|
+
// 把对方的态改掉——连接器 1 秒读完就把 loading 置 false,技能还在飞,于是技能页先画
|
|
8060
|
+
// 「技能目录暂时是空的 / 可能还没同步下来」;等技能真的超时,又把整页 banner 改成报错。
|
|
8061
|
+
// 拆开之后,技能慢只影响技能页自己的 loading/error。
|
|
8062
|
+
const [skillLoading, setSkillLoading] = useState(true);
|
|
8063
|
+
const [skillError, setSkillError] = useState('');
|
|
6972
8064
|
const [skillDetail, setSkillDetail] = useState(null);
|
|
6973
8065
|
const [installedSkills, setInstalledSkills] = useState(new Set());
|
|
6974
8066
|
// §6.3 已安装筛选:SkillHub 每页 20 条远程分页,而「已装」是本地集合,
|
|
@@ -7029,17 +8121,21 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7029
8121
|
}
|
|
7030
8122
|
}, [call]);
|
|
7031
8123
|
|
|
8124
|
+
// 技能列表请求序号:搜索防抖与翻页会连发多个请求,慢的那个回来时不能盖掉新的结果。
|
|
8125
|
+
const skillSeq = useRef(0);
|
|
7032
8126
|
const loadSkills = useCallback(async (page, sort, keyword) => {
|
|
7033
|
-
|
|
8127
|
+
const seq = ++skillSeq.current;
|
|
8128
|
+
setSkillLoading(true); setSkillError('');
|
|
7034
8129
|
try {
|
|
7035
8130
|
const res = await call('wbSkillCatalog', { page, sort, keyword, pageSize: WB_PAGE_SIZE });
|
|
8131
|
+
if (seq !== skillSeq.current) return; // 过期响应,直接丢弃
|
|
7036
8132
|
if (!res?.ok) throw new Error(res?.error || t('读取技能目录失败'));
|
|
7037
8133
|
setSkills(res.skills || []);
|
|
7038
8134
|
setSkillTotal(res.total || 0);
|
|
7039
8135
|
} catch (e) {
|
|
7040
|
-
|
|
8136
|
+
if (seq === skillSeq.current) setSkillError(String(e?.message || e));
|
|
7041
8137
|
} finally {
|
|
7042
|
-
|
|
8138
|
+
if (seq === skillSeq.current) setSkillLoading(false);
|
|
7043
8139
|
}
|
|
7044
8140
|
}, [call]);
|
|
7045
8141
|
|
|
@@ -7273,6 +8369,9 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7273
8369
|
if (!res?.ok) throw new Error(res?.error || t('安装失败'));
|
|
7274
8370
|
setNote(t('{0}:{1}', [skill.name, res.note || t('已安装')]));
|
|
7275
8371
|
setSkillDetail(null);
|
|
8372
|
+
// 磁盘上的 skill 集合已经变了:广播出去,顶部分类计数与(切回时的)Skill 看板
|
|
8373
|
+
// 立即按新集合重算,不再等用户离开看板/重挂载。市场自己的列表照旧局部刷新。
|
|
8374
|
+
emitSkillChange('market-install');
|
|
7276
8375
|
await loadInstalled();
|
|
7277
8376
|
await loadInstallState();
|
|
7278
8377
|
} catch (e) {
|
|
@@ -7288,6 +8387,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7288
8387
|
if (!res?.ok) throw new Error(res?.error || t('卸载失败'));
|
|
7289
8388
|
setNote(t('{0}:{1}', [skill.name, res.note || t('已卸载')]));
|
|
7290
8389
|
setSkillDetail(null);
|
|
8390
|
+
emitSkillChange('market-uninstall');
|
|
7291
8391
|
await loadInstalled();
|
|
7292
8392
|
await loadInstallState();
|
|
7293
8393
|
} catch (e) {
|
|
@@ -7334,16 +8434,19 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7334
8434
|
const builtinNeedsConfig = (item) => Array.isArray(item.configKeys) && item.configKeys.length > 0;
|
|
7335
8435
|
const builtinNeedsArgs = (item) => Array.isArray(item.placeholders) && item.placeholders.length > 0;
|
|
7336
8436
|
const builtinReady = (item) => !builtinNeedsKey(item) && !builtinNeedsConfig(item) && !builtinNeedsArgs(item);
|
|
7337
|
-
|
|
8437
|
+
// P1:目录随 RPC 同时送中英字段,这里按当前 locale 投影一次。只在 47 条上跑,
|
|
8438
|
+
// 不做 useMemo——locale 变化时 useI18nRevision 已强制重渲染,缓存反而会留下旧语言。
|
|
8439
|
+
const localizedBuiltins = builtins.map((item) => localizeBuiltinItem(item));
|
|
8440
|
+
const builtinCategories = (() => {
|
|
7338
8441
|
const counts = new Map();
|
|
7339
|
-
for (const item of
|
|
8442
|
+
for (const item of localizedBuiltins) {
|
|
7340
8443
|
const key = item.category || 'other';
|
|
7341
8444
|
const entry = counts.get(key) || { label: item.categoryLabel || key, count: 0 };
|
|
7342
8445
|
entry.count += 1;
|
|
7343
8446
|
counts.set(key, entry);
|
|
7344
8447
|
}
|
|
7345
8448
|
return [...counts.entries()].map(([key, entry]) => ({ key, ...entry }));
|
|
7346
|
-
}
|
|
8449
|
+
})();
|
|
7347
8450
|
const builtinQueryKey = builtinQuery.trim().toLowerCase();
|
|
7348
8451
|
// ── T03/R5:codebuddy 插件市场 ──
|
|
7349
8452
|
// 目录一次全量(官方 zip 里的 external_plugins 清单,无分页概念),本地过滤。
|
|
@@ -7597,7 +8700,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7597
8700
|
const awesomePageCount = Math.max(1, Math.ceil(sortedAwesome.length / AWESOME_PAGE_SIZE));
|
|
7598
8701
|
const safeAwesomePage = Math.min(awesomePage, awesomePageCount);
|
|
7599
8702
|
const visibleAwesome = sortedAwesome.slice((safeAwesomePage - 1) * AWESOME_PAGE_SIZE, safeAwesomePage * AWESOME_PAGE_SIZE);
|
|
7600
|
-
const filteredBuiltins =
|
|
8703
|
+
const filteredBuiltins = localizedBuiltins.filter((item) => {
|
|
7601
8704
|
if (builtinCategory !== 'all' && (item.category || 'other') !== builtinCategory) return false;
|
|
7602
8705
|
if (builtinOnlyReady && !builtinReady(item)) return false;
|
|
7603
8706
|
if (!builtinQueryKey) return true;
|
|
@@ -7622,7 +8725,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7622
8725
|
onChange: (value) => { setConnectorQuery(value); setConnectorPage(1); },
|
|
7623
8726
|
});
|
|
7624
8727
|
const skillSearch = h(WbSearchBox, {
|
|
7625
|
-
value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), disabled:
|
|
8728
|
+
value: skillKeywordInput, placeholder: t('搜索 SkillHub 技能(19 万+)'), disabled: skillLoading,
|
|
7626
8729
|
onChange: (value) => setSkillKeywordInput(value),
|
|
7627
8730
|
// 回车跳过防抖立即生效(防抖本身也会触发,双保险但幂等)。
|
|
7628
8731
|
onCommit: () => { setSkillPage(1); setSkillKeyword(skillKeywordInput); },
|
|
@@ -7780,11 +8883,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7780
8883
|
? h('div', { className: 'dsh-mcp-wb-toolbar' },
|
|
7781
8884
|
skillSearch,
|
|
7782
8885
|
h('select', {
|
|
7783
|
-
className: 'dsh-mcp-input dsh-mcp-wb-sort', value: skillSort, disabled:
|
|
8886
|
+
className: 'dsh-mcp-input dsh-mcp-wb-sort', value: skillSort, disabled: skillLoading,
|
|
8887
|
+
title: t('SkillHub 已不再提供全站下载量排序;这一项只在当前页内按下载量排序。'),
|
|
7784
8888
|
onChange: (e) => { setSkillSort(e.target.value); setSkillPage(1); },
|
|
7785
8889
|
}, WB_SKILLHUB_SORTS.map((option) => h('option', { key: option.key, value: option.key }, option.label))),
|
|
7786
8890
|
h('label', { className: 'dsh-mcp-check', title: t('仅在本页 {0} 条里筛出已安装的(SkillHub 是远程分页,无法跨页筛选)', [skills.length]) },
|
|
7787
|
-
h('input', { type: 'checkbox', checked: skillOnlyInstalled, disabled:
|
|
8891
|
+
h('input', { type: 'checkbox', checked: skillOnlyInstalled, disabled: skillLoading, onChange: (e) => setSkillOnlyInstalled(e.target.checked) }),
|
|
7788
8892
|
t('只看已安装')),
|
|
7789
8893
|
skillOnlyInstalled
|
|
7790
8894
|
? h('span', { className: 'dsh-mcp-builtin-count' }, t('本页已安装 {0} / {1}', [installedOnPage, skills.length]))
|
|
@@ -7795,8 +8899,17 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7795
8899
|
note ? h('div', { className: 'dsh-mcp-wb-note', role: 'status' }, note) : null,
|
|
7796
8900
|
busyLabel ? h('div', { className: 'dsh-mcp-wb-note busy', 'aria-live': 'polite' }, busyLabel) : null,
|
|
7797
8901
|
error ? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, error) : null,
|
|
8902
|
+
// 技能加载失败只落在这一行(带重试),不再借用整页 banner:一个上游接口挂了
|
|
8903
|
+
// 不该让「连接器 / 内置 / 插件 / 专家」页签看起来都坏了。
|
|
8904
|
+
tab === 'skill' && skillError
|
|
8905
|
+
? h('div', { className: 'dsh-mcp-wb-error', role: 'alert' }, skillError, ' ',
|
|
8906
|
+
h('button', {
|
|
8907
|
+
type: 'button', className: 'dsh-mcp-btn', disabled: skillLoading,
|
|
8908
|
+
onClick: () => loadSkills(skillPage, skillSort, skillKeyword),
|
|
8909
|
+
}, t('重试')))
|
|
8910
|
+
: null,
|
|
7798
8911
|
loading && !connectors.length && tab === 'connector' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取连接器目录…')) : null,
|
|
7799
|
-
|
|
8912
|
+
skillLoading && !skills.length && tab === 'skill' ? h('div', { className: 'dsh-mcp-wb-empty' }, t('正在读取技能目录…')) : null,
|
|
7800
8913
|
// §1.4 「只看可直接安装」把本页筛空了:这是本页最容易踩的坑(默认勾选),
|
|
7801
8914
|
// 主操作就是取消它——一句话说清「这里本来有条目,是筛选挡掉了」。
|
|
7802
8915
|
!loading && tab === 'connector' && !visibleConnectors.length
|
|
@@ -7823,7 +8936,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7823
8936
|
: null,
|
|
7824
8937
|
// §1.4 技能:搜索与「只看已安装」是两种完全不同的空,说法必须分开——
|
|
7825
8938
|
// 否则用户会以为 SkillHub 里根本没有这个词。
|
|
7826
|
-
!
|
|
8939
|
+
!skillLoading && tab === 'skill' && !skills.length
|
|
7827
8940
|
? h(EmptyState, {
|
|
7828
8941
|
icon: 'skill',
|
|
7829
8942
|
title: skillKeyword.trim() ? t('没有搜到「{0}」相关的技能', [skillKeyword.trim()]) : t('技能目录暂时是空的'),
|
|
@@ -7834,7 +8947,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7834
8947
|
})
|
|
7835
8948
|
: null,
|
|
7836
8949
|
// 「只看已安装」把本页筛空了:不能沿用「没有匹配的技能」——那会让人以为搜索没结果。
|
|
7837
|
-
!
|
|
8950
|
+
!skillLoading && tab === 'skill' && skills.length && !shownSkills.length
|
|
7838
8951
|
? h(EmptyState, {
|
|
7839
8952
|
icon: 'skill',
|
|
7840
8953
|
tight: true,
|
|
@@ -7996,7 +9109,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
|
|
|
7996
9109
|
)
|
|
7997
9110
|
: null,
|
|
7998
9111
|
tab === 'skill' && skillPageCount > 1
|
|
7999
|
-
? h(Pager, { page: skillPage, pageCount: skillPageCount, total: skillTotal, onPage: setSkillPage, disabled:
|
|
9112
|
+
? h(Pager, { page: skillPage, pageCount: skillPageCount, total: skillTotal, onPage: setSkillPage, disabled: skillLoading })
|
|
8000
9113
|
: null,
|
|
8001
9114
|
// ── T03/R5:codebuddy 插件市场 ──
|
|
8002
9115
|
// 重启提示条:常驻本 tab 顶部(组件卸载即消失,不写 localStorage)——
|
|
@@ -8402,6 +9515,7 @@ const EntryMenu = () => {
|
|
|
8402
9515
|
// 左侧栏底部入口(sidebar.footer.action,DSH 0.1.5 起提供):官方标准的发现入口。
|
|
8403
9516
|
// 点击打开的是同一个浮层面板——不切换主面板、不打断正在进行的会话。
|
|
8404
9517
|
const McpFooterAction = ({ wide }) => {
|
|
9518
|
+
useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
|
|
8405
9519
|
const mode = useEntryMode();
|
|
8406
9520
|
// 「仅悬浮按钮」模式:返回 null 就够了,不需要注销注册。list 型 slot 的每个条目直接渲染
|
|
8407
9521
|
// 进侧栏的 .footerActions(display:flex、无 gap/padding),外层 outlet 锚点又是
|
|
@@ -8819,6 +9933,7 @@ function openBoardCategory(key) {
|
|
|
8819
9933
|
}
|
|
8820
9934
|
const McpBoardPage = (props) => {
|
|
8821
9935
|
const { call, timer, pluginManager } = props;
|
|
9936
|
+
useI18nRevision(); // 宿主切语言后整块看板重渲染,模块级 getter 文案即时更新
|
|
8822
9937
|
// §1.2 URL 记忆:分类写进 hash,刷新 / 从别处退回 / 分享链接都能回到同一页。
|
|
8823
9938
|
// 初值只在首帧读一次(useState 惰性初始化),之后由 effect 单向写出,
|
|
8824
9939
|
// 不做 hash→state 的反向监听:宿主可能在任意时刻改 hash,双向同步必然互相打架。
|
|
@@ -8865,8 +9980,10 @@ const McpBoardPage = (props) => {
|
|
|
8865
9980
|
setCatHint({ key, left });
|
|
8866
9981
|
}, []);
|
|
8867
9982
|
const hideCatHint = useCallback(() => setCatHint(null), []);
|
|
8868
|
-
//
|
|
9983
|
+
// 顶层分类计数:全部是「本机在手」数据,打开看板就先取齐,
|
|
8869
9984
|
// 让 chip 数字常驻——而不是「进过哪一页才出现哪个」,视觉上才协调。
|
|
9985
|
+
// 四类里只有 Skill 会随用户操作变化(市场装/卸、看板卸载),所以它下面额外接了
|
|
9986
|
+
// 变更总线重取;其余三类仍是一次性读取。
|
|
8870
9987
|
// 市场是唯一例外+特例:目录在远端(24MB ZIP),不能为个数字在打开看板时
|
|
8871
9988
|
// 全量下载;这里调 wbConnectorCachedCount 只读磁盘缓存(绝不联网),
|
|
8872
9989
|
// 用户没进过市场时返回 null(chip 暂不显示数字),进过一次后就常驻。
|
|
@@ -8875,13 +9992,22 @@ const McpBoardPage = (props) => {
|
|
|
8875
9992
|
const [expertCount, setExpertCount] = useState(null);
|
|
8876
9993
|
const [mkCount, setMkCount] = useState(null);
|
|
8877
9994
|
const countsLoaded = useRef(false);
|
|
9995
|
+
// Skill 角标会在挂载后被反复重取(市场装/卸、Skill 看板卸载都会广播变更),用序号挡住
|
|
9996
|
+
// 乱序回包;读失败保留旧值——角标读不到不该把数字改成 0,也不该拖垮其它分类的计数。
|
|
9997
|
+
const skillCountSeq = useRef(0);
|
|
9998
|
+
const refreshSkillCount = useCallback(async () => {
|
|
9999
|
+
const seq = ++skillCountSeq.current;
|
|
10000
|
+
try {
|
|
10001
|
+
const res = await call('listSkills', skillScopeArg('global'));
|
|
10002
|
+
if (seq !== skillCountSeq.current) return;
|
|
10003
|
+
if (res && res.ok) setSkillCount(Array.isArray(res.skills) ? res.skills.length : null);
|
|
10004
|
+
} catch { /* 保留旧值 */ }
|
|
10005
|
+
}, [call]);
|
|
8878
10006
|
useEffect(() => {
|
|
8879
10007
|
if (countsLoaded.current) return;
|
|
8880
10008
|
countsLoaded.current = true;
|
|
8881
10009
|
let alive = true;
|
|
8882
|
-
|
|
8883
|
-
if (alive && res && res.ok) setSkillCount(Array.isArray(res.skills) ? res.skills.length : null);
|
|
8884
|
-
}).catch(() => {});
|
|
10010
|
+
refreshSkillCount();
|
|
8885
10011
|
call('list').then((res) => {
|
|
8886
10012
|
if (alive && res && res.ok && Array.isArray(res.servers)) setMcpCount(res.servers.length);
|
|
8887
10013
|
}).catch(() => {});
|
|
@@ -8894,7 +10020,10 @@ const McpBoardPage = (props) => {
|
|
|
8894
10020
|
if (alive && res && res.ok && Number.isFinite(res.total)) setMkCount(res.total);
|
|
8895
10021
|
}).catch(() => {});
|
|
8896
10022
|
return () => { alive = false; };
|
|
8897
|
-
}, [call]);
|
|
10023
|
+
}, [call, refreshSkillCount]);
|
|
10024
|
+
// 关键:角标不再只在首次挂载读一次。市场装完就广播,这里直接重取数量并重渲染;
|
|
10025
|
+
// Skill 看板挂着时它自己的订阅负责刷新列表,没挂着时靠其挂载 effect 重读,路径不同但都不漏。
|
|
10026
|
+
useEffect(() => onSkillChange(() => { refreshSkillCount(); }), [refreshSkillCount]);
|
|
8898
10027
|
// OPT-04:报告上提到这里,分类角标与 ToolTailor 页面本体共用同一份。
|
|
8899
10028
|
// 扫描时机不再等进裁缝页——账本是本机文件,打开看板就扫,橙点角标才有数。
|
|
8900
10029
|
// 用「已发起」标记而不是判断报告是否非空,是为了区分「还没开始」与「发起了但失败了」:
|
|
@@ -9136,6 +10265,7 @@ function whatsNewFor(current, lastSeen, entries) {
|
|
|
9136
10265
|
// 自带 RPC 与状态:面板只管「在这儿渲染一个」。它挂在面板里,就自然随面板挂载/卸载——
|
|
9137
10266
|
// 这正是「升级后第一次打开面板时弹一次」的触发点,不需要另外判开合。
|
|
9138
10267
|
const WhatsNewModal = ({ call }) => {
|
|
10268
|
+
useI18nRevision(); // P1:宿主切语言后条目按新 locale 重新选择
|
|
9139
10269
|
const [notice, setNotice] = useState(null);
|
|
9140
10270
|
const boxRef = useRef(null);
|
|
9141
10271
|
const confirmRef = useRef(null);
|
|
@@ -9199,6 +10329,8 @@ const WhatsNewModal = ({ call }) => {
|
|
|
9199
10329
|
useFocusTrap(boxRef, !!notice);
|
|
9200
10330
|
|
|
9201
10331
|
if (!notice) return null;
|
|
10332
|
+
// 在渲染期按当前 locale 选择,而不是在 RPC 回包时固化:弹窗开着时切语言也能即时变。
|
|
10333
|
+
const localizedEntries = localizeWhatsNewEntries(notice.entries);
|
|
9202
10334
|
return h(
|
|
9203
10335
|
'div',
|
|
9204
10336
|
{ className: 'dsh-mcp-overlay', onClick: (e) => e.target === e.currentTarget && dismiss() },
|
|
@@ -9212,10 +10344,10 @@ const WhatsNewModal = ({ call }) => {
|
|
|
9212
10344
|
h('span', null, t('这些变化你可能想知道')),
|
|
9213
10345
|
),
|
|
9214
10346
|
h('div', { className: 'dsh-mcp-whatsnew-body' },
|
|
9215
|
-
|
|
10347
|
+
localizedEntries.map((entry) =>
|
|
9216
10348
|
h('div', { key: entry.version, className: 'dsh-mcp-whatsnew-entry' },
|
|
9217
10349
|
// 一次跳多个版本时每条自带版本号;只报一条时标题里的版本号就够了。
|
|
9218
|
-
|
|
10350
|
+
localizedEntries.length > 1 ? h('div', { className: 'dsh-mcp-whatsnew-ver' }, 'v' + entry.version) : null,
|
|
9219
10351
|
entry.items.map((item, position) =>
|
|
9220
10352
|
h('div', { key: position, className: 'dsh-mcp-whatsnew-item' },
|
|
9221
10353
|
h('span', { className: 'dsh-mcp-whatsnew-dot', 'aria-hidden': true }, '·'),
|
|
@@ -9237,6 +10369,7 @@ const WhatsNewModal = ({ call }) => {
|
|
|
9237
10369
|
|
|
9238
10370
|
// ── 悬浮按钮宿主(shell.overlay):只剩这一个席位 ────────────────────────────────
|
|
9239
10371
|
const McpOverlay = () => {
|
|
10372
|
+
useI18nRevision(); // 宿主切语言后重渲染,模块级 getter 文案即时更新
|
|
9240
10373
|
const mode = useEntryMode();
|
|
9241
10374
|
const seats = overlaySeats(mode);
|
|
9242
10375
|
const [pos, setPos] = useState(() => readFabPos() || defaultFabPos());
|
|
@@ -9419,6 +10552,11 @@ const MCP_CONTRIBUTION = {
|
|
|
9419
10552
|
{ id: 'dsh-armory#mcpManager/listSkills', service: 'mcpManager', namespace: 'mcpManager', method: 'listSkills', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillListResult') },
|
|
9420
10553
|
{ id: 'dsh-armory#mcpManager/skillDetail', service: 'mcpManager', namespace: 'mcpManager', method: 'skillDetail', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillDetailResult') },
|
|
9421
10554
|
{ id: 'dsh-armory#mcpManager/revealSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'revealSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
10555
|
+
{ id: 'dsh-armory#mcpManager/removeSkill', service: 'mcpManager', namespace: 'mcpManager', method: 'removeSkill', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
|
|
10556
|
+
// 自定义套件:读全量集合 / 整集合覆盖保存(与 typert.js、lib/index.js 三处同步,
|
|
10557
|
+
// 漏一处这条通道恒 404 —— toolTailor、installInfo 各踩过一次)。
|
|
10558
|
+
{ id: 'dsh-armory#mcpManager/listSkillSuites', service: 'mcpManager', namespace: 'mcpManager', method: 'listSkillSuites', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('SkillSuiteListResult') },
|
|
10559
|
+
{ id: 'dsh-armory#mcpManager/saveSkillSuites', service: 'mcpManager', namespace: 'mcpManager', method: 'saveSkillSuites', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('SkillSuiteSaveResult') },
|
|
9422
10560
|
// ── 工具裁缝:与 revealSkill 同批加入看板,但两端契约此前双双缺失(客户端永远 404)──
|
|
9423
10561
|
{ id: 'dsh-armory#mcpManager/toolTailor', service: 'mcpManager', namespace: 'mcpManager', method: 'toolTailor', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('TailorReport') },
|
|
9424
10562
|
// ── WorkBuddy 市场直连:连接器 MCP + SkillHub 技能(同一命名空间同一信封)──
|
|
@@ -9472,19 +10610,34 @@ const MCP_CONTRIBUTION = {
|
|
|
9472
10610
|
};
|
|
9473
10611
|
// ── 插件入口:挂 RPC、注入样式、注册六个界面槽位 ──────────────────────────────
|
|
9474
10612
|
async function apply(ctx) {
|
|
10613
|
+
// OPT-25:先接宿主 locale(设置 → 通用里的语言),再注册槽位——槽位 label 是
|
|
10614
|
+
// 注册时求值的字符串,必须等这里算完。ctx.get 是可选读取,不写进 inject:
|
|
10615
|
+
// 老版本 DSH 没有 locale 服务时返回 undefined,自动回落 navigator/zh-CN。
|
|
10616
|
+
const readHostLocale = () => {
|
|
10617
|
+
try { return typeof ctx.get === 'function' ? ctx.get('locale') : null; } catch { return null; }
|
|
10618
|
+
};
|
|
10619
|
+
let hostLocaleService = readHostLocale();
|
|
10620
|
+
applyI18nLocale(resolveI18nLocale(hostLocaleService));
|
|
9475
10621
|
const disposeMount = await ctx.remote.$mount(MCP_CONTRIBUTION);
|
|
9476
10622
|
let disposeCss;
|
|
9477
10623
|
let timer;
|
|
9478
10624
|
let cleanupPromise;
|
|
10625
|
+
let disposeLocaleSub = null;
|
|
10626
|
+
let disposeServiceWatch = null;
|
|
9479
10627
|
const cleanup = () => {
|
|
9480
10628
|
cleanupPromise ??= (async () => {
|
|
9481
10629
|
try {
|
|
9482
|
-
|
|
10630
|
+
try { disposeLocaleSub?.(); } catch { /* 宿主订阅已失效 */ }
|
|
10631
|
+
try { disposeServiceWatch?.(); } catch { /* 宿主事件监听已失效 */ }
|
|
9483
10632
|
} finally {
|
|
9484
10633
|
try {
|
|
9485
|
-
|
|
10634
|
+
timer?.dispose();
|
|
9486
10635
|
} finally {
|
|
9487
|
-
|
|
10636
|
+
try {
|
|
10637
|
+
disposeCss?.();
|
|
10638
|
+
} finally {
|
|
10639
|
+
await disposeMount();
|
|
10640
|
+
}
|
|
9488
10641
|
}
|
|
9489
10642
|
}
|
|
9490
10643
|
})();
|
|
@@ -9513,7 +10666,7 @@ async function apply(ctx) {
|
|
|
9513
10666
|
// 直接 register:它声明感知,会等 ui-layout 把 main slot 声明出来(插件加载顺序不保证
|
|
9514
10667
|
// 晚于布局)。
|
|
9515
10668
|
ctx.slots.inject('main', () =>
|
|
9516
|
-
ctx.slots.register({ name: 'main', key: PANEL_ID, label:
|
|
10669
|
+
ctx.slots.register({ name: 'main', key: PANEL_ID, label: panelLabel(), inject: () => ({ call, timer, pluginManager }) }, McpBoardPage),
|
|
9517
10670
|
);
|
|
9518
10671
|
// 输入框召唤菜单(conversation.input.right,list 型 slot):权限/模型座位右侧的小图标,
|
|
9519
10672
|
// 点开 = 技能/MCP/专家三分类 + 悬停最近使用子菜单。声明感知注入(会话 slot 由 conversation
|
|
@@ -9521,13 +10674,13 @@ async function apply(ctx) {
|
|
|
9521
10674
|
// sessionId 由 slot 渲染作为 prop 显式传入(session 座位约定:id 只走 props,不走 summary 投影),
|
|
9522
10675
|
// 「挂专家」的人设通道 RPC 用它寻址当前会话。
|
|
9523
10676
|
ctx.slots.inject('conversation.input.right', () =>
|
|
9524
|
-
ctx.slots.register({ name: 'conversation.input.right', id: PANEL_ID + '-quick', order: 40, label:
|
|
10677
|
+
ctx.slots.register({ name: 'conversation.input.right', id: PANEL_ID + '-quick', order: 40, label: panelLabel(), inject: (sessionId) => ({ call, sessionId }) }, SummonQuickMenu),
|
|
9525
10678
|
);
|
|
9526
10679
|
// 会话头「当前生效专家」徽章(conversation.session.header.actions,list 型 slot):
|
|
9527
10680
|
// 与宿主 AgentPresetLabel 同列——但显示的是人设通道真正生效的专家(expertActive),
|
|
9528
10681
|
// 修复「换专家后顶栏仍显示第一次预设名」的显示/实效分叉。
|
|
9529
10682
|
ctx.slots.inject('conversation.session.header.actions', () =>
|
|
9530
|
-
ctx.slots.register({ name: 'conversation.session.header.actions', id: PANEL_ID + '-head-expert', order: -5, label:
|
|
10683
|
+
ctx.slots.register({ name: 'conversation.session.header.actions', id: PANEL_ID + '-head-expert', order: -5, label: panelLabel(), inject: (sessionId) => ({ call, sessionId }) }, SessionExpertBadge),
|
|
9531
10684
|
);
|
|
9532
10685
|
// 左上角全局面板行(sidebar.panellist):「插件」旁边,主入口。行点击由宿主承担——
|
|
9533
10686
|
// layout.selectPanel(PANEL_ID) 要求 main slot 里存在同 key 条目,上面的注册保证了这点。
|
|
@@ -9537,20 +10690,52 @@ async function apply(ctx) {
|
|
|
9537
10690
|
const syncPanelEntry = () => {
|
|
9538
10691
|
const want = ENTRY_VISIBILITY[entryMode.get()].panel;
|
|
9539
10692
|
if (want && !disposePanelEntry) disposePanelEntry = ctx.slots.register(
|
|
9540
|
-
{ name: 'sidebar.panellist', id: PANEL_ID, order: PANEL_ORDER, label:
|
|
10693
|
+
{ name: 'sidebar.panellist', id: PANEL_ID, order: PANEL_ORDER, label: sidebarEntryLabel() }, McpPanelIcon,
|
|
9541
10694
|
);
|
|
9542
10695
|
if (!want && disposePanelEntry) { disposePanelEntry(); disposePanelEntry = null; }
|
|
9543
10696
|
};
|
|
9544
10697
|
ctx.slots.inject('sidebar.panellist', () => { syncPanelEntry(); });
|
|
9545
10698
|
entryMode.subscribe(syncPanelEntry);
|
|
10699
|
+
// OPT-25:宿主切换语言后重算字典并刷新槽位文案。
|
|
10700
|
+
// sidebar.panellist 的 label 来自注册参数而不是组件,只能 dispose + 重新注册;
|
|
10701
|
+
// 面板/菜单等内容组件通过 useI18nRevision 强制重渲染,getter 文案即时生效。
|
|
10702
|
+
const applyHostLocale = () => {
|
|
10703
|
+
const next = resolveI18nLocale(hostLocaleService);
|
|
10704
|
+
if (next === I18N_LOCALE) return;
|
|
10705
|
+
applyI18nLocale(next);
|
|
10706
|
+
// 只在入口已经注册过时重注册:否则可能在 sidebar.panellist 声明前直接
|
|
10707
|
+
// 调用 register(inject 的声明感知就白用了)。没注册过就没有旧文案可刷新,
|
|
10708
|
+
// 之后的 syncPanelEntry 会用新 locale 取 label。
|
|
10709
|
+
if (disposePanelEntry) {
|
|
10710
|
+
disposePanelEntry();
|
|
10711
|
+
disposePanelEntry = null;
|
|
10712
|
+
syncPanelEntry();
|
|
10713
|
+
}
|
|
10714
|
+
emitI18nChange();
|
|
10715
|
+
};
|
|
10716
|
+
const bindHostLocale = () => {
|
|
10717
|
+
const service = readHostLocale();
|
|
10718
|
+
if (!service) return;
|
|
10719
|
+
hostLocaleService = service;
|
|
10720
|
+
applyHostLocale();
|
|
10721
|
+
if (!disposeLocaleSub) disposeLocaleSub = subscribeI18nLocale(service, applyHostLocale);
|
|
10722
|
+
};
|
|
10723
|
+
bindHostLocale();
|
|
10724
|
+
// locale 插件可能晚于本插件完成 apply:服务注册时再绑一次(Cordis 内置事件)。
|
|
10725
|
+
// 老宿主没有该事件也无妨,启动时的 ctx.get 已经覆盖绝大多数加载顺序。
|
|
10726
|
+
if (typeof ctx.on === 'function') {
|
|
10727
|
+
disposeServiceWatch = ctx.on('internal/service', (name) => {
|
|
10728
|
+
if (name === 'locale') bindHostLocale();
|
|
10729
|
+
});
|
|
10730
|
+
}
|
|
9546
10731
|
// 右下角悬浮按钮:保留既有能力,点击同样切到看板页。
|
|
9547
10732
|
ctx.slots.inject('shell.overlay', () =>
|
|
9548
|
-
ctx.slots.register({ name: 'shell.overlay', id: 'mcp-fab', order: 1000, label:
|
|
10733
|
+
ctx.slots.register({ name: 'shell.overlay', id: 'mcp-fab', order: 1000, label: panelLabel(), inject: () => ({ call, timer, pluginManager }) }, McpOverlay),
|
|
9549
10734
|
);
|
|
9550
10735
|
// 左侧栏底部入口(sidebar.footer.action,0.1.5 起声明):兼容入口,行为与面板行一致。
|
|
9551
10736
|
// 用 inject 而不是直接 register:它声明感知,会等侧边栏把 slot 声明出来。
|
|
9552
10737
|
ctx.slots.inject('sidebar.footer.action', () =>
|
|
9553
|
-
ctx.slots.register({ name: 'sidebar.footer.action', id: 'mcp-manager', order: 100, label:
|
|
10738
|
+
ctx.slots.register({ name: 'sidebar.footer.action', id: 'mcp-manager', order: 100, label: panelLabel() }, McpFooterAction),
|
|
9554
10739
|
);
|
|
9555
10740
|
return cleanup;
|
|
9556
10741
|
} catch (error) {
|