输入框按键管家 · dsh-composer-butler
一句话:给你的 DSH 输入框工具栏装一个「总开关面板」——把那些你可能一年用一次、却天天占着地方的按钮收起来,想用的时候一条命令全放回来。
本项目遵守「任何敏感信息不得上传到仓库」的最高红线:仓库里没有你的隐藏清单、没有扫描快照、没有任何密钥。 运行期数据(
state.json/scan.json/rules.json)全部在.gitignore里,永远不会被提交。
目录
- 一、这是什么东西
- 二、零基础安装
- 三、界面完全图解
- 四、五个手把手场景
- 五、配置文件与 HTTP API
- 六、工作原理(想改代码的人看)
- 七、排错手册
- 八、FAQ(18 问)
- 九、已知限制与安全说明
- 十、目录结构、测试与开发
- 十一、版本更新简介
- 十二、贡献与许可
- 附录 A:与同类方案对比
- 附录 B:一分钟速查卡
一、这是什么东西
1.1 用一句人话说
DSH 的输入框上方、下方、两侧,会挂着很多插件塞进来的小按钮:技能、PUA、Computer Use、语音输入、文件浏览……插件一多,输入框就被挤成一条缝。
这个插件给你一个「⋯」按钮。点开它,是一个清单,列出输入框工具栏里现在有哪些控件;不想要的,勾上就立刻从界面上消失;想要的,取消勾选就回来。它会记住你的选择,下次打开还在。
v0.3.0 起还多了一件事:这个「⋯」本身出现在哪,也由你定。工具栏那一行被挤掉/不渲染时,把它挪到侧栏底部、会话标题右侧,或者干脆变成一个右下角悬浮球——详见 3.6。
1.2 一个类比
把它想成工具箱的抽屉:
- 你的工具箱(DSH 输入框)里有 20 件工具,但常用的只有 3 件。
- 官方没给你抽屉,所有工具都摊在桌面上。
- 这个插件做了一件事:给每件工具贴标签,然后告诉你「哪些可以收起来」,收起来只是看不见,工具还在,随时能拿出来。
再换个说法:它像 Windows 的「显示/隐藏桌面图标」。图标藏了,程序还在,双击桌面空白处右键就能调回来——不是卸载。
1.3 六个真实场景
场景 A:输入框被挤爆了
你装了 8 个插件,输入框右侧排了一长串图标,每点一次都怕点错。打开面板,把「PUA」「Computer Use」「语音输入」勾上(隐藏),输入框瞬间清爽,只剩你每天用的「发送」和「文件」。
场景 B:某个按钮在开会演示时很碍眼
你要给同事演示 DSH,不想让他看到一堆实验性按钮。演示前勾上(隐藏),演示后取消勾选(恢复)——不用卸载重装,不用改配置。
场景 C:你有两个功能重复的插件
一个插件提供「技能」按钮,另一个也提供,两个并排出现,点哪个都行但看着乱。面板里会给它们都打上「重复」标记,你留一个、把另一个勾上(隐藏)。
场景 D:孩子/同事借你电脑
你的插件配置里有些私人用途的入口(比如某些自动化脚本)。临时勾上(隐藏),交还电脑,回来再取消勾选。
场景 E:你想知道某个按钮到底是谁加的
面板每一行右侧写着它来自哪个 slot(left / right / activity / dock 等)。你看到 activity 下有个不认识的控件,就知道它不是输入框右侧的常驻按钮,而是活动区的东西——这一类信息对排查「界面上这个鬼东西是谁」非常有用。
场景 F:工具栏那一整行都不见了(v0.3.0)
窄窗口、控件挤爆、或者还没进会话时,官方可能把 conversation.composer.dock 整行不渲染——这时候不是某个按钮被隐藏了,而是「放按钮的地方」没了,面板里怎么勾都救不回来。打开面板顶部的「按钮显示位置」,勾上「侧栏最底部」或「悬浮球」,⋯ 立刻出现在一个永远都在的地方。等工具栏恢复了,把旧位置勾回去、新位置取消即可。
1.4 它不做什么
明确划清边界,避免误会:
| 你可能以为 | 实际上 |
|---|---|
| 它会卸载插件 | ❌ 不。只是给它套一个 display:none。被隐藏的插件仍在后台运行,仍在消耗资源、仍在响应快捷键。 |
| 它会关掉插件功能 | ❌ 不。要真正停用,去 DSH 侧栏「插件」页关开关。 |
| 它能隐藏任意界面元素 | ❌ 不。它只处理 conversation.input.* 这一类 slot 下的直接子元素。侧栏、设置页、消息气泡都不在范围内。 |
| 它能改按钮顺序 | ❌ 不。顺序由各插件的 order 决定,本插件不重排。 |
| 它能同步到别的电脑 | ❌ 不。状态存在这台机器 + 这个浏览器里。换机器要重新勾。 |
| 它需要联网 | ❌ 不。全部本地。 |
1.5 名词表(看不懂先查这里)
| 词 | 意思 |
|---|---|
| composer | DSH 底部那个输入区(含输入框和它周围的按钮)。本项目名里的 composer 就是它。 |
| slot | DSH 客户端预留给插件挂 UI 的「插槽」。名字形如 conversation.input.right。插件把自己的按钮塞进指定插槽。 |
| 锚点(anchor) | 一个 slot 在 DOM 里对应的容器元素,带 data-slot="…" 属性,且样式为 display:contents(本身不占位,子元素直接参与布局)。 |
| 控件(control) | 本文指锚点的直接子元素——也就是各个插件挂上去的按钮本体。 |
| 半身 | DSH 插件分两半:host 半身跑在 Node 进程里(能读文件、能注册路由和工具);client 半身跑在浏览器里(能操作 DOM、能渲染 UI)。本项目两半都有。 |
| bundle | DSH 里能被装配的插件包形态。包里通过 dsh.bundle.patch 声明「怎么挂进宿主」。 |
| profile | DSH 的配置档案(本机默认是 desktop 或 web)。插件装在 <DSH配置目录>/profiles/<profile>/ 下。 |
| inject 声明 | 插件 export const inject = [...],声明「我需要哪些宿主服务」。声明漏了,注册时会崩。 |
二、零基础安装
2.1 装之前先确认三件事
① Node.js 版本
本项目 engines 要求:
node ^22.19.0 || >=24.0.0
在终端里查:
node -v
低于 22.19 就先升级。为什么要求这么高:DSH 客户端运行时与 cordis 依赖都基于这个区间,低版本可能在加载 ESM 与 import.meta 时直接报错。
② DSH 能正常启动
装插件的前提是宿主本身能跑。先确认你能打开 DSH 的 Web GUI(本机默认 http://127.0.0.1:9603),能在里面正常聊天。
③ 知道你的 profile 目录在哪
插件要装进某个 profile。常见位置:
C:\Users\<你>\.dsh\profiles\desktop\ ← 桌面版
C:\Users\<你>\.dsh\profiles\web\ ← Web 版
不确定就两个都看一眼,哪个里面有你正在用的 package.json(里面有 bundles 字段)就是它。
⚠️ 装之前先备份
package.json与pnpm-lock.yaml。改坏了能一键还原。 备份命令(PowerShell,在 profile 目录里执行):Copy-Item package.json package.json.bak-before-butler Copy-Item pnpm-lock.yaml pnpm-lock.yaml.bak-before-butler
2.2 方式一:从 zip / 源码装进 profile(通用)
第 1 步:拿到包
两个来源,任选:
- 从 Release 下载:打开 https://github.com/duma520/dsh-composer-butler/releases,下载
dsh-composer-butler-v0.1.0.zip,解压。 - 从源码 clone:
git clone https://github.com/duma520/dsh-composer-butler.git
解压/clone 后,你会看到一个目录,里面有 package.json、lib\index.js、lib\client.js、cordis.patch.yml。
第 2 步:放进 profile 的 node_modules
假设 profile 是 desktop:
mkdir "C:\Users\%USERNAME%\.dsh\profiles\desktop\node_modules\@lanbaolu"
xcopy /E /I /Y "解压出来的目录" "C:\Users\%USERNAME%\.dsh\profiles\desktop\node_modules\@lanbaolu\dsh-composer-butler"
包名是
@lanbaolu/dsh-composer-butler,所以目录必须是node_modules\@lanbaolu\dsh-composer-butler。层级少一层都加载不到。
第 3 步:在 profile 的 package.json 里登记两处
打开 C:\Users\%USERNAME%\.dsh\profiles\desktop\package.json,找到两个地方:
{
"dependencies": {
// ……原有内容……
"@lanbaolu/dsh-composer-butler": "0.1.0" // ← 加上这一行
},
"bundles": [
// ……原有内容……
"@lanbaolu/dsh-composer-butler" // ← 再加这一行
]
}
两处都必须加:
dependencies让包管理知道它存在(避免pnpm install把它清掉);bundles是真正让它被装配的清单。只加dependencies不会生效。
第 4 步:重启 DSH
完全退出 DSH(不是关窗口,是退出进程),再启动。
第 5 步:刷新页面
DSH 起来后,浏览器里按 Ctrl+F5 强制刷新一次。原因是注入/装配只更新服务端的模块表,不会推送给已经打开的页面。
刷新后,输入框右侧应该出现「⋯」。
2.3 方式二:本机有注入器插件时,一条命令
如果你的 DSH 装了超级注入器(dsh-super-injector,提供 dev_* 工具),可以用它的运行时注入通道,免重启:
dev_inject_plugin <源码目录绝对路径>
例如(把路径换成你自己的):
dev_inject_plugin D:\my-plugins\dsh-composer-butler
它会做三件事:建 junction → loader.create → 立即生效。
然后仍需刷新浏览器页面(同上,服务端模块表更新不推给已打开页面)。
验证注入结果:
dev_plugin_status
看到 dsh-composer-butler 的 fiber 是 active 就对了。
卸载:
dev_uninject_plugin dsh-composer-butler
⚠️ 已知缺陷:某些版本的注入器卸载时不删 junction(报
Path is a directory)。手工清理时必须用rmdir删链接本身,绝不能递归删除,否则会连带删掉你的源码目录。
2.4 方式三:开发调试(改完即生效)
你就是要改代码,那就别走上面两条:
- 把源码目录 junction 到 profile 的
node_modules\@lanbaolu\dsh-composer-butler; - profile
package.json的bundles加上包名; - 改完
lib\client.js或lib\index.js后:- 有注入器:
dev_reload_package dsh-composer-butler - 没注入器:重启 DSH
- 有注入器:
- 浏览器刷新页面看效果。
改完一定要做的三件事:
node --check lib\index.js
node --check lib\client.js
npx --yes dsh-plugin-standard@2 . --json
第三条是 DSH 插件规范校验,输出里 failCount 必须是 0。
2.5 三种验证方法
验证 A:看界面
输入框右侧出现「⋯」按钮 → client 半身起来了。 点开能列出控件 → host 半身(API)也通了。
验证 B:问工具
在会话里让 AI 调:
composer_butler_status
返回 JSON,里面有 dataDir(数据目录)、scan(最近一次扫描)、state(当前隐藏清单)。有这三样说明两半都正常。
验证 C:看数据文件
打开数据目录(默认就是包根目录),应该逐渐出现:
state.json—— 你勾选后立即生成scan.json—— 页面加载 2 秒后生成
如果 scan.json 一直不出现,说明浏览器那半没跑起来(见第七部分)。
2.6 卸载(含数据清理)
第 1 步:让它不再装配
从 profile package.json 的 bundles 数组里删掉 "@lanbaolu/dsh-composer-butler"(dependencies 里那行一起删更干净)。
第 2 步:删包
rmdir /S /Q "C:\Users\%USERNAME%\.dsh\profiles\desktop\node_modules\@lanbaolu\dsh-composer-butler"
如果这个目录是 junction(链接),不要用
rmdir /S,改用rmdir只删链接本身。
第 3 步:重启 DSH
第 4 步(可选):清数据
del 你原来的数据目录\state.json
del 你原来的数据目录\scan.json
del 你原来的数据目录\rules.json
浏览器里还有一份缓存,F12 → Application → Local Storage → 删掉键 composer-butler.hidden.v1。
2.7 装崩了怎么救
症状:DSH 起不来 / 起是起来了但一直报错。
急救三步:
- 还原 profile 配置
Copy-Item package.json.bak-before-butler package.json -Force Copy-Item pnpm-lock.yaml.bak-before-butler pnpm-lock.yaml -Force - 移除包目录(见 2.6 第 2 步)
- 重启 DSH
如果还不行,说明问题不在本插件——去看 DSH 自己的日志。
三、界面完全图解
3.1 入口有三个,随便哪个都能进
v0.2.1 起面板有三个固定入口(v0.3.0 又能额外把 ⋯ 挂到另外 8 个位置,见 3.6),共用同一份状态(在哪一处勾选,另外几处立刻同步):
| # | 位置 | 官方槽位 | 什么时候看得见 | 排序值 |
|---|---|---|---|---|
| ① | 输入框工具栏的 ⋯ 按钮 |
conversation.composer.dock |
工具栏正常显示时(位置可改,见 3.6) | order: 900(靠右) |
| ② | 设置 → 输入框按钮管家 | settings.section |
永远,不受窗口宽度影响 | order: 36 |
| ③ | 左侧栏「更多」菜单里的一行 | sidebar.panellist.actions |
点开侧栏「更多」时 | order: -100(排菜单最前) |
为什么要有三个:工具栏那一行在窄窗口、控件多、或还没进会话(首页态)时会被官方挤掉或不渲染。 这时三个入口里至少有一个能用——尤其 ② 和 ③ 完全不依赖工具栏是否可见。
③ 的 order 为什么是负数:sidebar.panellist.actions 是 list 槽位,官方按
(priority, order) 升序渲染。官方自己的「浏览更多插件」「创建你的插件」没设 order(默认 0),
所以负数能让我们的行排到所有官方条目之前,不用往下滚就看得见(v0.2.0 用了 order: 40,
排在菜单最底部,屏幕矮时得滚动才看得到,这是本次修正的原因之一)。
「更多」菜单里的样子:
┌──────────────────────────────┐
│ 更多 │ ← 菜单锚点(按钮本身)
├──────────────────────────────┤
│ ⋯ 输入框按钮管家 工具栏显隐 │ ← 本插件的行(order: -100,排最前)
│ 浏览更多插件 │
│ 创建你的插件 │
│ …官方面板条目… │
└──────────────────────────────┘
- 槽位:
sidebar.panellist.actions(不在 super-injector 的白名单里,但同文件有conversation.composer.dock/settings.section两个白名单注册,校验是「命中任意一个即通过」, 所以重启不会被判坏骨架跳过恢复) - 点击行为:先
onDismiss()收起菜单,再打开面板(面板用视口中央的虚拟锚点定位, 因为菜单行此时已被卸载,不能拿它当 anchor) - 排序值
order: -100
工具栏 ⋯ 的外观:
- 槽位:
conversation.composer.dock - 控件 id:
composer-butler - 排序值
order: 900(数值越大越靠右/越靠后) - 提示文本(鼠标悬停):
输入框按钮管家:自定义显示哪些控件 - 无障碍名(读屏器):
输入框按钮管家
再点一次「⋯」会关闭面板——它是开关,不是「打开」。
3.1.1 插件详情页也有配置区
「插件」页点开本插件的详情,会多出一块配置区(plugins.bundle.config,keyed 槽位,
key 就是包名 @lanbaolu/dsh-composer-butler)。这里的 page 视图渲染与设置页完全相同的面板,
summary 视图只给「工具栏控件 N 个,已隐藏 M 个」+ 一个「全部恢复」按钮。
v0.2.0 这里引用了一个没定义的组件
ButlerPluginConfig,打开插件详情页会抛ReferenceError,被官方SlotErrorBoundary吞掉,表现为配置区一片空白且不报错。 v0.2.1 已补齐定义——如果以后又看到配置区空白,先查这一处。
3.2 面板逐行说明
面板顶部还有一块「按钮显示位置」区(v0.3.0 新增),它管的是本插件自己的
⋯出现在哪, 和本节讲的控件清单是两回事(一个勾上=出现,一个勾上=隐藏)——详见 3.6。
点开后面板长这样:
┌────────────────────────────────────────────┐
│ 输入框按钮管家 [×] │ ← 标题行
├────────────────────────────────────────────┤
│ ☑ ⚡ 技能 left │ ← 每行:勾选框 + 名称 + 槽位
│ ☐ PUA left │
│ ☑ Computer Use left │
│ ☑ 选择一个技能 left │
│ ☐ 文件 right │
│ ☐ 发送 right │
│ ☐ 4 条排队消息 dock │
├────────────────────────────────────────────┤
│ [重新扫描] [全部恢复] [关闭] │
│ 只隐藏界面,不卸载插件——被隐藏的插件仍在 │
│ 后台运行。要彻底停用请用侧栏「插件」页的开关。│
└────────────────────────────────────────────┘
勾选框的含义:勾上 = 隐藏(从界面上拿掉);取消勾选 = 显示(放回界面上)。
方向容易记反,务必留意:内部存的是一个「隐藏清单」(
hidden数组), 所以勾选 = 把这一项加进隐藏清单。这和「勾选=启用」的直觉相反。 代码里就一行:box.checked = hidden.has(control.key)——勾上的项一定在隐藏清单里。(README 在 v0.2.1 之前把这段写反了,说是「勾上=显示」。以本段为准; 如果你用的是旧版页面,看到的行为也一直是「勾上=隐藏」。)
每行右侧的灰字是槽位名去掉前缀 conversation.input. 之后的部分:
| 右侧显示 | 实际槽位 | 位置 |
|---|---|---|
left |
conversation.input.left |
输入框左侧 |
right |
conversation.input.right |
输入框右侧 |
activity |
conversation.input.activity |
活动/状态区 |
dock |
conversation.input.dock |
停靠条(如排队消息) |
overlay |
conversation.input.overlay |
浮层 |
空面板显示:
未在输入框工具栏发现控件。请先打开一个会话。
原因见排错第 2 条。
3.3 面板底部的四个动作
| 按钮 | 作用 | 什么时候用 |
|---|---|---|
| 重新扫描 | 立刻重扫 DOM,刷新列表并上报 scan.json |
你刚装了新插件,或某个控件没出现在列表里 |
| 全部恢复 | 清空隐藏清单,所有控件全部显示 | 搞乱了想一键回到出厂状态 |
| 关闭 | 关面板,不改状态 | 只是不想看了 |
点面板外部 / Esc |
同「关闭」 | 快速关闭 |
3.4 勾上之后发生什么
发生在 0.1 秒内,三步:
- 写 localStorage(键
composer-butler.hidden.v1)—— 本浏览器立即记住; - 上报服务端(
POST /state)—— 落盘成state.json,换浏览器也能补回; - 改 DOM —— 给对应控件加属性
data-composer-butler-hidden,再用一段注入的 CSS 把它display:none !important。
关键点:它没有删 DOM、没有停插件、没有动 React 树。所以:
- 被隐藏的控件还在运行(快捷键、后台任务、定时器都照常);
- 撤销是零代价的(去掉一个属性而已);
- 不会触发插件重渲染导致的副作用。
3.5 「重复」徽章是什么
当同一个槽位里出现两个及以上名称相同的控件,面板会给它们加一个 重复 徽章,鼠标悬停提示:
同一工具栏里出现了多个同名控件(多半来自两个功能相近的插件)
这不是错误,是提示。你该做的是:留一个、把另一个勾上(隐藏),否则两个按钮占两份地方、可能触发两次相同动作。
3.6 按钮显示位置(v0.3.0 新增)
面板顶部多了一块「按钮显示位置(可多选,勾上即出现)」。
它解决的是另一个问题:工具栏那一行本身有时候不显示——窄窗口、控件太多、还在首页没进会话,官方都可能把 conversation.composer.dock 整行挤掉或不渲染。这时候你不是「按钮被隐藏了」,而是「按钮所在的整块地方没了」,面板里怎么勾都救不回来。
所以 v0.3.0 把 DSH 里所有能安全挂按钮的位置都列出来,你想让它出现在哪就勾哪,可以同时勾好几个。
9 个可选位置
| # | 界面上的位置 | 槽位 id | 什么时候真的看得见 | 形态 |
|---|---|---|---|---|
| 1 | 输入框底部工具行(和 tok/s、缓存命中同排) | conversation.composer.dock |
已打开会话,且工具栏正常渲染 | 小按钮 ⋯ |
| 2 | 输入框左侧(⚡ 技能、Computer Use 旁边) | conversation.input.left |
已打开会话 | 小按钮 ⋯ |
| 3 | 输入框右侧(发送键左边) | conversation.input.right |
已打开会话;活动区展开时整块被官方隐藏 | 小按钮 ⋯ |
| 4 | 输入卡片左上角的浮层 | conversation.input.overlay |
已打开会话 | 小按钮 ⋯ |
| 5 | 输入框上方(排队消息 / 附件条那条) | conversation.input.dock |
有排队消息或附件时才出现 | 小按钮 ⋯ |
| 6 | 会话标题右侧 | conversation.session.header.actions |
有会话就显示,不需要输入框 | 小按钮 ⋯ |
| 7 | 会话头部工具区 | conversation.session.header.utilities |
有会话就显示 | 小按钮 ⋯ |
| 8 | 左侧栏最底部 | sidebar.footer.action |
常驻,不依赖会话 | 小按钮 ⋯ |
| 9 | 屏幕右下角 | shell.overlay |
任何时刻都在(浮在所有页面之上) | 圆形悬浮球 |
出厂默认只勾第 1 个(conversation.composer.dock),和 v0.2.x 的行为完全一致——升级后不会突然多出好几个 ⋯。
怎么用
- 点开面板(任何入口都行);
- 在「按钮显示位置」区勾上你想让它出现的格子,可以一次勾多个;
- 立刻生效,不需要「保存」——勾的同时就写进 localStorage 与
state.json。
┌────────────────────────────────────────────┐
│ 输入框按钮管家 [×] │
├────────────────────────────────────────────┤
│ 按钮显示位置(可多选,勾上即出现) │ ← v0.3.0 新增
│ ☑ 输入框底部工具行 需打开会话 │
│ ☐ 输入框左侧 需会话 │
│ ☐ 输入框右侧 需会话 │
│ ☐ 输入卡片左上浮层 需打开会话 │
│ ☐ 输入框上方排队区 有排队才出现 │
│ ☐ 会话标题右侧 有会话即显示 │
│ ☐ 会话头部工具区 有会话即显示 │
│ ☐ 侧栏最底部 常驻 │
│ ☐ 悬浮球(右下角) 浮在所有页面 │
├────────────────────────────────────────────┤
│ ☑ ⚡ 技能 left │ ← 下面是原有的控件清单
│ ☐ PUA left │
│ … │
├────────────────────────────────────────────┤
│ [重新扫描] [全部恢复] [关闭] │
└────────────────────────────────────────────┘
和下面的控件清单是两回事,别混:
| 位置选择区 | 控件清单区 | |
|---|---|---|
| 勾上意味着 | 出现一个「⋯」按钮 | 隐藏一个官方控件 |
| 作用对象 | 本插件自己的按钮 | 别人插件的按钮 |
| 存哪里 | anchors 数组 |
hidden 数组 |
两个区方向相反(一个勾上=出现,一个勾上=隐藏),是历史原因:hidden 语义从 v0.1.0 就定型了,改方向会让老用户的配置反过来。位置区是新功能,按直觉设计成「勾上=出现」,界面上写清楚了。
为什么不是每个位置都能挂
DSH 官方一共声明了 80 个插槽,但只有 list 型(可注册多个)能安全加东西。single 型已经被官方占用(比如 conversation.input.permission 就是「完全权限」那个下拉、conversation.input.model 是模型选择器),再往上注册会直接抛错:
slot "conversation.input.permission" is single
所以候选位置只从 list 型里筛,再排除掉语义不对的(conversation.chat.turnTail 是每条消息尾部、deliverables.file.actions 是交付文件操作、contextMenu.content 是右键菜单……挂个「⋯」进去只会莫名其妙)。
勾了但没看见?先看「什么时候真的看得见」那列
位置选择区每一行右侧的灰字就是这个提示。最常见的三种:
- 勾了第 5 项(排队区)却什么都没有 → 你没在排队、也没附件,官方根本没渲染那块容器;
- 勾了第 3 项(输入框右侧)却时有时无 → 活动/状态区展开时,官方把整块
.standardControls隐藏了; - 勾了第 9 项(悬浮球)却在截图/录屏里看不到 →
shell.overlay是全局浮层,正常情况下一定在;若被别的全屏遮罩盖住,调高 z-index 或换个位置。
这不是 bug,是位置本身的条件。真想要「永远都在」,选第 8 项(侧栏底部)或第 9 项(悬浮球)。
两个固定入口不会被关掉
设置页(settings.section)和侧栏「更多」菜单(sidebar.panellist.actions)这两个入口不参与选择、固定保留。原因很直接:如果连入口本身都能被关掉,你就再也打不开面板了(自锁)。位置选择区只控制「输入框附近那几个 ⋯」,关不出事。
四、五个手把手场景
场景 1:把用不上的按钮全收起来
- 点输入框右侧
⋯。 - 面板列出所有控件,逐个看右侧槽位名。
- 对不想要的,勾上——它立刻从输入框消失。
- 关面板。
- 刷新浏览器,确认还是隐藏的。
结果:state.json 里多了一条 hidden 记录,下次打开自动应用。
场景 2:某个按钮突然不见了,怎么找回来
- 点
⋯(如果连⋯都不见了,说明整个插件没加载,看第七部分)。 - 面板里找到那一行——它现在是已勾选状态(勾上 = 隐藏)。
- 取消勾选,立刻回来。
- 如果不确定是哪一个,点「全部恢复」,全部出现后再逐个收。
场景 3:换浏览器 / 换电脑,隐藏清单还在吗
- 同机换浏览器:会。打开新浏览器首次加载时,client 发现本地
localStorage为空,会向服务端GET /state拉一次清单,自动套用。 - 换电脑:不会。
state.json在旧机器上。想带走就把这个文件复制到新机器的数据目录(路径见 5.5)。
场景 4:两个插件功能重复,留哪个
- 面板里找带
重复徽章的行。 - 两行的名称相同、槽位相同,区别可能在图标(
aria-label/title不同则不会判重)。 - 决定留哪个(通常留你更常点的),把另一个勾上(隐藏)。
- 如果两个都想留,那就都留——徽章只是提醒,不阻止你。
场景 5:只想临时藏一下,不想它记住
当前版本没有「临时隐藏」模式——所有勾选都会立即持久化。
变通做法:临时勾上(隐藏)→ 用完点「全部恢复」。多两步,但效果一样。
如果你确实需要「临时模式」,可以在 Issue 里提,这是合理的功能请求。
五、配置文件与 HTTP API
5.1 三个数据文件
全部在数据目录(默认 = 包根目录,可用 dataDir 配置改):
| 文件 | 谁写 | 内容 | 能删吗 |
|---|---|---|---|
state.json |
client → POST /state |
你的隐藏清单(hidden)+ 按钮位置清单(anchors) |
能删(等于全部恢复显示、位置回默认) |
scan.json |
client → POST /scan |
界面侦察快照(诊断用) | 能删(会自动重建) |
rules.json |
外部 POST /rules |
预留的特征表 | 能删(当前 client 不读它) |
⚠️ 这三个文件都不会上传到 GitHub。它们在
.gitignore里,属于「使用者本机状态」。
5.2 state.json 逐字段
{
"hidden": [
"conversation.input.left::text::⚡ 技能",
"conversation.input.right::svg::M1121h-1l1-7H7.5c-.580-."
],
"anchors": [
"conversation.composer.dock"
],
"updatedAt": "2026-11-10T10:36:56.798Z"
}
| 字段 | 类型 | 说明 | 写错会怎样 |
|---|---|---|---|
hidden |
字符串数组 | 要隐藏的控件身份 key | 不是数组 → client 解析失败,按空清单处理(等于全部显示) |
anchors |
字符串数组 | 本插件 ⋯ 按钮出现在哪些位置(v0.3.0 新增) |
不是数组 → 按默认值 ["conversation.composer.dock"];含未知 id → 逐项过滤掉,不认识的忽略 |
updatedAt |
ISO 8601 字符串 | 最后更新时间 | 只用于展示/排查,写错不崩 |
两个数组各管一件事,互不影响:
hidden管别人的控件(隐藏掉);anchors管我们自己的按钮(出现在哪)。
所以「全部恢复」只清 hidden,不会把你的位置选择也清掉;想让按钮回到只有一个位置,去面板的「按钮显示位置」区自己取消勾选。
⚠️
state.json由两个写入方(hidden与anchors的变更)共用,早期版本并发写会写坏文件(末尾出现重复的"updatedAt"段,整个 JSON 解析失败)。 v0.3.0 起 host 端改为同一路径串行 + 临时文件 rename 原子替换,12 个并发 POST 压测后文件仍是完整合法 JSON。若你在旧版里见过state读出来是null,就是这个原因。
key 的构成:
<槽位名>::<指纹类型>::<指纹值>
例:conversation.input.left::text::⚡ 技能
| 指纹类型 | 什么时候用 | 例子 |
|---|---|---|
aria |
控件有 aria-label |
…::aria::输入框按钮管家 |
title |
有 title 属性 |
…::title::只控制当前会话 |
text |
有可见文本 | …::text::⚡ 技能 |
svg |
只有图标 | …::svg::M79A9.59.5001144.5 |
index |
以上都没有 | …::index::0 |
为什么用这种 key:官方渲染层不提供稳定 id,只能用「可观测特征」拼一个。aria 最稳、index 最脆(顺序一变就失效)。
5.3 scan.json 逐字段
{
"capturedAt": "2026-11-10T10:38:59.816Z",
"hidden": ["…当前的隐藏清单…"],
"slots": [
{
"slot": "conversation.input.dock",
"children": [
{
"key": "conversation.input.dock::text::4 条排队消息",
"tag": "div",
"className": "F07MqG_dock",
"ariaLabel": "",
"title": "",
"text": "4 条排队消息",
"hidden": false
}
]
}
]
}
| 字段 | 说明 |
|---|---|
capturedAt |
采集时刻 |
hidden |
采集时的隐藏清单(快照,不参与判断) |
slots[].slot |
槽位名 |
slots[].children[] |
该槽位下每个控件的信息 |
children[].key |
身份 key(与 state.json 里对应) |
children[].tag |
标签名(button / div / span…) |
children[].className |
类名(截 60 字节,可能是 CSS Module 哈希) |
children[].ariaLabel / title / text |
三个候选名称来源(text 截 30 字) |
children[].hidden |
采集时是否处于隐藏状态 |
采集时机:页面加载后 2 秒一次 + 每次打开面板一次。
这个文件是干什么的:它就是你「界面上到底有什么」的体检报告。排查「这个按钮是谁加的」「为什么某控件没被列出来」时,先看它。
5.4 rules.json
预留接口,POST /rules 可写,当前 client 不读取它。设计意图是将来支持「按规则批量隐藏」(例如「所有 aria-label 含『实验』的控件默认隐藏」)。
现在不用管它,留着就行。
5.5 数据目录怎么定
默认:包根目录(lib 的上一级),也就是 state.json 和 package.json 并排。
自定义:在 cordis.patch.yml 里给 config 加 dataDir:
- insert:
- id: composer-butler
name: '@lanbaolu/dsh-composer-butler'
config:
dataDir: 'D:\dsh-data\composer-butler'
dataDir 是绝对路径。相对路径会被 path.resolve 按进程工作目录解析——不要用相对路径,你会找不到文件在哪。
为什么不放 lib/:代码和数据混放,git clone 后容易把运行期状态一起提交(本项目 v0.1.0 开发期就踩过,已改到包根)。
5.6 HTTP API 全表
前缀:/@lanbaolu/dsh-composer-butler/api
| 方法 | 路径 | 请求体 | 响应 | 说明 |
|---|---|---|---|---|
GET |
/info |
— | {dataDir, apiBase} |
查数据目录与 API 前缀 |
GET |
/state |
— | {hidden:[…], anchors:[…], updatedAt};文件不存在返 {hidden:[], anchors:[]} |
读状态 |
POST |
/state |
{hidden:[…], anchors:[…], updatedAt} |
{ok:true} |
写状态(落 state.json,原子写) |
GET |
/scan |
— | {capturedAt, hidden, slots};文件不存在 404 + scan.json 尚不存在 |
读扫描快照 |
POST |
/scan |
同上 | {ok:true} |
上报扫描快照 |
GET |
/rules |
— | 原样返回 rules.json |
读规则表 |
POST |
/rules |
任意 JSON | {ok:true} |
写规则表 |
| 其他 | — | — | 404 + 未知路由 |
兜底 |
手工调用示例(PowerShell):
# 看数据目录
Invoke-RestMethod http://127.0.0.1:9603/@lanbaolu/dsh-composer-butler/api/info
# 看当前隐藏清单
Invoke-RestMethod http://127.0.0.1:9603/@lanbaolu/dsh-composer-butler/api/state
# 一键全部恢复(等价于点面板上的「全部恢复」,只清 hidden,不动 anchors)
Invoke-RestMethod -Method Post `
-Uri http://127.0.0.1:9603/@lanbaolu/dsh-composer-butler/api/state `
-ContentType 'application/json' `
-Body '{"hidden":[],"anchors":["conversation.composer.dock"],"updatedAt":"2026-11-10T00:00:00Z"}'
端口按你自己的 GUI 地址改(本机默认 9603;本会话实际是 14045,以地址栏为准)。
5.7 浏览器 localStorage 键
| 键 | 内容 | 清了会怎样 |
|---|---|---|
composer-butler.hidden.v1 |
隐藏清单 JSON | 下次加载会从服务端 GET /state 补回(若服务端有);服务端也没有就全显示 |
composer-butler.anchors.v1 |
按钮位置清单 JSON(v0.3.0 新增) | 下次加载会从服务端 GET /state 补回;服务端也没有就回到默认 ["conversation.composer.dock"] |
为什么同时存两边:localStorage 保证「刷新立即生效、不用等网络」;服务端保证「换浏览器能补回」。这是规则「设置实时自动保存并持久化」的落地方式。
5.8 手工编辑 state.json 的正确姿势
可以手改,但要注意:
- 先关浏览器页面(否则你改完,页面一上报就覆盖回去)。
- 保持 JSON 合法(用 VSCode 之类带校验的编辑器)。
hidden里的字符串必须与scan.json里的key逐字符相同——多一个空格就失效。anchors只接受ANCHOR_CATALOG里那 9 个 id(见 3.6),写别的一律被忽略;写成[]是合法的,表示「一个输入框附近的⋯都不要」(设置页与侧栏那两个人口仍在)。- 改完刷新页面。
最稳的做法其实是别手改:点面板。手改只在你需要批量清理时才有价值(比如把整个 hidden 清空)。
六、工作原理(想改代码的人看)
6.1 为什么必须这么做
DSH 官方渲染层没有提供「逐 entry 可见性」的属性或 API——插件只能声明「我挂在哪个 slot」,不能声明「我默认隐藏」。插件市场也没有显隐管理能力。
所以唯一可行的路径是:在 DOM 层动手。
代价是:这条路依赖官方 DOM 结构的稳定性。官方哪天改了 Slots 的实现(比如给每个 child 多包一层 div),本插件就会失效。这是这套方案的根本性限制,写在这里让你心里有数。
6.2 DOM 锚点机制
官方 SlotOutlet 渲染出来是这样:
<div data-slot="conversation.input.right" style="display:contents">
<!-- 各插件挂上来的根节点,直接是这里的子元素 -->
<button aria-label="文件">…</button>
<button aria-label="发送">…</button>
<button data-composer-butler-self="1" aria-label="输入框按钮管家">⋯</button>
</div>
三个关键事实:
- 锚点带
data-slot,所以能精确选到:[data-slot^="conversation.input."]。 - 锚点
display:contents——它自己盒子不参与布局,子元素直接按父级布局规则排。所以「隐藏子元素」在视觉上就等于「这个按钮没出现过」。 - 错误边界不额外包 DOM——官方在插件崩了时用错误边界兜底,但没有加包裹 div。所以「锚点的直接子元素」就是「插件的根节点」。
于是隐藏一个控件 = 给这个直接子元素加一个属性 + 一段全局 CSS。
[data-composer-butler-hidden]{ display:none !important; }
用 !important 是因为个别插件的根节点带内联 display,不加会被压不住。
6.3 控件身份指纹算法
难点不在「隐藏」,在**「怎么认出同一个控件」**。
官方没给 id。只能拿可观测特征拼:
identityOf(el, slot, fallbackIndex)
优先级(前面的拿到就用):
aria-label(最稳,语义化,通常不随状态变)title(次稳)- 可见文本(会被状态文案污染,所以要归一,见下)
svg的path d(图标指纹,去空白后截 24 字符)index(兜底,最脆——顺序一变就认错)
拼出来的 key 形如:
conversation.input.left::text::⚡ 技能
为什么需要 stableName() 归一:
PUA 那个按钮的 aria-label 是动态的:
- 会话开着时:
PUA(当前会话已开启) - 会话关了时:
PUA(当前会话已关闭)
如果把整串当 key,你切一个会话,key 就变了,隐藏立刻失效。
解法是取分隔符之前的前缀:
text.split(/[,,、((::—–]/)[0].trim().slice(0, 24)
于是两种状态都归一成 PUA。
分隔符集合为什么刻意不含 ASCII 连字符 -:
这是个踩过的坑。如果 - 也算分隔符,hold-to-dictate(按住说话)会被截成 hold,而这个 hold 前缀可能与另一个控件(比如某个叫 hold to edit 的)撞车,造成误隐藏——你隐藏 A,B 也没了。
所以分隔符最终定为:[,,、((::—–](中文逗号/英文逗号、顿号、中文括号/英文左括号、中文冒号/英文冒号、破折号、连接号),7 类,不含 -。
这一条有 7 条真实文案的单测守着,改之前先看测试。
6.4 防自锁
最尴尬的 bug 会是:你把这个插件自己的按钮隐藏了,然后面板再也打不开——只能手改 state.json 才能救。
所以:
- 「⋯」按钮带标记
data-composer-butler-self="1"; scanControls()里对每个候选做child.closest('[data-composer-butler-self]') !== null判断,命中就跳过。
于是它永远不会出现在自己的清单里,也就永远隐藏不了自己。
如果你在面板里看到「输入框按钮管家」这一行,说明这道防线被绕过了(极少见),去 Issue 报。
6.5 自愈:MutationObserver
DOM 不是静止的。切会话、插件热装配、组件重渲染,都可能把锚点重建,重建之后你加的属性就没了。
对策:
new MutationObserver(callback).observe(document.body, {
childList: true,
subtree: true
})
- 60ms 节流:DOM 变化很密集,不节流会把主线程拖死。节流的意思是「60ms 内的多次变化合并成一次处理」。
- 只监听
childList+subtree:节点增删才处理,不管属性变化(避免自己改属性又触发自己,形成循环)。 - 卸载时
disconnect(),不留观察者。
这就是「切会话回来,隐藏还在」的原因。
6.6 状态单例为何重要
代码里有一份全局唯一的 hiddenSet:
const hiddenSet = new Set()
function getHidden() { return hiddenSet }
为什么不能各持一份:
v0.1.0 开发期踩过——apply() 的闭包持有 Set A,面板组件持有 Set B。你在面板里勾选改了 B,apply 用的还是 A,于是「面板显示已隐藏、界面没有任何变化」。更要命的是 MutationObserver 每次回调都持旧引用,改 B 永远不生效。
改成单例后,所有路径读写同一份数据,问题消失。
这是个典型的「看起来能跑、实则状态不同步」的 bug,改这块代码时别退回多实例写法。
6.7 host / client 两个半身
| host 半身 | client 半身 | |
|---|---|---|
| 文件 | lib/index.js |
lib/client.js |
| 运行在 | Node 进程(服务端) | 浏览器 |
| 能做什么 | 注册 HTTP 路由、读写文件、注册工具 | 操作 DOM、渲染 UI、读写 localStorage |
导出 name |
'@lanbaolu/dsh-composer-butler' |
— |
声明 inject |
['webServer', 'tools'] |
['slots'] |
| 注册的 effect | 'composer-butler: api'、'composer-butler: status tool' |
一个 slot 注册 |
inject 声明漏了会怎样:
v0.1.0 开发期踩过——inject 只写了 ['webServer'],没写 'tools',于是注册工具时宿主没注入 tools 服务,直接崩,整个插件 entry 变成 [no-fiber](等于没加载)。
能注册工具却不声明 tools,是宿主设计的严格性,不是 bug。改代码时两个都要声明。
6.8 为什么零构建
lib/client.js 是手写的 bundle:
window.__ModuleLoader__.load({ id: '@lanbaolu/dsh-composer-butler/client', factory: (require) => { … } })
它只 require('react'),而 react 由 shell 提供(dsh.client.inject 里声明了 @deepseek-ai/dsh-client-runtime 与 @deepseek-ai/dsh-client-ui-slots)。
好处:
- 没有
npm install依赖树、没有 webpack/vite/tsc,git clone就能跑; - 改一行刷新页面就见效,调试路径最短;
- 包体积极小(client 21KB)。
代价:
- 不能用 JSX,只能
h()函数式写法; - 不能用 TS;
- 依赖手工维护,改错一个字符就可能整页白;
所以 node --check 是必须的(见 2.4)。
6.9 一次完整生命周期
┌─ DSH 启动 ─────────────────────────────────────────┐
│ 1. bundles 里发现 @lanbaolu/dsh-composer-butler │
│ 2. 读 cordis.patch.yml → insert entry │
│ 3. 加载 lib/index.js(host 半身) │
│ ├─ 建 /@lanbaolu/dsh-composer-butler/api 路由 │
│ └─ 注册工具 composer_butler_status │
└────────────────────────────────────────────────────┘
↓
┌─ 浏览器打开 GUI ───────────────────────────────────┐
│ 4. 加载 lib/client.js(client 半身) │
│ 5. apply(ctx): │
│ ├─ ensureStyle() 注入 CSS │
│ ├─ applyHidden() 套用本地隐藏清单 │
│ ├─ 本地无隐藏清单 → GET /state 补一次 │
│ ├─ 本地无位置清单 → GET /state 补一次(v0.3.0) │
│ ├─ MutationObserver 开始盯着 DOM │
│ └─ slots.inject 12 处: │
│ · composer.dock / settings.section / │
│ sidebar.panellist.actions / 插件配置区 │
│ · 另外 8 个候选位置(v0.3.0,默认不显示) │
│ → 每个位置都由 AnchorGate 决定是否渲染 │
│ 6. 2 秒后 → 首次 reportScan() → POST /scan │
└────────────────────────────────────────────────────┘
↓
┌─ 你点「⋯」 ────────────────────────────────────────┐
│ 7. 扫 DOM → 渲染「位置选择区 + 控件清单」 │
│ 8. 每次打开都再 reportScan() 一次 │
│ 9. 勾控件 → commitHidden(): │
│ writeLocal → POST /state → applyHidden → 重渲染 │
│ 10. 勾位置 → commitAnchors(): │
│ writeLocal → POST /state → bumpAnchors → 重渲染 │
└────────────────────────────────────────────────────┘
↓
┌─ 你切会话 / 重渲染 ────────────────────────────────┐
│ 11. MutationObserver 触发(60ms 节流)→ applyHidden │
└────────────────────────────────────────────────────┘
6.10 位置门控:AnchorGate(v0.3.0)
一个按钮要「出现在用户选的位置」,必须解决两件事:注册到哪 和 何时渲染。
注册不能动态增删。slots 的 register 是一次性的:ctx.slots.inject(name, callback) 之后,这个位置就固定有一个 entry。所以做法是全部位置都注册,但每个位置的组件外面套一层门闸:
function makeAnchorGate(anchorId, kind) {
return function AnchorGate(props) {
const anchors = useAnchors() // 订阅位置清单变化
if (!anchors.has(anchorId)) return null // 没勾 → 渲染出 null,DOM 里什么都没有
return kind === 'float' ? h(FloatingButlerButton) : h(ButlerButton, props)
}
}
return null 是 React 的合法返回值,官方 slot 容器里不会留下空壳节点,视觉上等于「这个位置没有我们」。
「重渲染」靠订阅。anchorSet 是模块级单例,bumpAnchors() 通知所有 useAnchors() 的订阅者。为什么用递增计数而不是布尔或数组引用:
setTick(n => n + 1) // 而不是 setAnchors(new Set(anchorSet))
因为 anchorSet 是原地修改的同一个 Set 引用,setState 拿到相同引用会直接 bail,组件不重渲染——用户勾了位置却看不到按钮动。(这是 v0.3.0 开发期特意绕开的坑。)
九个位置用同一个 order: 900。它们分属不同槽位,排序只在自己槽位内比较,所以同一个数值够用;900 保证排在各个容器里靠后的位置(同槽位里若有官方控件,我们靠右)。
为什么悬浮球单独一种形态:shell.overlay 是一个覆盖全屏的浮层容器,往里塞一个普通按钮会被当作 flex 子项排在某处,只有 position: fixed 才能真正「浮」在右下角。所以 .cb-float 用 fixed 定位 + z-index: 9998(低于面板的 99999,面板打开时压在悬浮球之上)。
七、排错手册
按「现象 → 原因 → 处置」写。13 条,覆盖绝大多数情况。
1. 输入框右侧根本没有「⋯」
原因(按概率排序):
- client 半身没加载(最常见:注入后没刷新页面)
bundles里没加包名(只加了dependencies)- 包目录层级不对(不是
node_modules\@lanbaolu\dsh-composer-butler) - slot 名不匹配(你的 DSH 版本里
conversation.composer.dock不存在) - 工具栏那一行被挤掉了(窄窗口 / 控件多)—— 改用「设置 → 输入框按钮管家」或侧栏「更多」菜单行
处置:
Ctrl+F5强刷页面。- 查 profile
package.json的bundles是否含包名。 - F12 → Console 看有没有报错(
__ModuleLoader__相关)。 - 重启 DSH。
2. 有「⋯」,但面板是空的
面板原文:未在输入框工具栏发现控件。请先打开一个会话。
原因:
- 你还没打开任何会话(输入框没渲染,锚点不存在)
- 或者当前页面的 DOM 结构里没有
data-slot^="conversation.input."
处置:先打开/新建一个会话,再点「⋯」。若仍为空,点「重新扫描」,然后看 scan.json 里 slots 是否为空数组。
3. 勾上了,但按钮还在
原因:
- 控件的 key 在你点完之后变了(DOM 重建 + 以
index为指纹) - 另一个插件每次渲染都重建根节点,把属性冲掉了(观察者应该在 60ms 内补,但极端情况会闪)
- 你勾上的是另一个同名控件
处置:
- 点「重新扫描」,看该控件现在的 key 是否还和
state.json里一致。 - 若不一致,说明指纹不稳定(
index类),改为隐藏旁边更稳定的那个,或接受它。 - 看
aria-label是否含动态状态文案——这类应当被stableName()归一,若没归一是 bug。
4. 隐藏了,刷新后又出现
原因:
POST /state失败(服务端没落盘),且 localStorage 被清- 手工编辑过
state.json且格式非法
处置:
- 调
GET /state看服务端有没有记录。 - 没有 → 看 host 半身是否正常(
composer_butler_status能不能返回)。 - 有记录但页面没应用 → 强刷,看 Console。
5. 隐藏清单里出现 ::index::0 这种
说明:这个控件的三个名称来源(aria-label / title / 文本 / svg)全都没有,只能退化成「按位置」。它的隐藏最不稳定——别的控件一增删,位置就变。
处置:能接受就接受;想让作者改进,去 Issue 里贴 scan.json 对应片段。
6. 面板里看到「输入框按钮管家」自己
这是防自锁失效,属 bug。理论上不可能(closest('[data-composer-butler-self]') 拦着)。
处置:别勾它(勾了面板就再也打不开)。若已勾,手工把 state.json 里 hidden 清空,刷新。
7. 两个按钮并排,都带「重复」
见 3.5。处置:把其中一个勾上(隐藏)。
8. 切换会话后,隐藏失效了一两秒
原因:MutationObserver 60ms 节流 + 浏览器重渲染,会有极短窗口。这是设计取舍(不节流会卡)。
处置:不用管,会自愈。若持续不恢复,见第 3 条。
9. GET /scan 返回 404 scan.json 尚不存在
这不算错误。含义是「页面还没上报过」。打开页面等 2 秒,或点一次「⋯」就会生成。
10. 调 API 报 404 未知路由
原因:路径拼错。前缀必须完整:
/@lanbaolu/dsh-composer-butler/api/info
注意开头的 /@、中间的 @lanbaolu/,以及结尾的 /api。
11. DSH 启动报错,插件 entry 变 [no-fiber]
原因:通常是 inject 声明缺项(例如缺 tools),或者包加载阶段就抛异常。
处置:
node --check lib/index.js查语法。- 看
export const inject是否含['webServer', 'tools']。 - 从
bundles移除,先保证 DSH 能起来,再逐步排查。
12. 装完感觉界面变卡
原因:MutationObserver 每 60ms 最多跑一次,全量扫 conversation.input.* 下的直接子元素。控件数一般 < 50,正常无感。若你装了某个每秒重建上百个节点的插件,会叠加。
处置:F12 → Performance 录 5 秒,看 applyHidden 占比。是它就去 Issue 报,可以加缓存优化。
13. 勾了某个位置,但那里没出现「⋯」
先分清是哪一类:
- 那个位置本来就没渲染 → 见 3.6 的「什么时候真的看得见」列。最典型的是
conversation.input.dock(要排队消息/附件才出现)和conversation.input.right(活动区展开时整块被隐藏)。 - 勾了,但状态没保存 → 刷新后回到默认
composer.dock。多半是POST /state失败(host 半身没起来),F12 → Network 看/api/state是否 4xx/5xx。 - 位置对了,但按钮被别的插件遮住 → 悬浮球(
shell.overlay)若被全屏遮罩盖住,属于 z-index 冲突,换个位置即可。 - 一个位置都没勾 → 允许。
anchors: []是合法状态,只是没有⋯;设置页与侧栏「更多」这两个固定入口仍在。
处置:先勾一个「铁定会出现」的位置(sidebar.footer.action 常驻 / shell.overlay 全局)验证插件本身是否正常,再把想要的勾回去。
八、FAQ(18 问)
Q1:这是官方插件吗? 不是。这是第三方插件,基于 DSH 公开的 slot / bundle 机制实现,不改宿主源码。宿主升级可能让它失效,请留意版本发布说明。
Q2:隐藏按钮会不会让插件不工作? 不会。只加了一个 CSS 属性和一个 DOM 属性,插件本身完全不知道。它的快捷键、后台任务、定时器全部照常。
Q3:那我想真正停用某个插件怎么办? 用 DSH 侧栏的「插件」页开关。本插件面板底部也写了这句提示——就是为了防止混淆这两个概念。
Q4:会影响别人吗?
不会。状态存在你的机器上(state.json)和你的浏览器里(localStorage)。多用户共用一台机器时,各自浏览器各存一份 localStorage,但服务端 state.json 是共享的——后打开的人会补到前一个人的清单。这是个已知取舍,记在这里。
Q5:支持哪些 DSH 版本?
依赖 @deepseek-ai/dsh-client-ui-slots 提供的 ctx.slots 与 conversation.input.* 槽位。作者验证于 DSH 0.2.x。更早的版本可能没有这些槽位。
Q6:能隐藏侧栏按钮吗?
不能。只处理 conversation.input.*。侧栏、设置页、消息区都不在范围。
Q7:能改按钮顺序吗?
不能。顺序由插件的 order 决定。本插件自己用 order: 900 排在右侧偏后。
Q8:能导出/导入配置吗?
能,手动:复制 state.json 即可(内容就是纯 JSON 数组)。没有做 UI 导入按钮。
Q9:为什么按钮用「⋯」而不是图标?
零构建、不引入图标库,且「⋯」在中文界面里表意清楚(更多操作)。想换可以在 ButlerButton 里改字符。
Q10:面板能拖动吗?
不能。从工具栏 ⋯ 打开时贴着按钮下方(空间不足时上翻);从侧栏「更多」菜单行打开时用视口中央的虚拟锚点(菜单收起后原按钮已卸载,不能当 anchor)。点击外部或 Esc 关闭。
Q11:能批量操作吗? 「全部恢复」是唯一的批量操作,其余都得一行一行点。设计上故意不做「一键全部隐藏」——那会让输入框变成空的,容易吓到人。
Q12:rules.json 有什么用?
预留。将来做「按规则自动隐藏」用。现在写它没有可见效果。
Q13:会不会和别的显隐插件冲突? 可能。若两个插件都往同一个控件上加属性,行为叠加。实际中作者没遇到过,因为这类插件极少。
Q14:源码能改吗?许可允许吗?
BSD-3-Clause,可以改、可以分发,保留版权声明即可。改完建议跑 dsh-plugin-standard 校验。
Q15:我发现 bug / 想加功能,怎么办?
到 https://github.com/duma520/dsh-composer-butler/issues 提。附上:DSH 版本、scan.json 对应片段、浏览器 Console 报错(如有)。不要贴任何密钥、Cookie 或含个人信息的截图。
Q16:能让「⋯」出现在输入框以外的地方吗?比如工具条最左边、标题栏左端?
只能出现在官方声明为 list 型的插槽里,而且那个插槽的语义得是「可以加东西」。3.6 列的 9 个就是全部候选——不是挑出来的,是把 80 个插槽逐个看渲染位置后筛剩的。像「工具条最左边」属于 conversation.input.left 里的具体排序位置,我们只能保证自己在那个槽位内,不能插到别人控件中间。
Q17:勾多个位置会不会变成好几个按钮,各占一份资源?
会显示多个「⋯」,但它们共用同一份状态、同一个面板逻辑(anchorSet 单例 + 同一个 openPanel)。多出来的只是几个 20px 的按钮 DOM,没有额外的观察器、没有额外的定时器。不想要就取消勾选,立刻消失。
Q18:为什么「全部恢复」不把我的位置选择也重置?
因为它俩是两套数据:hidden(隐藏哪些官方控件)与 anchors(我们自己的按钮在哪)。「全部恢复」按字面意思只清隐藏清单。若连位置一起重置,你刚调好的布局会被一次误点毁掉。想重置位置,手动取消勾选即可。
九、已知限制与安全说明
9.1 硬限制(不是 bug,是方案决定的)
| 限制 | 原因 | 有没有可能解决 |
|---|---|---|
| 只隐藏界面,不卸载 | 官方无卸载 API | 不解决(这是定位) |
| 按钮位置只能从 9 个 list 型插槽里选 | single 型插槽已被官方占用,重复注册直接抛错 |
官方新增 list 型插槽才能扩 |
只处理 conversation.input.* |
其他区域的 slot 结构未逐一验证 | 可扩展,需实测 |
| 依赖官方 DOM 结构 | 没有官方可见性 API | 官方改结构就得跟着改 |
| 身份指纹可能漂移 | 无稳定 id | 官方给 id 才能根治 |
| 状态不跨机器同步 | 无云端 | 不解决(隐私优先) |
服务端 state.json 是机器级共享 |
无用户隔离 | 多用户场景需自行隔离数据目录 |
9.2 安全说明
- 不联网。插件只跟本机 DSH 的 webServer 通信(同源)。
- 不收集任何数据。
scan.json只写本机磁盘,且已列入.gitignore。 - 不读凭据。代码里没有任何读
cred.db、.env、环境变量的逻辑。 - 仓库不含运行期数据。
state.json/scan.json/rules.json全部被 git 忽略。 - 提交前扫描:本项目遵循「任何敏感信息不得上传仓库」的红线,每次提交前扫关键词与高熵串。
9.3 一个隐私提醒
scan.json 里会记录你输入框工具栏上可见控件的文本(例如技能名、按钮文案)。它不包含你聊天内容,但如果你的按钮文案本身含敏感信息(不太可能),它会被记录。这个文件不会上传,但如果你要把它贴到 Issue 里求助,先删掉敏感行。
十、目录结构、测试与开发
10.1 目录结构
dsh-composer-butler/
├── package.json # 包声明:exports / dsh.bundle / dsh.client / peerDeps
├── cordis.patch.yml # bundle 挂载声明(一行 insert)
├── lib/
│ ├── index.js # host 半身:HTTP 路由 + 状态工具
│ └── client.js # client 半身:按钮 + 面板 + DOM 显隐
├── README.md # 本文
├── LICENSE # BSD-3-Clause
├── .gitignore # 排除运行期数据与凭据
├── .gitattributes # 统一 LF
├── 软件升级迭代记录.md # 逐版变更记录
└── version/
├── 20261101-v1/ # v0.1.0 初版
├── 20261110-v2/ # v0.1.0 首发(README 全文)
├── 20261110-v3/ # v0.2.0
├── 20261110-v4/ # v0.2.1
└── 20261110-v5/ # v0.3.0(按钮位置自定义)
运行期会出现(不在仓库里):
├── state.json # 你的隐藏清单
├── scan.json # 界面侦察快照
└── rules.json # 预留规则表
10.2 自检命令
:: 语法
node --check lib\index.js
node --check lib\client.js
:: DSH 插件规范(failCount 必须为 0)
npx --yes dsh-plugin-standard@2 . --json
10.3 手工核对(浏览器控制台)
lib/client.js 末尾有 exports.__test = { scanControls, identityOf, applyHidden },这是构建期与人工核对用的内部导出,不是稳定 API,随时可能改,不要写进其他插件。
实际核对建议:打开面板 → 点「重新扫描」→ 打开数据目录里的 scan.json,逐条对照:
slots[].slot是否覆盖你界面上看到的区域?children[].text/ariaLabel/title是否与界面按钮对得上?hidden: true的条目是否确实在界面上看不见?
三者一致,说明扫描与显隐都正常。
10.4 改代码的注意事项(踩过的坑)
inject必须同时含webServer与tools—— 漏tools会让 entry 变[no-fiber]。- 状态必须单例 —— 别在组件里另建
Set(见 6.6)。 stableName的分隔符不含-—— 否则hold-to-dictate被截断造成误隐藏。- 数据目录别放
lib/—— 代码与数据分离。 - 「⋯」必须带
data-composer-butler-self—— 否则会被自己扫进列表,用户误关就锁死。 MutationObserver必须节流 —— 不加 60ms,DOM 密集变化会拖死主线程。- client 里只用
h(),不用 JSX —— 零构建,没编译器。 - 改完刷新页面才生效 —— 服务端模块表更新不推送已开页面。
- 写
state.json必须串行 + 原子 ——hidden与anchors各自会触发POST /state,两个请求几乎同时到达时fs.writeFile会交错写坏文件(v0.3.0 实测踩到:文件末尾出现重复的"updatedAt"段,整个 JSON 解析失败)。现为「同路径 Promise 链 + 写临时文件再rename」。
十一、版本更新简介
| 版本 | 日期 | 一句话 |
|---|---|---|
| v0.3.0 | 2026-11-11 | 按钮位置可自定义:9 个位置任选(可多选、即时生效、持久化),新增右下角悬浮球;修复 state.json 并发写坏 |
| v0.2.1 | 2026-11-10 | 侧栏「更多」入口排到菜单最前(order: -100),修复插件详情页配置区空白(ButlerPluginConfig 未定义),README 勾选方向改正 |
| v0.2.0 | 2026-11-10 | 入口从 1 个变 3 个:工具栏 ⋯ + 设置页分区 + 侧栏「更多」菜单行;commitHidden() 统一提交出口 |
| v0.1.0 | 2026-11-01 | 首个可用版本:⋯ 按钮 + 显隐面板 + 持久化 + 自愈 + 防自锁 + 重复标记 |
逐版详细变更见 软件升级迭代记录.md。
十二、贡献与许可
12.1 贡献
欢迎 Issue 与 PR。提之前请:
- 先跑 10.2 的两条自检命令;
- PR 里说明「改了什么、为什么、怎么验证的」;
- 不要在 Issue/PR 里贴
state.json全文(含你的个人使用习惯),贴片段即可; - 不要贴任何密钥、Cookie、token。
12.2 许可
BSD-3-Clause,见 LICENSE。Copyright (c) 2026, duma520。
附录 A:与同类方案对比
| 方案 | 能否隐藏按钮 | 是否需要改宿主 | 状态持久 | 代价 |
|---|---|---|---|---|
| 本插件 | ✅ 逐项 | ❌ 不需要 | ✅ 双写 | 依赖 DOM 结构 |
| 卸载不要的插件 | ✅ 彻底 | ❌ | — | 功能一起没了,重装麻烦 |
| 改宿主源码加可见性 | ✅ 最彻底 | ✅ 要改 | 看实现 | 升级被覆盖,风险高 |
| 插件市场管理 | ❌ 无此能力 | — | — | — |
附录 B:一分钟速查卡
装: zip 解压 → node_modules\@lanbaolu\dsh-composer-butler
→ profile package.json 的 dependencies + bundles 各加一行 → 重启 → Ctrl+F5
用: 点「⋯」(或 设置 → 输入框按钮管家 / 侧栏「更多」→ 输入框按钮管家)
勾上 = 隐藏;取消勾选 = 显示
位置:面板顶部「按钮显示位置」9 个位置可多选,勾上 = 该处出现「⋯」
默认只勾「输入框底部工具行」;右下角悬浮球要自己勾
救: 「全部恢复」= 一键全显示;搞崩了删 state.json
查: GET /@lanbaolu/dsh-composer-butler/api/info
GET /@lanbaolu/dsh-composer-butler/api/state
GET /@lanbaolu/dsh-composer-butler/api/scan
卸: bundles 删条目 → 删包目录 → 重启 → (可选)删 state/scan/rules
坑: ① 注入后必须刷新页面 ② 只隐藏不卸载 ③ 别手改 JSON 里的 key
输入框按键管家 · 让工具栏只留你真正在用的东西
No comments yet. Be the first to write one.