frzzaxszz/dsh-skill-catalog

plugin

DSH Web GUI 的技能面板:列出全部已安装技能并附中文备注,提供八类分类筛选、拼音与模糊搜索、置顶与最近使用排序,可原地管理每个技能(置顶、关闭、定位、卸载并可撤回)。随包自带一个维护技能。

技能包deepseek-harnessdshskill-picker

dsh-skill-catalog

DSH Web GUI 的技能面板:全量技能发现 + 全中文备注 + 8 类分类筛选 + 技能管理(置顶 / 关闭 / 定位 / 卸载可撤回)。 个人 fork,不发布到 npm,永不被上游升级覆盖。

这是什么

官方内置了 / 技能补全,但它是记忆驱动的——你得先记得技能名。技能一多就抓瞎。本插件在输入框旁加一个 ⚡ 按钮:点开就是全部技能,带中文备注、按分类可筛、拼音可搜,选中后把 /技能名 插进发送框,随消息发出由 DSH 原生机制加载。

记得名字用官方 /,忘了名字用 ⚡——两者互补,可同时使用。

为什么用它(vs 官方 / 补全)

官方内置了 / 技能补全,但它是记忆驱动的——你得先记得技能名,打 / + 前缀才能过滤出来。技能一多就抓瞎:

官方 / 补全dsh-skill-catalog
触发输入框打 /输入框旁 ⚡ 按钮
查找方式前缀记忆驱动,忘了名字就找不到全列表浏览 + 关键字搜索,忘了名字也能翻到
分类筛选无8 类 chips:工程 / 排错 / 需求 / 设计 / 文档 / 调研 / 技能 / 自动化
中文技能只能打名字/前缀拼音直搜:ji yi / jiyi / jy 都能搜到中文技能
排序固定最近使用置顶、常用靠前
描述可见精简完整描述(中文)一眼看全

记得名字用官方,忘了名字用本插件——两者互补,可同时使用。

特性

  • ⚡ 一键弹出全部技能(闪电图标,人人看得懂)
  • 8 类分类筛选:搜索框下排 chips,点一个只看该分类,再点一次回到「全部」
  • 🔤 拼音搜索:技能名和描述都生成拼音索引(全拼带空格 ji yi / 连打 jiyi / 首字母 jy),中文技能不用记字就能搜
  • 🔍 实时搜索(技能名 / 描述 / 拼音都搜)
  • ⌨️ 键盘导航:弹层内 ↑↓ 选择、Enter 插入、Esc 关闭,全程不碰鼠标
  • 🧠 最近使用置顶、常用靠前的智能排序
  • 🗂️ 自动 / 手动分组:一眼看出哪些技能模型能自己调、哪些只能你手动 / 调
  • 🛠️ 技能管理:右键(手机长按)置顶 / 关闭 / 在文件管理器定位 / 卸载(移入备份,可撤回)
  • 🌏 跨端共享:桌面端与网页端的置顶、使用记录共用一份
  • 🎨 跟随 Web UI 主题(CSS 变量),浅色/深色自适应
  • 📦 纯 client + host 双半插件,无额外运行时安装

技能管理

右键任意技能行(手机长按 500ms):

动作效果
置顶到顶部固定在该位置,顺序由你排
关闭把 SKILL.md 改名成 SKILL.md.disabled —— agent 真的不再加载它,不是障眼法
在文件管理器中定位打开技能所在目录
卸载移进 $DSH_HOME/skill-backups/<时间戳>-<名字>/,面板里可撤回;代码里没有任何破坏性删除

关闭的技能不会消失:面板右下角「已关闭 N」可以进去把它们开回来。

分类筛选

搜索框下面一排 chips,点一个只看该分类,再点一次回到「全部」:

chip全名覆盖什么
工程工程开发写码、实现、评审、代码设计、计划、分支收尾
排错诊断排错Bug、性能回归、会话复盘、沙箱 ACL
需求需求打磨连环追问、规格、工单、分诊、原型
设计前端设计界面、动效、文案语气
文档文档办公Word / Excel / PPT / Univer 全套
调研调研写作调研、教学、交接、导入、笔记、PR 描述
技能技能管理写技能、用技能、自我改进
自动化自动化浏览器、桌面操作、Dify 工作流

分类是纯筛选,不动分组——选中后仍是「置顶 / 最近使用·自动 / 最近使用·手动 / 全部·自动 / 全部·手动」五段,只是每段只剩该分类的技能。

分类表在 src/client/categories.js,一张 技能名 → 分类 的本地映射表,只影响显示层:不动任何 SKILL.md,所以不会改变模型的技能路由判断。技能若自己在 frontmatter 写了 category: 则优先用它,表只做兜底。未分类的技能不单独成 chip,只在「全部」里出现。

改完分类表必须把它内联进 lib/client.js(GUI 加载的是编译产物),跑 node scripts/inline-categories.mjs 即可;node scripts/check-categories.mjs 会拿真实技能全量自测。

安装

# 方式一:Git 依赖直装(推荐)
dsh plugin --profile web add "git+https://github.com/frzzaxszz/dsh-skill-catalog.git#path:/plugin"

# 方式二:克隆后 link(想在本地改代码时用)
git clone https://github.com/frzzaxszz/dsh-skill-catalog.git
dsh plugin --profile web add link:/path/to/dsh-skill-catalog/plugin

# 方式三:GitHub 简写
dsh plugin --profile web add "github:frzzaxszz/dsh-skill-catalog#path:/plugin"

⚠️ #path:/plugin 不能省(local link 则是 /plugin 结尾)。这个仓库把 package.json 与 cordis.patch.yml 放在 plugin/ 子目录,根目录只放仓库说明。少了这一段,pnpm 在仓库根 找不到 package.json,安装直接失败。

