dsh-preset-studio
Verifieddsh-preset-studio · v0.1.3 · MIT · Web UI
DSH 预设工作室:在设置里用一个整页新建 / 编辑 / 复制 / 删除本 profile 的 Agent 预设——组成树、系统提示词(含 {{变量}} 校验)、装配诊断、来源行定位、与 standard 的差异对比、快照回滚,以及导出为可分享的 bundle。
Install
dsh plugin add dsh-preset-studio Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Published to npm without a public repository. Inspect the package contents before installing.
Tags
Readme
dsh-preset-studio
DSH 的预设工作室:在设置里用一个整页看清、并且直接改本 profile 的 Agent 预设——它们由哪些插件行组成、系统提示词写了什么、哪里坏了、来自配置文件 的哪一行;然后新建、编辑、复制、删除、对比、导出,每一步都可回滚。
它解决什么
Agent 预设(如「快准狠模式」)在配置里是一大块 YAML:一个
@deepseek-ai/dsh-agent-preset 声明行,config.plugins 里塞着几十行插件、
组、!!js 条件开关,再加上一段 persona 提示词。这套结构平时只能靠翻
cordis.patch.yml 才能看明白,而那份文件通常上千行。
这个插件把那块内容变成可操作的页面:
| 呈现 | 说明 |
|---|---|
| 预设清单 | 本 profile 声明的 + 内置的,按 order 排序,标注默认项与健康状况 |
| 组成树 | 每个插件行的启用 / 关闭 / 条件状态,组的嵌套缩进,isolate 标注 |
| 组成统计 | 声明行数、去重后的插件模块数、启用 / 关闭 / 条件 / 组计数 |
| 系统提示词 | persona 行的 prefix 与 suffix 分段展示,以及用到的 {{变量}} |
| 提示词校验 | {{变量}} 写错的位置与原因(见下) |
| 装配诊断 | 预设装配失败时,registry 报出的原始原因 |
| 来源定位 | 该预设来自 patch 文件的第几行,是顶层行还是 insert 行,能否就地编辑 |
| 编辑器 | 改标题 / 描述 / 排序 / 插件方块网格(点方块即开关、一键全开全关、条件、isolate)/ 提示词 |
| 新建预设 | 默认照「标准模式」把插件都摆上,取消掉不要的即可,不必从空白一个个加 |
| 插件分类 | 方块按来源分两栏:「官方自带」与「你自己装的」 |
| 对比 | 与任一其他预设逐行比:只有它有、只有对方有、开关不同 |
| 复制为副本 | 从任意预设(含内置的「标准模式」)派生一份新预设,页面内表单填 id 与名称 |
| 快照与回滚 | 每次写入前的自动快照,可列出、可回滚,回滚本身也可回滚 |
| 导出为 bundle | 直接生成可发布的 dsh-preset-<id> 包(三个文件)到导出目录 |
| MCP 服务器 | 本 profile 声明的 MCP 服务器:传输方式、启动命令、超时、重连、启用状态,可增删改 |
| 工具可见性 | 每个预设能看到哪些工具:家族整体开关 + 展开后逐工具开关,写回它自己的 denyPrefixes |
导航是单列多级下钻:清单 → 某个预设 → 某个分区(组成 / 系统提示词 / 属性 / 运行时行状态),每层都是整宽单列,窄屏与移动端不需要横向挤压,返回即面包屑。 「MCP 与工具」与「快照与回滚」是清单层并列的入口,不在某个预设里面——它们管的是 整个 profile 的运行时形状,不属于某一个预设。
提示词变量为什么值得单独校验
{{变量}} 在 DSH 里是严格校验的,不是宽松的模板替换。渲染一个引用
了未注册变量的 section 会抛错,后果是该 persona section 注册失败、
整个预设装配不出来。而报错信息只在服务端日志里,界面上只会看到一个
装配失败的预设,看不出是哪个词写错了。
本插件复刻 @deepseek-ai/dsh-system-prompt 的扫描逻辑,在页面上直接指出:
- 未知变量 ——
{{nope}},并列出实际可用的三个:cwd、model、provider - 变量名不合法 —— 不符合
[a-z][a-z0-9_]*,例如{{Cwd}} - 引用写法不完整 —— 有
{{也有后面的}},但中间不是简单名字
扫描同时复刻了它的一处细节:孤立的 {{ 之后如果没有 }},那是普通文本,
不是错误。所以这里不会对散文里出现的 {{ 误报。
编辑器在保存前用同一套规则挡住未知变量,不让一次手误把预设写坏。
安装
dsh plugin --profile <profile> add dsh-preset-studio
或手动在 profile 的 cordis.patch.yml 里加:
- insert:
- id: preset-studio
name: 'dsh-preset-studio'
装好后重启 DSH,打开设置 → 预设工作室。
入口为什么是独立一页
它注册在 settings.section,id preset-studio,order: 21——紧挨在
官方「Agent 预设」页(order: 20)后面,因此侧栏里两页相邻。
之所以不能把功能并进官方那一页,有三个硬原因:
- 官方页的
settings.section注册没有声明children,第三方无法往它内部 挂任何东西;往里塞内容只能改官方包源码。 - 提供该页的包同时提供新会话的预设选择 chip 与会话头标签,禁用它腾出位置会 连带失去选择器。
settings.section是list类型(并列追加),没有「顶掉同位置条目」的 选举语义,所以也无法用自己的条目替换官方条目。
侧栏图标为什么是「画上去的」
settings.section 的注册参数只有 id / order / label,没有图标字段。
shell 用一张硬编码的映射表挑图标:
navIcon(id) // account / models / agent-presets / plugins / archived-sessions
// 其余 id 一律兜底成通用齿轮
所以第三方 id 永远拿到那只齿轮——它和「通用设置」长得一模一样,在一列入口里 认不出来。
契约里没有位置,就只能在渲染之后接管,做法与 dsh-mcp-manager、
dsh-agnes-multimodal 相同:
MutationObserver盯着document.body,找到[data-shortcut-modal="settings"] nav里文字等于「预设工作室」的那个button,给它打上data-ps-nav;- 一条 CSS 把 shell 的兜底齿轮
display:none掉,再用::before+-webkit-mask画自己的 mark; - 图标是内联 SVG data URI,
background-color: currentColor配合 mask, 于是亮/暗主题、悬停、选中态自动跟随,不引入任何颜色值。
两个细节值得记下来:
- 只加
data-*属性,不替换 shell 的节点。 那一行的svg归 React 所有, 把它换掉会让 React 在卸载时对着一个已不在树上的子节点调用removeChild而抛错。 React 不管理它没写过的属性,所以重渲染不会把标记抹掉。 - mask 变量定义在
[data-shortcut-modal="settings"]上,不是:root, 这样它不会漏到 shell 的其它地方。
mark 本身是四个圆角方块:左上实心表示「开着」,其余空心表示「可以关」—— 正是这一页在做的事(一个方块=一个插件)。选它也是为了和邻居区分开: 「MCP 服务器」是节点图,「多模态配置」是叠起来的方块加播放三角。
写入路径:为什么不能用官方的配置编辑器
@deepseek-ai/dsh-config-editor 的 entries() 只认 fiber.entry?.id === "include"
的行,且 configuration() / edit() 会主动排除带 insert 的行。而本机两个
预设恰好都是 - insert: 的子行,官方编辑器根本看不见它们。它也是 asar 专属包,
外部插件装不上。
所以 host 半自己实现 YAML 读写(lib/patch-doc.js),复刻官方 edit() 的安全
手法,并针对预设结构做了三件官方编辑器不会做的事:
| 手法 | 原因 |
|---|---|
yaml(eemeli)的 parseDocument + toString |
保留注释与格式;js-yaml 的 load/dump 会把整份文件的注释抹掉 |
carryComments(oldNode, newNode) |
config 是整块替换(applyEntryPatches 里是 target[key] = value,不是深合并),替换后必须把原节点的 commentBefore / comment / spaceBefore 搬回来,否则一次开关翻转就会丢掉注释 |
!!js 标记改写为带 tag 的标量 |
createNode 无法设置自定义 tag,条件开关必须以 !!js 写回,否则会被序列化成字符串 |
对真实那份 1057 行、357 条注释的 cordis.patch.yml 做整份重写,注释一条不少,
字节增量在 3 KB 以内,且结果在 boot 方言下可加载。
写入的三条硬约束:
config整块替换 → 界面持有完整插件树,哪怕只翻一个开关也会重述整个plugins数组。- 本插件新建的预设写成
- insert:子行,理由见下一节——顶层- id:行在 找不到目标时会被静默跳过,看起来像写成功了,实际什么都没挂上。 update永不改写id:会话里存的是它启动时的预设 id,改了会让既有会话脱钩。
为什么新建的行必须是 insert 行
applyEntryPatches 组装配置树时是从空树开始的:
function composeEntries(layers, warn) {
return applyEntryPatches([], structuredClone(layers.flat()), warn);
}
也就是说树只由 - insert: 行搭起来。随后每条补丁按 id 在树里找目标:
- 带
id的顶层行 —— 只作为覆盖目标参与,找不到就warn("patch: entry %C not found")然后continue。它自己不建树。 - insert:的子行 —— 无条件挂上去,并立刻进索引,后面的补丁可以再定位它。
用真实的三层(dsh-base + dsh-web-app + profile,共 171 行)实测过:
本机两个预设 quickfix / razor 都是 insert 子行,都能挂上;而 profile 里两条
用户自己写的顶层行(ui-settings-account、agent-preset-registry)今天就是
死行——它们的 id 在整棵组合树里根本不存在,只在启动日志里留两条警告。
所以新建预设、新建 MCP 服务器一律走 appendInsertChild,唯一合法的形状是
- insert: [ … ]。
导出是刻意的反向选择:bundle 补丁同样用 - insert:,因为它要被叠加到别人那份
还没有这个预设的 composition 上;写成 id 定位的顶层行会匹配不到任何东西,被
静默跳过并只留一条警告。
插件方块与开关
插件列表是一格一格的方块,一个方块就是一个插件,点方块本身就是开关—— 不用先找到那个小拨杆。关掉的方块留在原位只是变淡、边框变虚线,位置不乱跳, 这样才看得出「我刚才关了哪个」。方块右上角的状态点说明当前状态:绿=开着, 灰=关着,黄=看条件。方块右上角悬停出现两个小按钮:改设置、移出预设。
方块按来源分两栏:「官方自带」(@deepseek-ai/ 下的包,以及 cordis:group
这种 Loader 内建伪模块)与**「你自己装的」**(其余包名)。分组自己也是一个方块,
占满整行,子方块摆在它里面——它本身不是插件,只是把几个插件收在一起。
点方块的铅笔打开它的设置面板,面板就插在那个方块的正下方。这不是排版偏好: 面板原先排在整片网格的末尾,预设一长网格好几排高,点中间的方块面板就落到 屏幕外,点下去看着像没反应。插在正下方之后,「点了哪一格」和「面板在哪」 始终是同一眼能看到的两个位置。
卡片头的「全部启用 / 全部关闭」作用于顶层。两种开关的落点不同:
- 叶子方块的开关翻转它自己的
disabled。 - 分组的开关作用于整棵子树。这不是设计选择而是被迫的:Loader 的
Entry.disabled对options.group直接返回false,写一个组自己的disabled等于什么都没写(dsh-app-boot自己的deny()也要靠row.group = false绕开)。 分组显示的是子树状态,全是关才显示关,混着就显示条件色。
带 !!js 条件表达式的行不会被批量开关动到:那是条件,不是开关,被「全部关闭」
覆盖成 disabled: true 会把一份平台判断永久销毁。真实那份 patch 里有 win32 的成对
条件行,所以这条不是假想。条件行自己的开关点下去仍然会写成 disabled: true——
那是用户明确指着这一行说要关。组里全是条件行时,组开关与批量按钮都置灰。
新建预设的默认值
新建不从空白开始。默认值取 @deepseek-ai/dsh-agent-presets 自带的**「标准模式」**
(presets/standard/agent.cordis.yml)——那是官方对「一个完整 Agent 该开哪些插件」
给出的答案。用户要做的只是取消掉不要的,比一个个加省事得多。
读它必须用 boot 的方言(js-yaml + cordis-plugin-include 的 entryListSchema),
不能用 yaml:那份文件里 tool-bash / tool-pwsh 的 disabled 是 !!js 条件,
yaml 会把它退化成普通字符串,写回补丁时就成了恒真的 disabled,等于把两个
shell 工具一起关死。方言产出 { __jsExpr },正好接上 toPlain 的 { $js } 约定,
页面照原样渲染成 !!js …。
解析链上找不到那个包时,seed 是 null,新建退回空列表——降级,不是失败。
目录型预设:有内容,但没有声明行
DSH 的预设有两套并存的机制。桌面端走的是声明行:cordis.patch.yml 里一行
- id: preset-xxx 加一个 config。而 @deepseek-ai/dsh-agent-presets 这个包还带
目录型预设——presets/<id>/agent.cordis.yml 一份文件就是一个预设,standard
(标准模式)就是这种。
麻烦在于:目录型预设在本 profile 里没有声明行,但它是真实存在、真实可用的 预设。只认声明行的代码会在它身上出三种错,而且看起来互不相关:
| 症状 | 真实原因 |
|---|---|
| 「与 standard 对比」报「找不到声明行」 | 读对手配置时只查了声明行 |
| 「复制为副本」点了没反应 | 复制要读源配置,同样只查了声明行 |
| Standard 页显示「0 声明行 · 0 插件模块」 | 行数取自声明行的 config.plugins |
三处共用一个修法:读不到声明行时,退到它自己的 composition 文件。于是 「与 standard 对比」能列出它 31 行,复制能从它派生新预设,它自己的页面也报出 真实的 31 行 / 25 个模块 / 3 个组——和对比页给出的数字一致。
读那份文件同样必须用 boot 的方言(js-yaml + entryListSchema),理由和新建
预设的种子一样:tool-bash / tool-pwsh 的 disabled 是 !!js 条件,用 yaml
解析会退化成普通字符串,复制出来的新预设里那两个工具就永久关死了。
「复制为副本」的按钮门控也据此分开:能不能就地改(editable,要求有声明行)
和能不能拿它当模板(writable,只要求本 profile 可写)是两件事。用前者去
禁后者,等于标准模式永远复制不出来——而它恰恰是最值得照抄的那一份。
MCP 服务器与工具可见性
一个预设能调用哪些工具,由两件事共同决定,而这两件事在配置里离得很远:
- 有哪些工具 ——
@deepseek-ai/dsh-mcp-client的声明行。每台服务器贡献一批 工具,名字固定是mcp__<serverName>__<原名>。 - 这个预设允许用哪些 —— 预设自己
config.plugins里的dsh-preset-tool-restrict行,denyPrefixes是一串前缀。
「MCP 与工具」这一页把两件事放在同一屏:上面是服务器清单(可增删改),下面是 选中预设的工具家族开关。之所以不拆成两页,是因为把一个家族显示成「关」而工具清单 是旧的时候,界面就在撒谎。
工具清单读的是「已知名」,不是「可见名」
ctx.tools.view(scope) 返回 { visible, knownNames, restrictableNames }:
knownNames 保留已被隐藏的工具,visible 不保留。编辑器必须用前者——否则
一个预设关掉的工具会从清单里消失,用户再也没有办法把它打开。页面底部会写明这次
数据来自哪个来源(view / schemas / 读不到),读不到时不假装有数据。
服务器编辑
支持 @deepseek-ai/dsh-mcp-client 的两种传输,字段按它的 zod schema 一一对应:
| 传输 | 字段 |
|---|---|
stdio |
command、args(每行一个)、env(每行 KEY=VALUE)、cwd |
streamable-http |
url、headers(每行 Name: value) |
两种共有 serverName(^[A-Za-z0-9_-]{1,32}$)、toolCallTimeoutMs、
failOnStartupError、reconnect(JSON),以及补丁行上的 disabled。
切换传输时会清掉另一种传输的遗留键。 config 是整块替换,如果只写新键,
url 会留在原来那台 stdio 服务器上。所以保存时把 schema 里这次用不到的已知键
显式标成 null,setRowConfig 见到 null 就删键;新建路径反过来,用
withoutDeletions() 把删除标记摘掉,免得新行里出现 url: null。
serverName 在 profile 内唯一,重名报 duplicate-id。
预设的工具开关
这一页打开时选中的是 profile 的默认预设(selectedDefault),不是列表里的
第一个。这一条不是小事:如果落在第一个预设上,用户会看着 A 的开关去改 B 的行,
现象正是「我改了一个预设,另一个预设的设置也跟着变了」。
开关按家族分组:mcp__<serverName>__ 是 MCP 家族,其余按第一个 __ 前的
前缀分组,没有前缀的是单个工具。家族顺序是 MCP 优先,然后按工具数量降序。
家族可以展开,展开后每个工具名自己有一个开关。 这一层不是装饰:家族开关只能
整个家族一起关,而「这个预设里我只要 mcp__playwright-mcp__browser_click 别看
得见」是一个真实且常见的意图,没有单工具开关就没有任何开关能表达它。两种隐藏方式
在存储上是两回事——家族前缀和单个工具名都是 denyPrefixes 里的条目——所以
- 家族里部分被隐藏时,家族开关显示为第三种状态(黄色,副标题写「N / M 个已隐藏」), 它既不是「全开」也不是「全关」,点一下表示「整个家族都关」。
- 单独放行一个被家族前缀盖住的工具,会把那个前缀换成它其余兄弟的名字,结果精确等于 「这一个可见,其余照旧隐藏」。
- 单独关一个工具就是追加它自己的名字。
翻转一个开关就是重写这个预设 denyPrefixes 的整个数组(同样是整块替换)。
两种情况要分清:
- 预设自己有
tool-restrict行 —— 开关直接写这一行。 - 预设没有这一行 —— 此时生效的是插件内置默认(
mcp__playwright-mcp__、mcp__droidmind__),界面按默认值显示开关状态并写明这一点。拨动任意一个开关 (包括「全部启用」)都会为它新建这一行。
空列表是一个真实的意图,不是「没写」。 显式写出 denyPrefixes: [] 的含义是
「这个预设不屏蔽任何工具」,它是权威的,不会再回落到插件默认——否则「全部启用」
在一个从来没有这一行的预设上就什么也改不动。只有键缺省(行不存在,或行里
没有 denyPrefixes)才继承插件默认。
原始 denyPrefixes 文本框默认是折叠的,收在「高级:直接编辑原始列表」后面。
它是逃生口,不是主控件:普通用户的每一个意图都能用上面的开关表达,而一个要求
「按 denyPrefixes 语法写」的文本框对其他人就是一面墙。需要家族开关覆盖不到的
组合(比如一条自定义前缀)时再展开它,直接写前缀或单个工具名。清空它并保存就是
上面那个空列表。
安全手法
每次写入都走同一条路(applyWrite):
- 解析当前文档 → 在内存里改
- 在 boot 方言下校验(
yaml.parseDocument总量检查 +entryListSchema可加载性检查)——不过就不写 - 把改动前的内容存成快照
auto-<动作> - 原子落盘:写临时文件再
rename,权限0o600 - 失败则回滚到快照
校验在快照之前,所以被拒绝的编辑不会留下快照垃圾。回滚本身先存一份
auto-before-rollback,因此回滚是可撤销的。自动快照保留最近 40 个。
删除当前默认预设时,默认项回落到 registry 自己的 default(再不行 "standard"),
保证下次启动仍有默认值。
写入后不需要手动调用任何内部 API:dsh-hmr 在监听 profile.patchPath、
home patch 与 package.json,文件一变它自己会 reconcileProfilePatches。
(lib/ 下的代码变更不在监听范围内,改插件自身代码要重启 DSH。)
数据来源
页面数据全部来自 host 半在每次请求时实时读取的服务,没有会过期的缓存:
loader.entries()—— 声明行本身,其config已是解析后的对象agentPresets(registry)—— 各预设的激活诊断,以及每行的实际启用状态tools(ctx.get("tools"))—— 运行时注册表里已知的工具名,用于分组与统计profileContext.patchPath—— 打开 YAML 文档,用于报告来源行并执行写入
profileContext 与 webRuntime 用 ctx.get() 读取,不列为必需注入:桌面版
(dsh/lib/profile-boot-BZ2ZjNWi.js,asar 内)会 provide("profileContext", …),
而 CLI 启动的 profile(dsh/lib/profile-boot-CuwbWsnH.js)从不 provide 它。若把
它们写进 inject,CLI 下整个插件会停在 pending (waiting for service: profileContext)——路由不注册、页面不出现。降级后 CLI 下仍可读,只是
patchPath 为 null、写按钮全部禁用,并附一句
this profile exposes no patch path。
!!js 条件开关在传输时被标记为 { "$js": "…" },页面上按 !!js … 原样
呈现。若把它当成普通对象序列化,会变成一个空的 {}——那会把「这个开关
有条件」误报成「没有条件」。
接口
host 半只开一个路由,带与其他插件路由相同的围栏 (loopback / 已配置的信任域 Host 头 + 同源浏览器标记;这是防 DNS rebinding, 不是身份认证)。请求体上限 1 MB。
POST /preset-studio/api
{ "action": "list" }
{ "action": "create", "config": { id, name, description, order, plugins } }
{ "action": "update", "id": "razor", "config": { … } }
{ "action": "delete", "id": "razor" }
{ "action": "duplicate", "id": "razor", "newId": "razor-2" }
{ "action": "setDefault", "id": "razor" }
{ "action": "snapshots" }
{ "action": "snapshot", "label": "手动" }
{ "action": "rollback", "name": "<快照文件名>" }
{ "action": "export", "id": "razor" }
{ "action": "diff", "id": "razor", "against": "standard" }
{ "action": "mcp" }
{ "action": "mcpSave", "rowId": "", "config": { transport, serverName, … }, "disabled": false }
{ "action": "mcpDelete", "rowId": "codegraph-mcp" }
{ "action": "toolRestrict", "id": "razor", "denyPrefixes": ["mcp__playwright-mcp__"] }
mcpSave 的 rowId 为空表示新建。toolRestrict 在预设还没有 tool-restrict
行时按需创建,列表为空则不写入。
错误码到 HTTP 的映射:bad-input / bad-id / bad-name / bad-plugins /
bad-snapshot / bad-mcp → 400;not-found → 404;duplicate-id /
not-declared → 409;no-profile / no-registry / yaml-unavailable → 503;
其余 500。
权限
- 可写,但只写两处:profile 的
cordis.patch.yml,以及 profile 目录下的.preset-studio-snapshots/;导出另写$DSH_HOME/preset-studio-exports/。 - 写入前一律先校验、先快照;任何一步失败都不落盘。
- 不发送任何网络请求,不引入运行时依赖。
兼容性
- 需要
@deepseek-ai/dsh-agent-preset(预设声明机制)与@deepseek-ai/dsh-agent-preset-registry(agentPresets服务)存在。 后者缺失时页面仍能列出声明,只是没有运行时诊断,且不能新建(没有默认项可回落)。 - MCP 与工具那一页不需要额外依赖:服务器清单是直接读补丁文件里的
@deepseek-ai/dsh-mcp-client行。工具清单需要运行时tools服务;读不到时 家族开关不可用,页面会写明来源是「读不到」而不是显示一份空清单。 yaml用于读写 patch 文件;不可用时页面会显示一条说明,读仍可用, 写会明确报yaml-unavailable,而不是静默失败。
License
MIT