Skip to content

dsh-preset-studio

Verified

dsh-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)后面,因此侧栏里两页相邻。

之所以不能把功能并进官方那一页,有三个硬原因:

  1. 官方页的 settings.section 注册没有声明 children,第三方无法往它内部 挂任何东西;往里塞内容只能改官方包源码。
  2. 提供该页的包同时提供新会话的预设选择 chip 与会话头标签,禁用它腾出位置会 连带失去选择器。
  3. 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 相同:

  1. MutationObserver 盯着 document.body,找到 [data-shortcut-modal="settings"] nav 里文字等于「预设工作室」的那个 button,给它打上 data-ps-nav;
  2. 一条 CSS 把 shell 的兜底齿轮 display:none 掉,再用 ::before + -webkit-mask 画自己的 mark;
  3. 图标是内联 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 方言下可加载。

写入的三条硬约束:

  1. config 整块替换 → 界面持有完整插件树,哪怕只翻一个开关也会重述整个 plugins 数组。
  2. 本插件新建的预设写成 - insert: 子行,理由见下一节——顶层 - id: 行在 找不到目标时会被静默跳过,看起来像写成功了,实际什么都没挂上。
  3. 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 服务器与工具可见性

一个预设能调用哪些工具,由两件事共同决定,而这两件事在配置里离得很远:

  1. 有哪些工具 —— @deepseek-ai/dsh-mcp-client 的声明行。每台服务器贡献一批 工具,名字固定是 mcp__<serverName>__<原名>。
  2. 这个预设允许用哪些 —— 预设自己 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):

  1. 解析当前文档 → 在内存里改
  2. 在 boot 方言下校验(yaml.parseDocument 总量检查 + entryListSchema 可加载性检查)——不过就不写
  3. 把改动前的内容存成快照 auto-<动作>
  4. 原子落盘:写临时文件再 rename,权限 0o600
  5. 失败则回滚到快照

校验在快照之前,所以被拒绝的编辑不会留下快照垃圾。回滚本身先存一份 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