本 fork 不发布到 npm,所以没有 dsh plugin add dsh-skill-catalog@版本 这条路—— 那会装到上游的同名包,功能完全不同。

网络特例(国内/HTTPS 受限时):

  • git clone 走 HTTPS 慢或不通时,改用 SSH:git clone git@github.com:frzzaxszz/dsh-skill-catalog.git
  • github: 简写强制 HTTPS clone;仅 SSH 可用时改用(同样要带 #path:/plugin): dsh plugin --profile web add "git+ssh://git@github.com/frzzaxszz/dsh-skill-catalog.git#path:/plugin" (或先执行 git config --global url."git@github.com:".insteadOf "https://github.com/" 让 pnpm 走 SSH)

重启 dsh web(或刷新页面加载新 bundle)后生效。

自带技能

装本插件会一并装上它自己的维护技能,不需要另外去 ~/.agents/skills 里手放:

技能什么时候用
sync-catalog-zh-descriptions新装了技能、面板上还有英文备注、或新技能没落到分类 chip 里时,调它一次,同时补中文备注与分配分类

这些技能放在仓库的 plugin/bundled-skills/ 里。因为 DSH 读技能的四个根 (~/.agents/skills、$DSH_HOME/skills、<cwd>/.agents/skills、<cwd>/.dsh/skills) 都不包含 node_modules,光把文件塞进包里是没人读的——所以 host 半边在启动时向 skills 服务注册了一个 provider(rank 350,源码见 src/bundled-skills.js), 行为与 dsh-mattpocock-skills-deck 的随包技能一致。

优先级:项目级技能(100/200)> 随包(350)> 你自己装在 ~/.agents/skills 的同名副本(500)。 如果你自己放了一份同名技能,你那份会盖住随包的——这是故意的,用户的选择优先。

为什么这件事要「调用一个 skill」,而不是全自动

新技能一进来就自动翻译、自动归类,是更理想的形态,技术上本插件也做得到:host 侧 ctx.on('agent/pre-step', ({ agent }) => …) 拿到活 agent,agent.followup(msg) 把任务塞进 会话,模型自己就去做(同机的 meow-memory 就是这样全自动整理记忆的),不需要任何外部 API。

仍然设计成显式调用,是因为三个非技术理由:

  1. 要往你正在跑的会话里插话。 followup/steer 表现为对话里凭空多出一次助手动作。 技能面板是高频 UI,你只是点开看一眼列表,却撞见 agent 在改写分类表——体验有争议, 而且会花你的 token。
  2. 判定「新技能」需要基线。 插件每次全量扫盘,本身没有「上次处理到哪」的记忆。要自动 就得再存一份已处理快照(name + 描述哈希),多一个状态文件与一套迁移/清理。
  3. 自动生成的质量低于显式调用。 分类边界需要揣摩(写文档 ≠ 文档、pr 归调研、 prototype 归需求),中文备注要求读懂使用场景。更麻烦的是错了不会报错——只是安静地 待在错的 chip 里。

取舍是:把你「记得调哪个技能」的负担降到零(插件自带它),但不把你「审一眼结果」的机会 一起拿走。要做全自动,可行路线是默认关闭的开关,或「面板显示『有 N 个未分类』+ 点一下补」 ——后者不插话也不多花 token,更稳妥。这些都还没做。

用法

  1. 打开任一会话,在输入框工具行右侧找到**⚡ 按钮**
  2. 点击弹出技能列表(可输入关键字或拼音过滤,如 ji yi 搜「记忆」)
  3. ↑↓ 选择、Enter 插入(或直接鼠标点选)→ 发送框自动出现 /技能名
  4. 继续输入你的话并发送——DSH 会识别 /技能名 手势,自动加载该技能并按其指令执行

示例:点选 duo-xuan-pi-gai 后发送框变为 /duo-xuan-pi-gai 帮我批改多选,发送后技能自动加载。也可以在输入框直接打 /duo xuan、/duoxuan 靠拼音补全选到它。

原理

DSH 的 dsh-tool-skill 在 agent/pre-step 阶段扫描用户消息中的 /kebab-case-name 手势(SKILL_GESTURE 正则),命中后把对应技能内容作为 skill-invocation 注入对话——即"用户消息里写 /技能名 就会自动加载技能"是官方既有能力,只是没有 UI。

本插件只补 UI 一层:

[client]  ⚡ 按钮 → fetch('/dsh-skill-catalog/skills')
                    ↓
[host]    扫描用户级 $DSH_HOME/skills + 项目级 <cwd>/.dsh/skills 等 → 技能目录(name + description)
                    ↓
[client]  点选 → inputActions.setDraft(draft + '/技能名 ')
                    ↓
[DSH]     agent/pre-step 识别手势 → 自动加载技能 → 执行
  • client 半:注册到官方 conversation.input.right 插槽(composer 工具行、发送按钮左侧的控件位),技能列表优先走官方宿主 skills API(remote.skills.list——与 DSH 内置 / 补全同源,会话作用域,自动含用户级/项目级技能),失败时回退到 host 扫描路由;插入文本走框架输入机的 inputActions.setDraft(单一路径,撤销/草稿持久化自动处理);最近/常用排序 + 拼音索引(pinyin-pro)在 client 侧生成,按技能缓存

与官方 / 补全的关系(v0.4.0 起:增强,而非并列)

v0.2.0–0.3.4:插件注册了一个独立的 / 候选源(skill-fuzzy),与官方 ui-skill 源并列——菜单里出现两个技能分组,搜索行为相互独立(冲突风险、视觉重复)。这条路已被官方堵死:inputTriggers.registerSource 对 (trigger, name) 有唯一性硬检查,重名直接抛 slash source "/skill" is already registered。

v0.4.0–v0.5.13:改为给官方 @deepseek-ai/dsh-client-ui-skill 包的 candidates 打文件补丁——候选逻辑从 skill.name.startsWith(query)(前缀匹配)换成调用插件注入的全局函数 window.__dshSkillPickerFuzzy(fuzzysort 模糊 + pinyin-pro 拼音 + 最近/常用排行)。v0.5.1 起由 host 端每次启动自动应用(另加 order: 2→-1:技能组排在命令组之上),首次修改前自动备份 .bak。代价是极度依赖"那个文件是一个可写的普通文件"——打包桌面端(app.asar 内)、pnpm 硬链接、迁移后悬空的软链,任一情况都让它静默失效。

v0.5.15 起(首选路径):改用运行时接管,不碰任何文件。官方的 inputTriggers 服务把源注册表放在实例字段 live.sources 上,里面就是活源对象;而斜杠菜单是每次调用现取 source.candidates:

// 注意:sources() / all() 不在服务上,它们在每个会话 controller 的 roster 里。
// InputTriggerService 的成员只有:inject, live, constructor, registerSource, sessionOf, sessions
const source = ctx.inputTriggers.live.sources.find((s) => s.trigger === '/' && s.name === 'skill')
const original = source.candidates
source.candidates = async (projection, args) => rank(await original(projection, { ...args, query: '' }), args.query)
source.order = -1                                    // 技能组排在命令组之上
const onPick = source.onPick                         // 选中记账
source.onPick = (args) => { track(args?.candidate?.name); return onPick.call(source, args) }

⚠️ 这里踩过的坑(0.5.14 未发布版本):我最初把它写成 ctx.inputTriggers.sources('/')。那个 { sources, all } 确实存在,但它属于每个会话 controller 内部的 roster 对象,不在服务上——于是查找永远返回 undefined,接管静默地什么都没做。现在 findSkillSource() 按 live.sources → sources() → all() → roster.* 依次尝试,并有回归测试锁死真实形状。

关键点:官方 candidates 返回的是已映射的展示项、且已被官方自己的匹配器过滤过,所以必须用空查询问它要全量(rankByName(items, "") 原样返回全部),再自己排序。官方规则全部继承(userInvocable 过滤、子智能体会话排除、「仅用户可调用」文案)。文件补丁保留为旧内核兜底,两条路同时存在时不会打架(运行时接管始终以空查询取全量,不会双重过滤)。

效果:官方「技能」分组仍是唯一一个 / 技能列表,只是匹配与排序被升级;⚡ 面板与 / 菜单共用同一个 rankPickerItems,因此匹配结果和显示顺序完全一致。接管成功时,⚡ 面板底部会显示 「/ 增强:运行时接管」 徽标 —— 一眼就能看出它到底有没有生效(这正是 0.5.14 那次翻车最该有的东西)。

手动兜底(旧流程,v0.5.15 起已不再需要):把官方包拷到 profiles/web/local/dsh-client-ui-skill/,profile package.json 加 "@deepseek-ai/dsh-client-ui-skill": "link:…",pnpm install 后重启 DSH。仅当你的内核连 inputTriggers 服务都不提供时才还需要它。

更新日志

  • v1.2.0:插件开始自带技能——装插件会一并装上 sync-catalog-zh-descriptions。 为什么需要机制上的改动:DSH 读技能的四个根都不包含 node_modules,把 SKILL.md 塞进包里 根本没人读,所以 host 半边在启动时向 skills 服务注册了一个 provider(src/bundled-skills.js, rank 350,list/get 自己读盘并解析 frontmatter),做法与 dsh-mattpocock-skills-deck 的 随包技能一致。降级设计:skills 服务不存在、或 registerProvider 抛错时一律返回 undefined 并只打一条 warn——一个技能 provider 不该让整个插件起不来。优先级:项目级(100/200)> 随包(350)> 用户自己装在 ~/.agents/skills 的同名副本(500),后者会盖住随包这份,这是故意的。 同时:README 重写(补分类章节、技能管理表、安装口径去掉已作废的 npm 方式、修掉「两处优化」 等过时表述),仓库根目录新增总览 README。

  • v1.1.0:⚡ 面板加「按分类筛选」——搜索框下面一排分类 chips(全部 / 工程 / 排错 / 需求 / 设计 / 文档 / 调研 / 技能 / 自动化),点一个只看该分类,再点一次回到「全部」。分类从哪来:新增 src/client/categories.js,一张 name → 分类 的本地映射表,走的是 zh-descriptions.js 同一条路(只影响显示层、不动任何 SKILL.md、插件升级不丢),所以不改变模型的技能路由判断;技能若自己在 frontmatter 写了 category: 则优先用它(host 磁盘扫描顺带把它带出来),表只做兜底。几个刻意的取舍:① 分类是纯筛选,不动分组——选中后仍是「置顶 / 最近使用·自动 / 最近使用·手动 / 全部·自动 / 全部·手动」五段,只是每段只剩该分类的技能(用户要的是「进一步筛选」,不是替换视图);② 单选不是多选(真要多选时搜索框更好用),chips 换行成两行而不横向滚动(隐藏的横向滚动没人会发现);③ 分类不记忆(每次打开回「全部」——记住了反而让人以为技能丢了),已关闭视图不显示 chips 也不受其影响,官方 / 菜单完全不掺分类(打字即用的模糊匹配,加分类只会破坏手感);④ 未分类的技能不单独成一个 chip,只在「全部」里出现;空分类不出 chip(点进去必然空白的按钮比没有更糟)。实现上的一次手工活:安装包里 build.mjs 被裁掉、esbuild 没装,没法重新打包,所以 categories.js 的代码是内联进 lib/client.js 的(和 zh-descriptions.js 同样待遇),脚本 scripts/inline-categories.mjs 幂等可重跑;scripts/check-categories.mjs 拿宿主的真实技能全量做自测(66 个技能全部分到类、8 个 chip 无一为空、无未分类、无重复键)。顺带:sync-catalog-zh-descriptions 技能同步升级,新技能接入后调一次即可同时补中文备注与分配分类。

  • v0.5.27:给「插完技能把光标放回输入框」加一张豁免牌 —— 配套 dsh-pocket 手机端新补丁「切会话不抢焦点」。起因:手机端装上那个补丁后,它把程序自己抢的焦点一律退回 ✗ —— 而本插件的 focusComposer() 正是程序主动聚焦(这正是它的意义所在),于是「插完技能光标不回来」复发。修法:focusComposer() 在 editor.focus() 之前写一个时间戳 window.__dshSkillPickerFocusing = Date.now(),那个拦截器看到 1.2 秒内的时间戳就放行 ✓(用时间戳而不是布尔,是因为紧随其后还有一次 60ms 的守卫重定位 —— 一次设值同时覆盖两次聚焦)。⚠️ 两个补丁是一对:只装一边,手机上就会复发 ✗。完整背景与两侧补丁全文见 local/dsh-pocket-mobile-fix/skill-panel-fileguard-fix-2026-10-02.md。

  • v0.5.26:四个细节修复 + 八个追加修复(全部是国豪实机挑出来的):① 选完技能光标先是不回来、② 回来了但落在开头、③ 右键菜单在浅色主题下看不清、④ 深色主题下半透明;追加 ⑤ 提示条不再常驻、⑥「已关闭」改成右下角一个入口、⑦字体统一到 DSH 自己的 UI 字体、⑧分组小标题对齐 DSH 规格 + 去掉 emoji、⑨修「已关闭的技能还出现在主列表里」、⑩行内那个置顶针也删掉(改走右键菜单,至此面板零 emoji)、⑪底部那句提示改成一行短的并永久保留、⑫搜索框改成"模型选择器那个"样式(无边框)、⑬面板加 data-dsh-skill-catalog 标记(配合 dsh-pocket 的手机端"文件守卫"放行 —— 那是个独立插件的 bug,详见 local/dsh-pocket-mobile-fix/skill-panel-fileguard-fix-2026-10-02.md)。① 光标不回输入框:他的原话 点了按钮然后选了 skill,skill 的名字确实落到聊天框里了,但是没有光标闪烁,意味着我还要点一下聊天框才能继续打字;但如果你是通过输入斜杠、在出来的列表中点击 skill,那它是有闪烁光标的。根因:点技能行时焦点落在了那个按钮上,随后面板关闭、按钮卸载 → 焦点掉回 body,于是输入框有内容但没光标;官方 / 菜单没这问题,因为整个过程焦点一直在 composer 里。修法:pick() 之后用一个版本无关的 DOM 兜底把焦点还回去 —— 从 ⚡ 自己的节点向上最多 8 层找到包含编辑器的祖先并 focus();放在 requestAnimationFrame 里执行,等面板卸载 + React flush 完。② 光标落到了开头:他紧接着截图指出 /coding 的光标在斜杠前面。根因:给 contenteditable 调 focus() 而不给选区时,规范就是把光标放开头 —— 我上一版正是只 focus、没摆选区。修法:显式把 Range 塌缩到末尾(等价于"鼠标点在这行末尾",官方编辑器会同步 DOM 选区),并在 60ms 后带守卫地再补摆一次(只在输入框仍聚焦时才动,免得覆盖用户刚点的别处)—— 防的是富文本编辑器 focus 后又把自己的选区重置回去。⚠️ 再调一次 setDraft 是没用的:文字没变它会直接 return,光标永远不动。实测:光标位置 15 / 15(= 文字长度)→ 末尾 ✅;直接键入 XYZ → /backup-memory XYZ ✅。③④ 菜单配色(改了三次才对,过程值得记):他先在网页端(浅色主题)发现字看不清,再在桌面端(深色主题)发现背景是半透明的。第一次错:背景用了 --dsw-alias-bg-elevated + 深色兜底 —— 实测这个变量在 DSH 里根本不存在,所以深色兜底永远生效,而文字用的是存在且跟随主题的 --dsw-alias-label-primary → 浅色主题下 深底深字,对比度实测 1.25。第二次错:改用官方语义变量 --dsw-specific-menu —— 它存在,但在深色下是 #43454a73(alpha 0.45),那是配合 DSH 自己的背景模糊用的;我没加模糊,于是对话内容直接透过菜单 ✓ 被他抓个正着(他的截图给了决定性证据:同一屏上面板实心、菜单半透明)。最终修法:背景改用面板自己用的那个变量 --dsw-specific-tip(两个主题都是实心 ✓),并加 backdropFilter: blur(8px) 当保险;文字/悬停仍走主题变量。实测(这次专门量"透明度"这个属性本身,因为前两轮就是没量它才来回翻车):浅色 → 面板与菜单同为 rgb(245,246,247)、alpha=1、对比度 17.46;深色 → 同为 rgb(53,54,56)、alpha=1、对比度 11.57 ✅。顺带:右键菜单之前完全没有 hover 反馈,这次补上了(和面板行同一套做法)。⑤ 提示条常驻不消失:他问 这个消息停留得也太久了吧,多少秒啊你 —— 提示条是 toast,本该自己消失,我漏了超时。修法:普通成功提示 2.6 秒自动消失;带「撤回」的(卸载)给 10 秒,因为撤销它等于撤掉唯一一条退路;面板重新打开时清空(上次的提示已经过期)。验证教训:我自己的探针先报"10.27 秒"、又报"产物里没这个数字",两次都是我的检查写错了(探针在提示条消失后误抓到了「⏻ 已关闭」分组标题;检查条件写死 10000 而 esbuild 把它印成了 1e4)—— 他说"我测了其实对的",是他对的。先怀疑自己的量具,再怀疑代码。 ⑥「已关闭」从常驻分组改成右下角一个入口:他的原话是 它应该跟我红方平齐,放在靠右下角的位置 + 点击后里面显示的都是所有已关闭的 skill + 在已关闭的 skill 列表中右键可以像刚刚一样弹出列表 + 不然你这个"已关闭"是不是有点太占地方了,它一直显示的话。修法:底部那行改成 justify-content: space-between —— 左边是操作提示(切到已关闭视图时提示也会随之变成「右键 = 开启 / 定位」),右边是一个小胶囊 ⏻ 已关闭 N;点它整个列表切换成已关闭视图(搜索框同样能过滤它,走同一个 matchRank),再点一次变 ← 全部技能 切回来;视图里右键/长按弹同一个菜单(对已关闭的技能只给「开启 / 定位」,不给「置顶 / 卸载」)。面板每次打开重置回全部视图(免得它停在上次的视角里)。顺带删掉了没人用的 actionSpanStyle。实测(真浏览器,用一个自己建的假技能走完整条路):全部视图 → 胶囊显示 ⏻ 已关闭 1 且右对齐 ✓、已关闭的技能不在主列表里 ✓;点胶囊 → all=false / listsClosed=true / listsNormal=false、提示变成「右键 = 开启 / 定位(手机长按)」✓;右键 → 菜单恰好是 /zz-view-selftest、⏻ 开启、📂 在文件管理器中定位 ✓;点开启 → 该行消失 ✓。测试后验证过磁盘:只剩他自己 8-13 关的 web-design-guidelines 一条,64 个技能一个没动。⑦ 字体统一:他的要求是 我想你用和右下角选择模型里的英文和中文一样的字体,这样才显得跟 DSH 比较统一。先量再改(避免又猜):把模型选择器 DeepSeek V4.1 Flash 和面板里每处文字的 fontFamily/size/weight/lineHeight 全读出来对比,结果字体族本来就一样(都是继承 DSH 的 --dsw-font-family),真正不一致的是技能名那一处——它被我刻意写成了 --ds-font-family-code(等宽代码字体),当初的理由是"/技能名 看起来像命令"。另外面板行还落在浏览器给 <button> 的默认字号 13.3333px 上(不是我声明的)。修法:新增 FONT_FAMILY = var(--dsw-font-family, …) 并在面板根、搜索框、行、名称、描述、状态行、菜单全部显式声明(不再靠继承碰运气),字号对齐 composer 的节奏(正文 14px / 行高 22-24px、次要文字 12px);ui-monospace 依赖清零。实测:模型选择器 14px / 400 / line-height 24px、面板技能名 14px / 400 / 22px、搜索框 14px、描述与页脚 12px ✅。教训:我第一版探针又量错了元素(抓到了外层容器而不是 nameStyle 那个 span),所以第一次得出结论"字体本来就一样"——量要对准真正落到属性上的那个元素。⑧ 分组小标题 + 去掉 emoji:他继续追问 这两个也是吗?感觉看着不像啊。就是这个小标题,还有,我感觉这图标没必要吧。加图标,那前面一团火,这种图标看着不像 DSH 的风格。照旧先量再改:拿 DSH 自己的分组标题(侧栏「工作区」)当基准,测出我那个小标题是 11px / 600 / letter-spacing 0.44px,而 DSH 是 14px / 400 / normal —— 又小又粗还带字距,这就是"不像"的全部原因;改完两者逐项一致(连颜色都是同一个 rgb(129,133,140))。图标全部去掉:分组标题的 📌/🔥/🗂️、右键菜单的 📌/⏻/📂/🗑、底部入口的 ⏻ 全部改成纯文字(分组标题本来就是标题,DSH 的标题也是纯文字)。唯一保留的是行尾那个置顶针(📌/📍)—— 它是有功能的开关(点一下置顶),只有置顶的行才亮。后来他也把这一条否掉了:那个置顶的按钮也不需要了吧,毕竟右键它就能有置顶 —— 于是行内那个针也删了,置顶/取消置顶只走右键菜单(「置顶」分组本身就说明了哪些被置顶)。至此整个面板零 emoji(实测 anyEmojiInPanel: false),行内只剩名称 + 描述。⑪ 底部提示的最终形态:他先说 这句话感觉有点影响美观,因为它和上面的都对不齐,而且它又显示不完全 —— 于是对齐 + 缩短,并做成"用过一次右键就永久隐藏"(localStorage 标记)。他看完之后改主意:我感觉你变成这一行就不错,那你就可以永久留着了,因为它一直显示很短,可以留着 —— 于是撤掉一次性逻辑,永久显示「右键可管理 · 手机长按」(已关闭视图里是「右键可开启 · 手机长按」)。实测:连开三次(含用过菜单之后、整页刷新之后)提示都在、aligned: true(左边与行文字对齐 = 列表 6 + 行 10 的 margin-left:16px)、truncated: false(不再出现「手机…」)✅;那一版的一次性标记已从代码删净(showHint/RC_HINT_KEY 0 命中)。⑫ 搜索框:他的要求 我这个搜索框能不能变成这个模型搜索的样式?我感觉确实可以跟 DSH 去统一一下。这次没能量到基准 —— 模型选择器里那个搜索框不是 <input>(三次探针分别在 input 列表、占位符匹配、全元素扫描里都找不到它),所以按他两张截图的视觉差异改:去掉那 1px 边框(透明边框以保持几何不变),保留淡填充;因为无边框就没有焦点提示,补上聚焦时一圈细环(boxShadow: 0 0 0 1px,用 React 的 onFocus/onBlur 实现 —— 内联样式写不了 :focus)。实测:失焦 border: 1px solid rgba(0,0,0,0) + 背景 rgba(38,49,72,0.06) + 无环;聚焦背景升到 0.1 + 一圈 0 0 0 1px;尺寸始终 324×36,不会跳动 ✅。后来他又微调高度:改成30看看,就是行高减个六 → 再加回3吧 → 32吧。关键坑(值得单独记):前两个数字根本没生效 —— 面板是 flex-direction: column + max-height 的容器,输入框作为 flex 子元素被压缩到了 21px,所以不管写 30 还是 33 渲染出来都是 21(他"看不出变化"正是这个原因,我还只改了数字)。直到给输入框加 flex: none,声明的 height 才第一次真正生效 —— 在 flex 容器里改子元素高度,先确认它没被 flex-shrink 吃掉。最终实测 height: 32px ✅。⑨ 关闭的技能还出现在主列表里(他报的,严重):我不是关闭了吗?这列表里居然还有他。先排除最常见的嫌疑:全盘搜 web-design-guidelines 的副本 —— 没有第二份,磁盘上就那一个目录、且确实是 SKILL.md.disabled(他自己 8-13 关的)。根因:取列表有两条路 —— 官方 skills API,和「本地扫描」兜底;为了「已关闭」视图能工作,我的扫描器是故意会返回 disabled 条目的,但前端把两条路的返回值当成了同一种形状 ✗ → 关掉的技能就从兜底那条路混进了主列表。修两层:① 扫描结果里 disabled === true 的直接不进主列表;② 主列表再按名字排除所有已关闭的技能 —— 这是唯一的构建点,不管数据来自哪条路都兜得住。实测(用他真实的那个技能验,不造数据、不写任何文件):主列表 mentionCount: 0 ✅、在主列表里主动搜 web-design 仍是 0 ✅、底部入口显示 已关闭 1 ✅、「已关闭」视图里恰好出现 1 次 ✅;测试后复验磁盘:仍关闭的只有他这一条、技能数 64 不变。全量 npm test:88 tests / 87 pass / 0 fail

  • v0.5.25:修 ⚡ 面板的弹出位置:改成贴窗口右边缘,不再横跨正文——国豪反馈:我感觉点这个按钮,列表出现的位置不太好。如果再靠右显示就好了。根因:面板原本是 position: absolute; right: 0,贴的是 ⚡ 按钮自己的右边缘 —— 而 ⚡ 挨着模型选择器、离输入框右边缘还有 200 多像素,所以面板被"右对齐"到那个位置,于是往左横跨半个屏幕、压住对话正文。修法:改成 position: fixed + right: 24px(贴窗口右边缘),纵向偏移仍由 ⚡ 按钮的 getBoundingClientRect() 量出来(打开前就量好,避免第一帧跳一下)。实测(真实浏览器里量的坐标,不是目测):面板右边缘 x=1488、窗口宽 1512 → 距右边 24px ✅;改之前它右边缘只会落在 ⚡ 按钮那里(x≈1083)。全量 npm test:88 tests / 87 pass / 0 fail

  • v0.5.24:删掉面板底部那两个徽标(「本地扫描」/「/ 增强:运行时接管」)——国豪的反馈:确实有了,不过本地扫描和增强感觉没必要啊,感觉有点占地方,有点多余。他说得对:它们是"给排障的人看的",不是给用户看的,却常驻占掉两行。但没有直接丢掉 —— 「插件一切正常」和「插件压根没在跑」在界面上一模一样,这正是 issue #14 的教训,所以信息搬到了不占地方的两处:① ⚡ 按钮的 tooltip(鼠标悬停才显示,如 选择技能(插入 /技能名 到发送框) · 列表来自本地扫描 · / 增强:运行时接管);② 控制台一行(面板每次打开打印一次:技能条数 + 列表来源 + / 增强是否真的生效)。顺带把已经没人用的 sourceBadgeStyle / sourceBadgeTextStyle 一并删除。全量 npm test:88 tests / 87 pass / 0 fail

  • v0.5.23:⚡ 面板里可以直接管理技能了:关闭 / 开启 · 定位 · 卸载(可撤回)——起因是国豪视频评论区有人问"能加一个管理 skill 功能吗?实现删除、加、启动、关闭等操作"。查证结论:官方没有任何技能管理界面(把 asar 里 289 个官方包全扫了一遍,没有禁用/卸载技能相关代码;评论里说的"专门的插件"指的是 find-skills 那种搜索+安装的 skill,只解决"加"),所以这块是真空白。交互方式(国豪的主意,比我的第一版好):动作不占行内 UI,而是右键技能行弹出一个小菜单(置顶 / 关闭 / 定位 / 卸载),手机则长按 500ms 弹同一个菜单(滑动即取消,长列表滚动不会误触);行内只留一个 glyph(📌/📍,兼作"已置顶"状态指示)。踩到的 UI 坑:关闭监听最初挂在捕获阶段,会在菜单项自己的 onClick 之前就把菜单关掉 → 点了没反应;改成冒泡阶段 + 菜单自身 stopPropagation。①「关闭」是真关闭,不是障眼法:读了官方 dsh-skill-filesystem 的判定逻辑 —— 一个技能 = 目录下有 SKILL.md(大小写严格),或技能根下的顶层 .md。所以关闭就是把 SKILL.md 改名成 SKILL.md.disabled:provider 直接看不见它,agent 也不再加载,要回来只需改回名字。②「卸载」从不删除:移进 $DSH_HOME/skill-backups/<时间戳>-<名字>/(旁边写 manifest.json 记原路径),面板里出现「撤回」;skill-ops.js 里一个破坏性文件调用都没有,而且有一条结构性回归测试盯着这件事(注释里出现过破坏性调用的名字都会让它红 —— 这是它第一次触发时教我的)。跨盘无法 rename 时直接报错,绝不退化成"复制+删除"。③ 三道安全闸(这些路由不需要鉴权就能到达,所以不是可选项):请求必须带 x-dsh-skill-catalog: 1 自定义头(跨站表单 POST 设不了自定义头,CORS 预检 host 也不应答);客户端只报技能名,路径由 host 自己查(⚡ 面板主数据来自官方 API,那套 DTO 不保证带 path;而且浏览器说了不算);路径必须恰好落在已知技能根的一层之下(../ 逃逸、根目录本身、技能内的文件、任意绝对路径全部拒绝)。④ 一个必须补的洞:官方 skills/list 不会返回已关闭的技能 → 关掉之后就再也找不到、开不回来了;所以面板额外拉一次 host 扫描,单独渲染「⏻ 已关闭」分组专门用来开回来。⑤ 真机端到端实测(用我自己建的假技能,全程只动它一个):关闭后磁盘上 SKILL.md→SKILL.md.disabled ✓、扫描仍能列出它且 disabled:true ✓(这是"能开回来"的前提)、开启还原 ✓、卸载后原位置消失而备份里 SKILL.md+manifest 完整 ✓、backups 能列出 ✓、restore 回原位 ✓、不带自定义头 → 403 ✓、name 塞 ../../../Windows/System32 → 404 ✓、GET → 405 ✓;测试后你的技能数一个没变、备份目录零残留。回归测试:新增 test/skill-ops.test.mjs 13 例(路径闸 9 种情形、关闭/开启往返、重复关闭拒绝、开启未关闭的拒绝、卸载后原位置消失但备份完整且 manifest 正确、撤回后再占用时拒绝覆盖、备份原路径被改成根外后不可撤回、备份 id 不允许带路径、"模块里不存在破坏性调用"、无 manifest 的杂目录被忽略、备份目录必须落在 DSH_HOME 下)。全量 npm test:88 tests / 87 pass / 0 fail

  • v0.5.22:「置顶 / 最近使用」改成两端(乃至手机)共用一份——起因是国豪发现"桌面端和网页端的置顶不一样",并当场质疑"不就读同一个配置吗,要一个小时?"。他是对的,而且根因就是"读的不是同一个配置":localStorage 按源(origin)隔离,桌面端是 dsh-app://app、网页端是 http://127.0.0.1:3080、手机连上去又是第三个源 —— 同一份数据被存了三遍,所以在桌面端钉的技能网页端永远看不到。修法(约 40 行 host + 一个客户端模块,不是重写数据层):host 侧新增 /dsh-skill-catalog/state(GET 取 / PUT 存),存到 $DSH_HOME/dsh-skill-catalog-state.json(写用 temp+rename,原子);客户端新增 src/client/shared-state.js,把这份母本镜像进 localStorage,于是渲染期的同步读取一行都不用改(原来的数据路径是同步的,改成异步要动一大片 —— 这个顾虑没必要)。细节:① 每次 savePinned/saveUsage(含 / 菜单里的选中记录)都会自动回推;② 插件启动时 + ⚡ 面板每次打开时都会拉取,所以另一端改了立刻能看见;③ 首次同步用并集、之后才跟随(关键,实测中发现的坑):host 端 ?migrate=1 会把收到的状态与已有状态合并再返回;客户端用一个 dsh-skill-catalog:shared-synced 标记区分"这台还没同步过"。没同步过且本地有数据 → 走并集迁移(绝不覆盖);同步过 → 直接采纳文件(所以"在 A 端取消置顶"能真的传播到 B 端)。为什么必须有这一步:如果首次也直接采纳,那么哪台客户端先启动,文件就被它写成什么样,后面启动的那台就会丢掉自己独有的钉子 —— 桌面端 3 条、网页端 2 条的情况下,网页端先起就会把桌面端的 ji-zhang 吃掉。端到端实测时正是这条被暴露出来的;④ 文件不存在 / 为空而本地有数据时,推送而不是覆盖为空;⑤ 全程 best-effort:host 不可达时行为和以前完全一样(纯本地),绝不会因为同步失败把选择器弄坏。回归测试:新增 test/shared-state.test.mjs 7 例(拉取即镜像、缺文件时以本机播种并带 migrate=1、迁移返回的并集被本地采纳、远端为空时不把本机数据清成空、推送内容正确、host 失败时一律返回 false 且不抛、同步后会广播事件让界面重读)。全量 npm test:72 tests / 71 pass / 0 fail

  • v0.5.21:文件补丁正式退休:默认不再改官方文件,并自动把改过的还原回去——起因是国豪发现同一件事桌面端对、网页端不对,并直接怀疑到"之前魔改过"。查证结果:他猜对了。 两端加载的官方 @deepseek-ai/dsh-client-ui-skill 根本不是同一份:桌面端从 app.asar 取(打包只读,永远是原版),网页端从 ~/.dsh/profiles/node_modules/ 取共享那份 —— 而那份被本插件的老文件补丁改过(实测:__dshSkillPickerFuzzy×2、order: -1,旁边躺着 .bak)。这个差异为什么会让排序看起来不一样:补丁把官方 candidates 改成走 window.__dshSkillPickerFuzzy,而它返回的已经是「置顶+使用热度」排好序的列表;我的包装层再做一次排序时,groupByPinned 把「🗂️ 全部」分组的输入顺序保留下来(同热度时的稳定排序)——于是网页端的「全部」是使用热度序、桌面端是字母序。根因级别的问题:文件补丁自 v0.5.15 起已是纯冗余 —— 它只做三件事(技能组置顶 order: -1、模糊+拼音匹配、onPick 记使用),而运行时接管三件全都自己做了。它却仍在改用户的官方包,还让两个 profile 对同一个文件产生分歧。修法:① 新增 revertUiSkillPatches(),启动时只针对"仍带我们标记"的文件从 .bak 还原(用 temp+rename,绝不动 pnpm 硬链接的共享 inode;幂等);② 文件补丁改为默认关闭,只有显式设 DSH_SKILL_CATALOG_FILE_PATCH=1 才走旧的自愈路径(给"触发源注册表里没有 live sources 可接管"的老内核兜底)。本机已实测还原:共享那份 lib/client.js 现在 __dshSkillPickerFuzzy=0、order: 2、hash 与 .bak 完全一致。全量 npm test:65 tests / 64 pass / 0 fail

  • v0.5.20:/ 菜单的空查询也走排序,「置顶」技能排到最前面(与 ⚡ 面板一致)——国豪指出:按 ⚡ 按钮时列表是「📌 置顶(3)」在前、再「📁 全部(57)」,但直接打 / 出来的却是纯字母序、置顶不在前面,两边排序不一样。根因:包装层对空查询做了短路 —— if (query === '') return cached / if (query === '' || ...) return everything,即"空查询把官方列表原封不动返回"。而 ⚡ 面板走的是 rankPickerItems(items, ''),它会先 groupByPinned(...) 把置顶提到最前。于是同一个数据源、两套排序。 修法:去掉空查询短路,空查询也交给 rank() —— rankPickerItems(items, '') 本就设计为"空查询 = 全部条目、按面板顺序(置顶优先)",所以两边从此同源同序。顺带发现一条假通过的测试:旧的 an empty query returns the official result untouched 里 rank 是故意抛错的,而 rank 一抛错就被包装层的 try/catch 兜住、仍然返回官方列表 —— 也就是说那条测试测的是兜底路径,不是它声称的语义,所以它在新行为下照样"通过"。已改写为正面断言:空查询必须调用 rank,且置顶技能排第一。全量 npm test:65 tests / 64 pass / 0 fail

  • v0.5.19:修掉 v0.5.17 里"预热走错了路"的 bug(0.5.18 因 npm 发布延迟被误判为"卡住",实际已发布;两者代码逐字节相同)—— 上一版已经把胜负手找对了(技能组要 0 个 await 才抢得到高亮),但它预热时调的是官方 source.warm():warm() 只预热官方模块自己的缓存,插件自己的 itemCache 依然是空的 → 菜单打开那次仍然未命中、仍然 await 官方、仍然比命令组多一跳、仍然输。这一条是从运行诊断里确认的:{"mode":"runtime","installed":true,"took":"candidates+order+onPick"} 与 {"servedFromCache":false,"sessionId":"session-de1f2494-…","primed":true,"count":71} —— 即"接管装上了、缓存也被填过,但菜单那次仍是 servedFromCache:false"。修法:预热改为走插件自己的包装路径(一次空查询 candidates),一次调用同时填满两层(包装层写快照 + 官方层自然预热)。另:缓存热重载后不再被清空,所以重载后的第一次打开也是命中。桌面端已实测确认修好。回归测试:新增一条直击要害的用例 —— 官方取数故意慢 60ms,预热后菜单调用必须在 30ms 内返回;另把"预热走官方 warm()"这条反例锁死(断言不会只调 warm())。全量 npm test:65 tests / 64 pass / 0 fail

  • v0.5.18(从未发布到 npm):修复内容与 0.5.19 完全相同,但版本号因 npm 暂存槽位冲突(E409 Cannot publish over previously staged version)无法发布,改用 0.5.19。教训:npm 上"已 staging"的版本号会被占住且 npm stage list 看不到,直接换号最快。

  • v0.5.17:修复「打 / 时菜单开到下面去、高亮不在第一条技能」——症状是输入一个 /,菜单滚到底部、高亮停在命令组(添加 / 文件 或 指令 / compact)上,而不是停在最上面第一条技能。根因:官方斜杠菜单的高亮是一条「谁先就绪谁拿高亮、拿到就粘住」的规则 —— highlight = validHighlight(旧高亮, groups) ?? firstHighlight(groups),firstHighlight 取的是第一个「已就绪」分组的第一条;随后 scrollIntoView({ block: "nearest" }) 把高亮滚进视野。本插件为了让技能组排在最上面把它的 order 从 2 改成 -1,于是命令组一旦先就绪,高亮就落在它身上、而它现在被压在技能组下面 → 菜单往下滚去够高亮。关键:这是 microtask 跳数的比赛,不是网络快慢 —— 「已就绪的 promise」await 一次就是一个 microtask;实测三种写法的胜负(技能组在 roster 里排第一,命令源至少一层 await):| 写法 | 先就绪 | |---|---| | 包装层 await 官方 candidates(2 跳) | 命令组 ❌ | | 不 await,但返回官方那个 promise(adoption 仍要多跳) | 命令组 ❌ | | 命中自己的缓存,0 个 await 直接返回 | 技能组 ✅ | 所以光"提前预热"没用(0.5.16 就是这么改的、也是这么失败的:即使目录已热,我们仍比命令组多一跳)。修法:插件自己按 Session 缓存一份完整展示列表(itemCache),candidates 命中缓存时不 await 任何东西直接返回 —— 它返回的 promise 在调用点就已 resolve,.then 的 microtask 排在命令组之前,从构造上稳赢;未命中才走官方取数并写