dsh-toolbox-ui 1.6.5 → 1.6.7

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/lib/client.js CHANGED
@@ -166,7 +166,12 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
166
166
  @keyframes mcpBlink{50%{opacity:.35}}
167
167
  .dsh-mcp-item-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
168
168
  .dsh-mcp-item-name{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:.1px}
169
- .dsh-mcp-item-sub{font-size:10.5px;color:var(--mcp-mut);display:flex;gap:6px;align-items:center}
169
+ .dsh-mcp-item-sub{font-size:10.5px;color:var(--mcp-mut);display:flex;gap:6px;align-items:center;min-width:0;overflow:hidden;white-space:nowrap}
170
+ /* ⚠️ 子行必须有 min-width:0 + overflow:hidden:它在 flex 列里(.dsh-mcp-item-main 是
171
+ flex:1;min-width:0),只要里面放进任何**不收缩**的长文本(真机踩过:一整条根路径),
172
+ 它就会撑破 185px 的单元格并**没有省略号地**硬裁——显示成半截字,看起来像渲染坏了。
173
+ .dsh-mcp-item-name 早就有 ellipsis,子行此前漏了。 */
174
+ .dsh-mcp-item-sub>*{min-width:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis}
170
175
  .dsh-mcp-badge{display:inline-flex;align-items:center;padding:1.5px 7px;border-radius:999px;font-size:10.5px;font-weight:500;line-height:16px;border:1px solid var(--mcp-line);color:var(--mcp-mut);background:var(--mcp-bg-1);corner-shape:round}
171
176
  .dsh-mcp-badge.dot::before{content:"";width:5px;height:5px;border-radius:50%;background:currentColor;margin-right:5px;opacity:.85;corner-shape:round}
172
177
  .dsh-mcp-badge.t-http{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-tertiary)}
@@ -511,6 +516,8 @@ const SKILLS_CSS = `.dsh-skills-root{display:flex;flex-direction:column;min-heig
511
516
  .dsh-skills-ack{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:18px;padding:8px 0;color:var(--dsw-alias-label-primary);cursor:pointer}
512
517
  .dsh-skills-ack input{margin:2px 0 0;flex:none;accent-color:var(--mcp-accent)}
513
518
  .dsh-skills-note{padding:10px 12px;font-size:12px;line-height:18px;color:var(--mcp-mut);border-top:.5px solid var(--mcp-line);flex:none}
519
+ /* 市场翻页区:按钮居中。滚动到底的自动加载挂在 .dsh-mcp-market-view 的 onScroll 上。 */
520
+ .dsh-skills-hub-more{display:flex;justify-content:center;padding:4px 0 10px;flex:none}
514
521
  /* 外部源的「上游页面」链接:与插件其余链接同一套(品牌色 + hover 下划线),
515
522
  不在弹窗里引入第二种链接样式。 */
516
523
  .dsh-skills-link{color:var(--mcp-accent);text-decoration:none;word-break:break-all}
@@ -593,29 +600,30 @@ const sourceRank = (source) => SOURCE_RANK[source] ?? 999;
593
600
 
594
601
  function skillScopeOptions(rows) {
595
602
  const seen = new Map();
596
- for (const row of Array.isArray(rows) ? rows : []) {
603
+ for (const entry of Array.isArray(rows) ? rows : []) {
597
604
  // ⚠️ 去重键必须是**作用域身份(source + cwd)**,不是 source:两个工作区都会有
598
605
  // `project-agents`,只按 source 去重会把它们合成一项,还带上第一个的 root。
599
- for (const entry of [row, ...(Array.isArray(row?.disabled) ? row.disabled : [])]) {
600
- const source = entry?.source;
601
- if (typeof source !== 'string' || source === '' || seen.has(skillScopeIdentity(entry))) continue;
602
- seen.set(skillScopeIdentity(entry), {
603
- // 安装目标的身份与宿主一致:source + root(见 installTargetId)。
604
- id: installTargetId({ source, root: entry.root }),
605
- source,
606
- // 标签同样带项目名:筛选下拉里两行「项目 · .agents/skills」是没法选的。
607
- label: skillScopeLabel(entry),
608
- scope: entry.scope === 'project' ? 'project' : 'global',
609
- root: entry.root,
610
- cwd: entry.cwd,
611
- // wire 上的目标引用用的是 projectPath;这条退路上它等于行的 cwd。
612
- projectPath: entry.cwd,
613
- // ⚠️ writable 必须带下去:宿主在每一行上标了它(bundled 为 false),
614
- // 第一版把它丢了,于是「内置(只读)」既进了候选、还因为排在前面成了**默认**
615
- // 安装目标。注意它现在只是**显示用**:真正的可写与否由服务端判定(不接受载荷里的 writable)。
616
- writable: entry.writable !== false,
617
- });
618
- }
606
+ //
607
+ // 曾经这里还要展开 `row.disabled`(旧版把已停用技能放在**第二个数组**里)——
608
+ // 那个数组已经不存在了(停用是行上的派生态,见 SkillsManagerGateway.list)。
609
+ const source = entry?.source;
610
+ if (typeof source !== 'string' || source === '' || seen.has(skillScopeIdentity(entry))) continue;
611
+ seen.set(skillScopeIdentity(entry), {
612
+ // 安装目标的身份与宿主一致:source + root(见 installTargetId)。
613
+ id: installTargetId({ source, root: entry.root }),
614
+ source,
615
+ // 标签同样带项目名:筛选下拉里两行「项目 · .agents/skills」是没法选的。
616
+ label: skillScopeLabel(entry),
617
+ scope: entry.scope === 'project' ? 'project' : 'global',
618
+ root: entry.root,
619
+ cwd: entry.cwd,
620
+ // wire 上的目标引用用的是 projectPath;这条退路上它等于行的 cwd。
621
+ projectPath: entry.cwd,
622
+ // ⚠️ writable 必须带下去:宿主在每一行上标了它(bundled 为 false),
623
+ // 第一版把它丢了,于是「内置(只读)」既进了候选、还因为排在前面成了**默认**
624
+ // 安装目标。注意它现在只是**显示用**:真正的可写与否由服务端判定(不接受载荷里的 writable)。
625
+ writable: entry.writable !== false,
626
+ });
619
627
  }
620
628
  // 全局在前、项目在后(与宿主 rank 的语义一致:rank 小的先被采纳,用户级是默认视角)。
621
629
  return [...seen.values()].sort((left, right) => {
@@ -770,8 +778,8 @@ function skillTwinCounts(rows) {
770
778
  * 详情要渲染的那一行:**按身份从当前 rows 派生**。
771
779
  *
772
780
  * 为什么不是把选中的行对象存进 state:那样存的是"上一次取数时的那个对象"。面板里刚启停一个
773
- * 技能时列表会重新取数,而详情仍渲染旧对象——真机实测:磁盘上已经是 `SKILL.md.disabled`,
774
- * 详情却仍写「已启用」,点「刷新」也不变,只有重新选中那一行才对。派生之后这个状态无法存在。
781
+ * 技能时列表会重新取数,而详情仍渲染旧对象——真机实测:磁盘上的状态已经改了,详情却仍写旧值,
782
+ * 点「刷新」也不变,只有重新选中那一行才对。派生之后这个状态无法存在。
775
783
  * 选中项从列表里消失(被删掉/换了作用域)时返回 null,详情自然回到空态。
776
784
  */
777
785
  const selectedSkillRow = (rows, key) => (Array.isArray(rows) ? rows.find((row) => row?.key === key) ?? null : null)
@@ -792,8 +800,10 @@ const selectedSkillRow = (rows, key) => (Array.isArray(rows) ? rows.find((row) =
792
800
  * 谁生效由宿主的 providerOrder 决定,面板拿不到,但两个不同项目里的同名技能互不影响,
793
801
  * 标一份为胜者只是为了让提示有个主语,不会误导操作。
794
802
  *
795
- * 已停用的行不参与:它的文件是 `SKILL.md.disabled`,模型根本看不到它,说它"遮蔽/被遮蔽"
796
- * 都是假的。它只该出现在诊断里(见 skillDiagnostics)。
803
+ * 已停用的行不参与:两个调用面都关掉的技能,模型和用户都拿不到它,
804
+ * 说它"遮蔽/被遮蔽"都是假的。它只该出现在诊断里(见 skillDiagnostics)。
805
+ * ⚠️ 判据是 `enabled === false`——**不知道**(null)的行要参与:
806
+ * 它可能照样进了模型目录,把它排除掉才是漏报。
797
807
  *
798
808
  * @param {object[]} rows
799
809
  * @returns {object[]} 被遮蔽的行(附生效者信息)。
@@ -822,11 +832,9 @@ function shadowedSkills(rows) {
822
832
  /**
823
833
  * 汇总「用户需要知道但模型看不到」的条目。
824
834
  *
825
- * ⚠️ 输入只有**合并后的行**(已停用技能由宿主放进 `disabled`,面板合并进 rows 并打上
826
- * `enabled:false`)。上一版另外收 `disabled`/`illegal`/`blocked` 三个数组,而宿主只有一个
827
- * `disabled` 通道(`illegal` 从来没有生产者),调用方为了避免「同一条出现两次」只好传空数组
828
- * —— 于是这个函数**恒返回空**,整块诊断 UI 是死的(用例只测纯函数,看不见这件事)。
829
- * 现在它只从行推导,判据与行身份同源(`enabled === false` 就是「已停用」)。
835
+ * ⚠️ 输入只有**行**,判据与行身份同源:`enabled === false` 就是「两个调用面都关掉了」。
836
+ * 停用现在是 frontmatter 的两个字段,宿主照样把它报在目录里,所以这里不需要第二个数组,
837
+ * 也就不可能「同一条出现两次」或「整块 UI 恒为空」。
830
838
  *
831
839
  * 非法 frontmatter(宿主只 warn + skip)目前**没有**生产者,见 TODO row 21——
832
840
  * 在它落地之前,这里不假装能报。
@@ -838,14 +846,17 @@ function skillDiagnostics(rows) {
838
846
  }
839
847
 
840
848
  /**
841
- * detail 请求的参数:**送整条引用**(name + source + root + 项目路径)。
849
+ * detail 等动作请求的参数:**送整条引用**(source + root + 项目路径 + 磁盘条目 + 形态)。
842
850
  *
843
851
  * 为什么不能只送 name(或 name + cwd):宿主侧 `ctx.skills.get(name)` 按 rank 返回
844
- * **生效者**,而面板的模型是 (name, root) 精确操作(D8)——只送名字时,点「被遮蔽」
845
- * 那一行看到的是生效者的正文,用户以为自己在读被遮蔽的那份。现在宿主按引用定位到
846
- * 那个根再读文件,顺带连「已停用」的正文也读得出来(停用时路径是 `.disabled`)。
852
+ * **生效者**,而面板的模型是 (根, 条目) 精确操作(D8)——只送名字时,点「被遮蔽」
853
+ * 那一行看到的是生效者的正文,用户以为自己在读被遮蔽的那份。
854
+ *
855
+ * ⚠️ 送的是 **entry(磁盘条目名)而不是 name(技能名)**:宿主允许两者不一致
856
+ * (`dir-name/SKILL.md` 里写 `name: dir-alias` 照收),而定位必须按磁盘上的真实位置。
857
+ * name 只是显示用的身份,服务端从文件里自己读得出来。
847
858
  *
848
- * 单独成一个函数:这条不变量得有个能被直接断言的家,而不是埋在组件闭包里。
859
+ * 单独成一个函数:这条不变量得有个能被直接断言的家里,而不是埋在组件闭包里。
849
860
  */
850
861
  function skillDetailRequest(row) {
851
862
  return {
@@ -853,6 +864,8 @@ function skillDetailRequest(row) {
853
864
  source: row?.source,
854
865
  root: row?.root,
855
866
  projectPath: row?.cwd,
867
+ entry: row?.entry,
868
+ kind: row?.kind,
856
869
  };
857
870
  }
858
871
 
@@ -3199,7 +3212,7 @@ const McpTab = (props) => {
3199
3212
  )),
3200
3213
  );
3201
3214
 
3202
- // 能力层计数:技能的来自 SkillsPanel 回传的 list 结果(skills + disabled 之和)。
3215
+ // 能力层计数:技能的来自 SkillsPanel 回传的 list 结果(**行数**——已停用的技能也在列表里)。
3203
3216
  // MCP 面**不显示数字**——跨作用域总数外壳拿不到(list 只回答当前作用域那批),
3204
3217
  // 编一个出来比不显示更糟:用户会拿它当「一共有几个 MCP」。
3205
3218
  const abilityTabs = seg(
@@ -4099,12 +4112,23 @@ const SkillRow = ({ row, selected, onSelect, onToggle, busy, shadowedBy, twins }
4099
4112
  onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelect(row); } },
4100
4113
  },
4101
4114
  // 状态点沿用 MCP 那套语义:停用 = stopped(灰),可用 = connected(绿)。
4102
- h(Dot, { phase: row.enabled === false ? 'stopped' : 'connected' }),
4115
+ // 三态:读不出文件时(enabled === null)用 loading 的灰点——不假装知道它是哪一种。
4116
+ h(Dot, { phase: row.enabled === false ? 'stopped' : row.enabled === null ? 'loading' : 'connected' }),
4103
4117
  h('div', { className: 'dsh-mcp-item-main' },
4104
4118
  h('div', { className: 'dsh-mcp-item-name', title: row.name }, row.name),
4105
4119
  h('div', { className: 'dsh-mcp-item-sub' },
4106
4120
  // 作用域由**组头**承载,行内不再重复一条会被裁断的路径(这是分组的直接收益)。
4107
- row.modelInvocable === false ? h(Badge, { cls: 's-off' }, '仅用户可调用') : null,
4121
+ //
4122
+ // ⚠️ 两个调用面**各自**都可能被关掉,所以这里要按面各说一句——只标模型面的话,
4123
+ // 「仅模型可调用」(用户面关着)在列表上**看不出任何异常**,用户点进去才会发现
4124
+ // 自己没法用 /名字 调用它。顺序有讲究:停用是更强的结论(两个面都关了),先说它,
4125
+ // 否则停用的行会被说成「仅用户可调用」——用户会以为手动还能调用它,实际不能。
4126
+ row.enabled === false
4127
+ ? h(Badge, { cls: 's-off' }, row.migrated ? '旧机制停用' : '已停用')
4128
+ : [
4129
+ row.modelInvocable === false ? h(Badge, { key: 'no-model', cls: 's-off', title: '模型不会自动调用它' }, '模型不用') : null,
4130
+ row.userInvocable === false ? h(Badge, { key: 'no-user', cls: 's-off', title: '你不能用 /名字 手动调用它' }, '不可手调') : null,
4131
+ ],
4108
4132
  shadowedBy
4109
4133
  // 被遮蔽时说出**生效的那份在哪**:原先只说「被遮蔽」,用户找不到另一份。
4110
4134
  ? h(Badge, { cls: 's-err', title: '模型只会收到「' + skillScopeLabel(shadowedBy) + '」那一份' }, '被遮蔽')
@@ -4161,15 +4185,16 @@ function invocationUnknownReason(row) {
4161
4185
  }
4162
4186
 
4163
4187
  /** 管理模式的右侧详情:元信息 + 正文 + 启停/删除(与 MCP 面同一张详情卡片)。 */
4164
- const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove, onModelInvocable, tab = 'overview', onTab }) => {
4188
+ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove, onModelInvocable, onUserInvocable, tab = 'overview', onTab }) => {
4165
4189
  if (!row) return h('div', { className: 'dsh-mcp-detail' }, h('div', { className: 'dsh-mcp-d-empty' },
4166
4190
  h('b', null, '选择左侧的一个技能'),
4167
4191
  h('span', null, '这里会显示它的位置与调用权限、SKILL.md 正文,以及遮蔽或校验失败时的诊断。')));
4168
4192
  const disabled = row.enabled === false;
4193
+ // 三态:true / false / null(读不到文件时不知道两个调用面是否都关掉了)。
4194
+ const enabledKnown = row.enabled === true || row.enabled === false;
4169
4195
  const readonly = row.writable === false;
4170
4196
  // 调用权限的**三态**:true / false / 未知。未知是真实的一态——宿主对 frontmatter 非法的技能
4171
- // 整条跳过,那时「能不能被模型调用」没有答案。此前它被渲染成「不可调用」,那是凭空的断言
4172
- // (已停用的行尤其明显:它们的权限只有我们读得到的那份文件能回答)。
4197
+ // 整条跳过,那时「能不能被模型调用」没有答案。此前它被渲染成「不可调用」,那是凭空的断言。
4173
4198
  const invocationKnown = row.modelInvocable === true || row.modelInvocable === false;
4174
4199
  const paneOff = (id) => detailPaneOff(tab, id);
4175
4200
  const tabBtn = (id, label, count) => detailTab(tab, onTab, id, label, count);
@@ -4199,11 +4224,13 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
4199
4224
  readonly ? h(Badge, { cls: 's-lock' }, '内置只读') : null,
4200
4225
  ),
4201
4226
  h('div', { className: 'dsh-mcp-d-meta' },
4202
- h(Badge, { cls: disabled ? 's-off' : shadowedBy ? 's-warn' : 's-on' }, disabled ? '已停用' : shadowedBy ? '被遮蔽' : '已启用'),
4227
+ h(Badge, { cls: disabled ? 's-off' : shadowedBy ? 's-warn' : 's-on' },
4228
+ disabled ? '已停用' : shadowedBy ? '被遮蔽' : enabledKnown ? '已启用' : '状态未知'),
4203
4229
  invocationKnown
4204
4230
  ? h(Badge, { cls: row.modelInvocable ? null : 's-off' }, row.modelInvocable ? '模型可调用' : '模型不可调用')
4205
4231
  : h(Badge, { cls: 's-off', title: invocationUnknownReason(row) }, '调用权限未知'),
4206
4232
  invocationKnown && !row.userInvocable ? h(Badge, { cls: 's-off' }, '用户不可调用') : null,
4233
+ row.migrated ? h(Badge, { cls: 's-warn', title: '这是旧版启停机制(改名成 .disabled)留下的条目;恢复后请用新的停用方式' }, '旧机制残留') : null,
4207
4234
  ),
4208
4235
  h('p', { className: 'dsh-mcp-d-desc' }, row.description || '(无说明)'),
4209
4236
  ),
@@ -4236,10 +4263,18 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
4236
4263
  h('div', { className: 'dsh-mcp-kv' },
4237
4264
  // 作用域与根目录合成一行:分组之后「哪一级」已由组头说过,这里只补完整路径。
4238
4265
  h('b', null, '位置'), h('span', null, (row.root || '(未报告)') + '(' + skillScopeLabel(row) + ')'),
4239
- h('b', null, '状态'), h('span', null, disabled ? '已停用(SKILL.md.disabled)' : '已启用'),
4240
- // 「模型自动调用」是**可操作**的那一项(issue #5:快速改 disable-model-invocation,
4241
- // 不必手工去翻 SKILL.md)。写在 SKILL.md 的 frontmatter 里,所以只在可写根上给开关;
4242
- // 权限读不出来时不装懂——开关禁用并写明未知。
4266
+ // 「停用」的落点是**文件内容**(frontmatter 的两个调用面字段),不是文件名——
4267
+ // 旧机制(改名 .disabled)只在迁移读取时才会出现在状态里,如实标出来。
4268
+ h('b', null, '状态'), h('span', null,
4269
+ row.migrated
4270
+ ? '旧机制停用(SKILL.md.disabled)——点「启用」把它改回来'
4271
+ : disabled
4272
+ ? '已停用(disable-model-invocation: true + user-invocable: false)'
4273
+ : enabledKnown ? '已启用' : '未知(读不到这个文件)'),
4274
+ // 两个调用面**各自可写**:宿主支持的就是两个面,而「已停用」只是「两个都关」这一种组合。
4275
+ // 只给一个面开关的话,用户在面板里拨不回「仅手动可调用」那一档——只能去编辑文件。
4276
+ // 写在 SKILL.md 的 frontmatter 里,所以只在可写根上给开关;权限读不出来时不装懂
4277
+ // ——开关禁用并写明未知。
4243
4278
  h('b', null, '模型自动调用'),
4244
4279
  h('span', null,
4245
4280
  h(Toggle, {
@@ -4249,14 +4284,29 @@ const SkillDetail = ({ row, shadowedBy, body, busy, onRetry, onToggle, onRemove,
4249
4284
  title: readonly
4250
4285
  ? '内置技能只读'
4251
4286
  : invocationKnown
4252
- ? '写入 SKILL.md 的 disable-model-invocation;宿主重新发现这条技能后才影响模型(重启 DSH 最稳妥)'
4287
+ ? '写入 SKILL.md 的 disable-model-invocation。宿主用文件监听盯技能根,改动约 0.25 秒就生效(实测中位数 246ms),不必重启'
4253
4288
  : invocationUnknownReason(row),
4254
4289
  onChange: () => onModelInvocable(row, row.modelInvocable !== true),
4255
4290
  }),
4256
4291
  ' ',
4257
4292
  invocationKnown ? (row.modelInvocable ? '允许' : '禁止') : '未知',
4258
4293
  ),
4259
- h('b', null, '用户可调用'), h('span', null, invocationKnown ? (row.userInvocable ? '是' : '否') : '未知'),
4294
+ h('b', null, '用户可调用'),
4295
+ h('span', null,
4296
+ h(Toggle, {
4297
+ on: row.userInvocable === true,
4298
+ disabled: busy || readonly || !invocationKnown,
4299
+ label: row.name + ':允许用户手动调用',
4300
+ title: readonly
4301
+ ? '内置技能只读'
4302
+ : invocationKnown
4303
+ ? '写入 SKILL.md 的 user-invocable:关掉后你就不能用 /名字 手动调用它(模型仍可自动调用)。与上面那个一起关掉就是「已停用」'
4304
+ : invocationUnknownReason(row),
4305
+ onChange: () => onUserInvocable(row, row.userInvocable !== true),
4306
+ }),
4307
+ ' ',
4308
+ invocationKnown ? (row.userInvocable ? '允许' : '禁止') : '未知',
4309
+ ),
4260
4310
  ),
4261
4311
  ),
4262
4312
  h('div', { className: 'dsh-mcp-card' + paneOff('text') },
@@ -4459,6 +4509,37 @@ const HubCard = ({ entry, installed, onInstall }) => h(
4459
4509
  ),
4460
4510
  );
4461
4511
 
4512
+ /**
4513
+ * 市场每页条数。上游 /api/skills 的 pageSize 上限就是 100(宿主侧会夹住),用满它:
4514
+ * 上游有 19 万条(实测 total=192936),按 48 一页要翻 4019 页,按 100 翻 1930 页。
4515
+ */
4516
+ const HUB_PAGE_SIZE = 100;
4517
+
4518
+ /**
4519
+ * 市场条目的唯一性不变式:同一个 slug 只出现一次。
4520
+ *
4521
+ * 为什么必须全局去重(跨页 + 页内一起):上游**同一页内**就会返回重复 slug——
4522
+ * 实测第 1 页(pageSize=100, sortBy=downloads)100 条里只有 88 个不同 slug
4523
+ * (agent-browser / anti-fraud 各出现 3 次,同一技能被不同发布者重复发布)。
4524
+ * 只按「与已加载页」去重挡不住页内重复:重复 key 进 React 后 reconciliation
4525
+ * 错乱,真机表现是卡片越滚越多、计数与实物不符(297 张卡 vs 计数 285)、
4526
+ * 搜索像没生效。 HubCard 的 key 是 slug,所以这条不变式同时是渲染正确性的前提。
4527
+ *
4528
+ * @param {object[]} items - 上游条目(已规范化的形状)。
4529
+ * @returns {object[]} 去重后的新数组(保持原顺序,不修改入参)。
4530
+ */
4531
+ function dedupeHubSkills(items) {
4532
+ const seen = new Set();
4533
+ const unique = [];
4534
+ for (const item of items ?? []) {
4535
+ const slug = String(item?.slug ?? '');
4536
+ if (seen.has(slug)) continue;
4537
+ seen.add(slug);
4538
+ unique.push(item);
4539
+ }
4540
+ return unique;
4541
+ }
4542
+
4462
4543
  /**
4463
4544
  * 技能能力面的入口组件:管理 + 市场。
4464
4545
  *
@@ -4482,7 +4563,16 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4482
4563
  // 宿主支持的**全部**落盘位置(含还没建的)。老宿主没有这个方法 → 空数组 → 退回发现集。
4483
4564
  const [targets, setTargets] = useState([]);
4484
4565
  // 市场只有**一个**来源:skillhub.cn(直连上游、本插件只做披露)。
4485
- const [hub, setHub] = useState({ items: [], total: 0, loading: false, error: '', kind: '' });
4566
+ // page/done 是翻页的真值来源:上游 total=192936(实测,深页有货),只取首页就会
4567
+ // 「计数写着 19 万、屏幕只有一页」——数字与实物对不上,用户以为下拉会刷新。
4568
+ const [hub, setHub] = useState({ items: [], total: 0, page: 1, done: false, loading: false, loadingMore: false, error: '', kind: '' });
4569
+ // 滚动回调与「加载更多」按钮都在渲染之外触发,那里读不到本次渲染的 hub——ref 拿最新的一份。
4570
+ const hubRef = useRef(hub);
4571
+ useEffect(() => { hubRef.current = hub; }, [hub]);
4572
+ // 同步的在途标志:state 要等下一次渲染+effect 才同步进 ref,那之间的重复触发
4573
+ // (双击按钮、滚动事件连续 fire)会并发打同一页——去重后第二次响应全是重复项,
4574
+ // 会被误判成 done,翻页提前终止。所以这道闸必须是同步的。
4575
+ const hubInFlight = useRef(false);
4486
4576
  const [hubCategory, setHubCategory] = useState('');
4487
4577
  const [hubCategoryLabels, setHubCategoryLabels] = useState({});
4488
4578
  // 外部源条目被选中时的披露信息(点安装时按 slug 拉一次 detail)。
@@ -4520,21 +4610,34 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4520
4610
  summaryRef.current?.({ state: 'down', count: null, error: result.error || '', scopeTabs: [] });
4521
4611
  return result;
4522
4612
  }
4523
- // 宿主的 `list` 返回的是**两个数组**(skills / disabled),不是带 enabled 字段的一坨。
4524
- // 停用态因此由「在哪一个数组里」表达——这里统一打上 enabled 标志,
4525
- // 让列表、筛选、开关三处读同一个字段,而不是各自去猜数组来源。
4526
- const next = [
4527
- ...skillRows(result.skills || []).map((row) => ({ ...row, enabled: true })),
4528
- ...skillRows(result.disabled || []).map((row) => ({ ...row, enabled: false })),
4529
- ];
4613
+ // 宿主返回的是**一个**数组:停用不再是「在另一个数组里」,而是行上的派生态
4614
+ // (enabled = 两个调用面是否都关掉,见 lib/skills-registry.js 的 toView)。
4615
+ // 旧版是两个数组、停用件由宿主扫盘补一份——那套做法的代价见 SkillsManagerGateway.list。
4616
+ const next = skillRows(result.skills || []);
4530
4617
  setRows(next);
4531
- // 计数是**两个数组之和**:已停用的技能也是技能,面板里看得见、也能被重新启用,
4532
- // 只数 skills 会让「能力层写着 8、列表里有 11 行」这种自相矛盾出现。
4618
+ // 计数就是行数:已停用的技能也是技能(面板里看得见、也点得动),
4619
+ // 只数可调用的会让「能力层写着 8、列表里有 11 行」这种自相矛盾出现。
4533
4620
  summaryRef.current?.({ state: 'ready', count: next.length, error: '', scopeTabs: skillScopeTabs(next) });
4534
4621
  return result;
4535
4622
  }, [call, workspaces]);
4536
4623
 
4537
4624
  useEffect(() => { void load(); }, [load]);
4625
+ // 切回这个标签页时重取一次列表:**宿主侧本来就已经自动发现**(它用 chokidar 监听技能根,
4626
+ // 新增/改名/删除技能无需重启即到达模型),所以面板缺的只是「知道自己该重取一次」。
4627
+ //
4628
+ // ⚠️ 为什么不是订阅 `skills/change` 做到推送式刷新:那个事件**不在**宿主的 Remote 转发
4629
+ // 白名单里,而白名单由 `dsh-api-remotes` 独占(`registerRemoteEvents` 只接受一个源,
4630
+ // 第二个注册直接抛 already registered)——第三方插件没有合法途径把它送到浏览器。
4631
+ // 这是契约没开这个口,不是我们偷懒;如实写在这里,免得下一轮有人又去试一遍。
4632
+ //
4633
+ // 也不轮询:技能变化远没有 MCP 连接状态那么频繁,而每次 list 都要扫全部技能根并逐个读文件
4634
+ // 解析 frontmatter。焦点重取的收益/成本比明显更好。
4635
+ useEffect(() => {
4636
+ if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') return undefined;
4637
+ const onVisibilityChange = () => { if (document.visibilityState !== 'hidden') void load(); };
4638
+ document.addEventListener('visibilitychange', onVisibilityChange);
4639
+ return () => document.removeEventListener('visibilitychange', onVisibilityChange);
4640
+ }, [load]);
4538
4641
  useEffect(() => {
4539
4642
  // 安装目标:问宿主「可以写到哪里」。拿不到(老宿主)就留空,由 skillTargetOptions 退回归发现集。
4540
4643
  void (async () => {
@@ -4550,15 +4653,44 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4550
4653
  const searchHub = useCallback(async () => {
4551
4654
  setHub((current) => ({ ...current, loading: true, error: '', kind: '' }));
4552
4655
  try {
4553
- const result = await call('skillHubSearch', { keyword: hubKeyword, category: hubCategory, pageSize: 48 });
4656
+ // page: 1 是**重置搜索**:换关键字/分类时整体替换,翻页态归零。
4657
+ const result = await call('skillHubSearch', { keyword: hubKeyword, category: hubCategory, page: 1, pageSize: HUB_PAGE_SIZE });
4554
4658
  if (!result.ok) throw new Error(result.error || '搜索失败');
4555
- setHub({ items: result.skills || [], total: result.total || 0, loading: false, error: '', kind: '' });
4659
+ setHub({ items: dedupeHubSkills(result.skills), total: result.total || 0, page: 1, done: false, loading: false, loadingMore: false, error: '', kind: '' });
4556
4660
  } catch (error) {
4557
4661
  const message = String(error?.message || error);
4558
- setHub({ items: [], total: 0, loading: false, error: message, kind: classifyHubError(message) });
4662
+ setHub({ items: [], total: 0, page: 1, done: false, loading: false, loadingMore: false, error: message, kind: classifyHubError(message) });
4559
4663
  }
4560
4664
  }, [call, hubCategory, hubKeyword]);
4561
4665
 
4666
+ // 加载下一页:**追加**而不是替换(替换就成了「永远只有第 1 页」)。
4667
+ const loadMoreHub = useCallback(async () => {
4668
+ const current = hubRef.current;
4669
+ // 没有更多就不发:done(上游该给新条目的页全是重复项)或已显示满 total。
4670
+ if (hubInFlight.current || current.loading || current.loadingMore || current.done || current.items.length >= current.total) return;
4671
+ hubInFlight.current = true;
4672
+ setHub((prev) => ({ ...prev, loadingMore: true, error: '', kind: '' }));
4673
+ try {
4674
+ const result = await call('skillHubSearch', { keyword: hubKeyword, category: hubCategory, page: current.page + 1, pageSize: HUB_PAGE_SIZE });
4675
+ if (!result.ok) throw new Error(result.error || '搜索失败');
4676
+ setHub((prev) => {
4677
+ // 一条不变式表达两种重复:拼接后整体去重,新增的尾部就是这一页的净增。
4678
+ // 跨页重复(排名漂移)与页内重复(多个发布者同一 slug)一次挡住;
4679
+ // 净增为 0 = 上游翻不出新东西了,再滚也是重复请求。
4680
+ const merged = dedupeHubSkills([...prev.items, ...(result.skills || [])]);
4681
+ return { ...prev, items: merged, page: prev.page + 1, loadingMore: false, done: merged.length === prev.items.length };
4682
+ });
4683
+ } catch (error) {
4684
+ const message = String(error?.message || error);
4685
+ setHub((prev) => ({ ...prev, loadingMore: false, error: message, kind: classifyHubError(message) }));
4686
+ } finally {
4687
+ hubInFlight.current = false;
4688
+ }
4689
+ }, [call, hubCategory, hubKeyword]);
4690
+
4691
+ // 还有没有下一页:一条派生值,按钮显隐、滚动触发、停止条件都读它——不各写一份判断。
4692
+ const hubHasMore = hub.items.length < hub.total && hub.done !== true;
4693
+
4562
4694
  // 分类只取一次:它不随关键字变,放进搜索的 effect 里会每搜一次多打一次分类接口。
4563
4695
  useEffect(() => {
4564
4696
  void (async () => {
@@ -4578,9 +4710,7 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4578
4710
  // 而 importSkill 不传 writable 时宿主按可写处理 → 会直接写进内置目录)。
4579
4711
  const installTargets = scopeOptions.filter((option) => option.writable !== false);
4580
4712
  const shadowed = shadowedSkills(rows);
4581
- // ⚠️ 诊断只从**合并后的行**推导(已停用技能由宿主放进 disabled、面板合并进 rows)。
4582
- // 上一版这里传 `{skills: rows, disabled: []}`:宿主没有 illegal 通道,disabled 又被清空,
4583
- // 于是 `skillDiagnostics` 恒返回空 —— 整块诊断 UI 是死的(见函数上的注释)。
4713
+ // 诊断只从**行**推导(停用是两个调用面字段的派生态,见 lib/skills-registry.js 的 toView)。
4584
4714
  const diagnostics = skillDiagnostics(rows);
4585
4715
 
4586
4716
  // 加载某一行的正文。三态必须都落到 UI 上:失败没有终态,就会长得像「一直在加载」。
@@ -4617,12 +4747,11 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4617
4747
  }, [call, flash, load]);
4618
4748
 
4619
4749
  const toggleRow = useCallback(async (row, enable) => {
4620
- // 双向:MCP 面每行都有启用/停用开关,技能面必须同样能**恢复**已停用的技能
4621
- // (原先只有停用单向,已停用条目只能看、没有任何恢复入口)。
4750
+ // 双向:MCP 面每行都有启用/停用开关,技能面必须同样能**恢复**已停用的技能。
4622
4751
  const next = enable === undefined ? row.enabled === false : enable === true;
4623
- // 只送**引用**(source + root + 项目路径),不送 writable:可写与否由服务端判定,
4624
- // 客户端传什么不算数(否则「只读根不可写」只是客户端的礼貌)。
4625
- await act('setEnabled', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd, enabled: next });
4752
+ // 只送**引用**(source + root + 项目路径 + 磁盘条目 + 形态),不送 writable:可写与否由服务端
4753
+ // 判定,客户端传什么不算数(否则「只读根不可写」只是客户端的礼貌)。
4754
+ await act('setEnabled', { ...skillDetailRequest(row), enabled: next });
4626
4755
  }, [act]);
4627
4756
 
4628
4757
  const toggleModelInvocable = useCallback(async (row, modelInvocable) => {
@@ -4630,7 +4759,15 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4630
4759
  // 与启停同一条纪律:只送引用,可写与否由服务端判定。
4631
4760
  // 重新取数在 act 里:行上的权限来自**磁盘上的那个文件**(宿主侧从文件解析),刷出来的就是
4632
4761
  // 真正写下去的值;被拒绝时文件没变,开关如实停在原位,而原因由 act 说出来。
4633
- await act('setModelInvocable', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd, modelInvocable: modelInvocable === true });
4762
+ await act('setModelInvocable', { ...skillDetailRequest(row), modelInvocable: modelInvocable === true });
4763
+ }, [act]);
4764
+
4765
+ const toggleUserInvocable = useCallback(async (row, userInvocable) => {
4766
+ // 「用户可调用」的开关:写技能文件里的 user-invocable。
4767
+ // 它存在的理由与「停用」直接相关:停用 = 两个面都关,而只给一个面开关的话,
4768
+ // 用户在面板里拨不回「仅手动可调用」那一档(只能去编辑文件)。
4769
+ // 纪律与上面那个完全一致(只送引用、可写与否由服务端判定、失败要把原因说出来)。
4770
+ await act('setUserInvocable', { ...skillDetailRequest(row), userInvocable: userInvocable === true });
4634
4771
  }, [act]);
4635
4772
 
4636
4773
  const removeRow = useCallback(async (row) => {
@@ -4638,7 +4775,7 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4638
4775
  // 之前这个菜单项接的是 toggleRow —— 点删除只会把技能停用,还报成功。
4639
4776
  if (!confirm('确认删除技能「' + row.name + '」?' + (row.kind === 'bundle' ? '整个目录(含 scripts/references)' : '该文件') + '会从磁盘移除,不可撤销。')) return;
4640
4777
  // 删成功才清选中:失败时那一行还在,清掉选中只会让用户面对一个空详情、还不知道为什么。
4641
- if (await act('removeSkill', { name: row.name, source: row.source, root: row.root, projectPath: row.cwd })) setSelectedKey('');
4778
+ if (await act('removeSkill', skillDetailRequest(row))) setSelectedKey('');
4642
4779
  }, [act]);
4643
4780
 
4644
4781
  const confirmInstall = useCallback(async (target) => {
@@ -4744,8 +4881,13 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4744
4881
  h('div', { className: 'dsh-mcp-item-main' },
4745
4882
  h('div', { className: 'dsh-mcp-item-name' }, row.name),
4746
4883
  h('div', { className: 'dsh-mcp-item-sub' },
4747
- h(Badge, { cls: 's-off' }, '已停用'),
4748
- h('span', null, row.root || ''),
4884
+ // ⚠️ 这里以前还塞了一整条**根路径**(`row.root`)。真机实测:那串路径在
4885
+ // 侧栏 185px 的单元格里溢出(scrollWidth 237 > clientWidth 185),
4886
+ // 而且 `overflow:hidden` **没有省略号**,于是那行显示成「已」后面什么都没有
4887
+ // ——用户看到的是半截字,还以为是渲染坏了。
4888
+ // 路径本来就在**组头**的 title 里(组的身份就是 source + cwd),
4889
+ // 行内再放一遍既冗余又撑破布局。这里只说状态。
4890
+ h(Badge, { cls: 's-off' }, row.migrated ? '旧机制停用' : '已停用'),
4749
4891
  ),
4750
4892
  ),
4751
4893
  // 已停用条目也要能一键恢复:原先它们只能看不能动。
@@ -4774,13 +4916,27 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4774
4916
  onRetry: () => void loadBody(selected),
4775
4917
  onToggle: toggleRow,
4776
4918
  onModelInvocable: toggleModelInvocable,
4919
+ onUserInvocable: toggleUserInvocable,
4777
4920
  onRemove: removeRow,
4778
4921
  }),
4779
4922
  )
4780
4923
  // 市场与 MCP 面同一副骨架:分类药丸 + 搜索 | 说明 | 网格 + 卡片。
4781
4924
  // 曾经这一面用的是 .dsh-skills-bar / .dsh-skills-tags / .dsh-skills-grid / .dsh-skills-card
4782
4925
  // 一整套私有原语——同一个「发现市场」在两个能力面长得不一样,于是切换时像换了个产品。
4783
- : h('div', { className: 'dsh-mcp-market-view', role: 'region', 'aria-label': '技能发现市场' },
4926
+ // 滚动监听挂在滚动容器**自己**身上(React 对 scroll 是原生直挂,不靠事件委托),
4927
+ // 不在 effect 里挂:宿主外壳在持续高频重渲染本面板(真机 wire 上 list/skillTargets
4928
+ // 每秒一轮、连续上百次渲染),被动 effect 的重跑会被饿住——IntersectionObserver
4929
+ // 版的观察器在首屏搜索完成后迟迟创建不出来,实测「首次滚动不加载、交互过一次才加载」。
4930
+ // onScroll 的处理器每次渲染都是新闭包,读的也是当次渲染的 loadMoreHub,不过期。
4931
+ : h('div', {
4932
+ className: 'dsh-mcp-market-view', role: 'region', 'aria-label': '技能发现市场',
4933
+ onScroll: (event) => {
4934
+ const box = event.currentTarget;
4935
+ // 离底部还有 200px 以上就什么都不做——给一点提前量,用户看到的就是「到底就有」。
4936
+ if (box.scrollTop + box.clientHeight < box.scrollHeight - 200) return;
4937
+ void loadMoreHub();
4938
+ },
4939
+ },
4784
4940
  h('div', { className: 'dsh-mcp-market-toolbar' },
4785
4941
  h('div', { className: 'dsh-mcp-market-head' },
4786
4942
  h('div', { className: 'dsh-mcp-search' },
@@ -4809,24 +4965,29 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4809
4965
  ),
4810
4966
  h('div', { className: 'dsh-mcp-market-section' },
4811
4967
  h('b', null, 'skillhub.cn(外部源)'),
4812
- h('span', null, hub.loading ? '正在搜索…' : hub.total + ' 条结果 · 装入' + (scope === 'global' ? '全局' : '项目')),
4968
+ // 计数说清「已显示 / 共」:只写总数就是 19 万条结果旁边只摆一页卡片——
4969
+ // 数字是真的,用户在面板里却到不了其余条目,看着就像没有。
4970
+ h('span', null, hub.loading ? '正在搜索…' : '已显示 ' + hub.items.length + ' / 共 ' + hub.total + ' 条 · 装入' + (scope === 'global' ? '全局' : '项目')),
4813
4971
  ),
4814
4972
  // 两种失败**分开说**:宿主没这个接口 ≠ 上游挂了。混成一句会让用户去查网络,
4815
4973
  // 而真正要做的是重启宿主加载新版插件。
4816
- hub.error
4817
- ? (hub.kind === 'missing'
4818
- ? h(Notice, {
4819
- tone: 'warn', icon: 'warn',
4820
- title: '当前宿主进程还没有外部源接口',
4821
- children: '面板已更新,但这个正在运行的宿主进程是在更新之前启动的,所以它不认识 skillHubSearch。重启宿主(dsh 进程)后即可使用。',
4822
- })
4823
- : h(Notice, {
4824
- tone: 'warn', icon: 'warn', title: '连接 skillhub.cn 失败', children: hub.error,
4825
- actions: [h(Btn, { key: 'retry', cls: 'sm', disabled: hub.loading, onClick: () => void searchHub(), children: '重试' })],
4826
- }))
4827
- : (hub.items.length === 0
4828
- ? h(SkillsEmpty, { text: hub.loading ? '正在搜索…' : '没有匹配的外部技能。' })
4829
- : h('div', { className: 'dsh-mcp-grid' },
4974
+ // 错误 Notice 只在**一页都没有**时替换整个结果区:首屏失败 = 市场打不开,这么摆;
4975
+ // 加载更多失败是另一回事——已加载的页面要留着,错误与重试放在结果区上方,
4976
+ // 否则上游抖一下,用户已翻的几百张卡就全没了(追加失败是翻页引入的新失败面)。
4977
+ hub.items.length === 0
4978
+ ? (hub.error
4979
+ ? (hub.kind === 'missing'
4980
+ ? h(Notice, {
4981
+ tone: 'warn', icon: 'warn',
4982
+ title: '当前宿主进程还没有外部源接口',
4983
+ children: '面板已更新,但这个正在运行的宿主进程是在更新之前启动的,所以它不认识 skillHubSearch。重启宿主(dsh 进程)后即可使用。',
4984
+ })
4985
+ : h(Notice, {
4986
+ tone: 'warn', icon: 'warn', title: '连接 skillhub.cn 失败', children: hub.error,
4987
+ actions: [h(Btn, { key: 'retry', cls: 'sm', disabled: hub.loading, onClick: () => void searchHub(), children: '重试' })],
4988
+ }))
4989
+ : h(SkillsEmpty, { text: hub.loading ? '正在搜索…' : '没有匹配的外部技能。' }))
4990
+ : h('div', { className: 'dsh-mcp-grid' },
4830
4991
  ...hub.items.map((item) => h(HubCard, {
4831
4992
  key: item.slug,
4832
4993
  entry: item,
@@ -4841,7 +5002,19 @@ const SkillsPanel = ({ call, workspaces, mode = 'manage', scopeFilter = 'all', o
4841
5002
  setHubDetail(result.ok ? result : { pageUrl: target.pageUrl, publisher: null, security: null, requiresApiKey: target.requiresApiKey });
4842
5003
  })();
4843
5004
  },
4844
- })))),
5005
+ }))),
5006
+ hub.error && hub.items.length > 0
5007
+ ? h(Notice, {
5008
+ tone: 'warn', icon: 'warn', title: '加载更多失败', children: hub.error,
5009
+ actions: [h(Btn, { key: 'retry-more', cls: 'sm', disabled: hub.loadingMore, onClick: () => void loadMoreHub(), children: '重试' })],
5010
+ })
5011
+ : null,
5012
+ // 翻页区:显式按钮(兜底,键盘可达)。滚动到底的自动加载挂在上面那个滚动
5013
+ // 容器的 onScroll 上——没有更多时整块消失,摆一个点不动的按钮比不摆更糟。
5014
+ hubHasMore
5015
+ ? h('div', { className: 'dsh-skills-hub-more' },
5016
+ h(Btn, { cls: 'sm', disabled: hub.loadingMore, onClick: () => void loadMoreHub(), children: hub.loadingMore ? '加载中…' : '加载更多' }))
5017
+ : null,
4845
5018
  h('div', { className: 'dsh-skills-note' },
4846
5019
  h('b', null, '外部源,本插件未核验。'),
4847
5020
  'skillhub.cn 上的技能由任何人发布,条目里没有许可信息;安装前会把上游给的发布方认证与安全扫描结论摆出来,但那不等于本插件的审查结论。收录 ≠ 背书。'),
@@ -5078,6 +5251,7 @@ const MCP_CONTRIBUTION = {
5078
5251
  sk('detail', [skParam('payload', 'SkillPayload')], 'SkillDetailResult'),
5079
5252
  sk('setEnabled', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
5080
5253
  sk('setModelInvocable', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
5254
+ sk('setUserInvocable', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
5081
5255
  sk('removeSkill', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
5082
5256
  sk('importSkill', [skParam('payload', 'SkillPayload')], 'SkillMutationResult'),
5083
5257
  sk('skillTargets', [skParam('payload', 'SkillPayload')], 'SkillTargetsResult'),