dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
DeepSeek Harness 综合体验插件:会话菜单 4 组重排 + 8 项会话管理、批量管理、桌面「重启」入口、插件升级卡片(版本检查 / 项目地址 / Star)
一个综合性体验插件,把 DSH 的会话管理补齐到「右键即用」,并给桌面端的托盘菜单与应用菜单补上「重启」入口。
项目地址:https://github.com/polan-prologue/dsh-experience-plus · 许可证:MIT · DSH 兼容:见「三之三、跨 DSH 版本兼容契约」
# 从 GitHub 安装(推荐:锁定 tag,升级由你决定)
dsh plugin --profile desktop add github:polan-prologue/dsh-experience-plus#v0.1.0
# 从本地目录安装(二次开发时用这个)
dsh plugin --profile desktop add link:/path/to/dsh-experience-plus
# 卸载
dsh plugin --profile desktop remove dsh-experience-plus
--profile换成你自己的 profile 名(桌面版通常是desktop,纯 Web 版是web)。 装完重启 DSH;宿主半侧的改动(路由、配置字段)不会热重载。
这个插件会碰你的磁盘吗? 只有两处,且都默认关闭或显式触发:
| 动作 | 默认 | 说明 |
|---|---|---|
桌面「重启」补丁(改写 app.asar) | off = 只检测、不写盘 | 想启用要在设置页点「立即应用补丁」或跑 tools/应用桌面补丁.cmd |
| 自动检查更新(出网请求) | 0 = 关闭 | 开启后每次启动查一次、之后按间隔复查;只读仓库 tag,不上传任何本机信息 |
其余功能(会话菜单、批量管理、未读圆点、自动作答)都只动会话与插件自身配置。
排序靠 order 与内核自带的 4 行交错实现,分隔线落在本插件自己的行上(separatorBefore),
因此不需要遮蔽任何原生行——原生行为、快捷键、对话框全部保持原样。
| 组 | 菜单项 | order | 来源 | 图标 |
|---|---|---|---|---|
| 1 | 置顶对话 | 100 | 内核 | IconPinOutlineRegular |
| 重命名 | 200 | 内核 | IconEditOutlineRegular | |
| 复制会话 ID | 250 | 本插件 | IconCopyOutlineRegular | |
| 标记为未读 / 已读 | 260 | 本插件 | IconQueueOutlineRegular | |
| 在新对话中继续 | 300 | 内核 | IconBranchOutlineRegular | |
| 2 | 分隔条 | 350 | 本插件 | —(见下) |
| 归档对话 | 400 | 内核 | IconArchiveOutlineRegular | |
| 批量管理 | 450 | 本插件 | IconChecklistOutlineRegular | |
| 3 | 在文件资源管理器中显示 | 500 | 本插件 | IconFolderOpenOutlineRegular |
| 复制工作目录 | 510 | 本插件 | IconFolderCloseRegular | |
| 导出对话记录 | 520 | 本插件 | IconDownloadOutlineRegular | |
| 4 | 删除对话 | 600 | 本插件 | IconTrashOutlineRegular(danger 红字) |
与需求原文的一处差异:需求第 1 组写作「重命名、置顶对话」、第 4 组写作「删除对话、归档对话」。 实际实现按内核原生顺序(置顶在前、重命名在后)与截图(归档对话只出现一次,位于第 2 组)落地。 理由:置顶/重命名是内核注册的行,调换它们需要遮蔽原生行并重新实现其行为(含重命名对话框), 收益只是顺序,风险却很高。如果你确实要「重命名在前」,告诉我,我再用遮蔽方案做。
内核没有会话右键菜单——dsh-client-ui-workspace 里 onContextMenu 出现 0 次,
那张菜单只有行尾的「…」按钮能打开。本插件用纯插件侧的桥把它补上:
document 上监听 contextmenu;data-row-key="session:<id>" 的行内时,替用户点一次该行的「…」按钮,
菜单就按原生方式锚定展开(preventDefault,不弹浏览器菜单);data-row-key(非哈希属性,比 CSS Module 的 class 稳定得多);所以现在「右键会话行」和「点行尾 …」两条路都能打开同一张菜单。
本插件的行与内核自带的行共用同一张菜单,任何一行渲染抛错都会让整张菜单打不开。
因此每一行都包了 RowBoundary:出错只让那一行消失并记一条 console,绝不连累原生行。
同理,所有 standard props(useSessions、useMenuOpenState)都做了存在性判断,
缺失时退化为「该项不可用」而不是「菜单打不开」。
| 能力 | 落地方式 |
|---|---|
| 复制会话 ID | 浏览器侧写剪贴板(async clipboard API,失败退回 execCommand) |
| 标记为未读 | 插件自持状态(localStorage)+ 行首圆点 + 菜单项在「未读/已读」间切换 |
已按用户要求移除。原因是内核根本无法把已有会话移到另一个项目(工作区成员关系 = 账目 ∧ 会话 header 里固化的 cwd,而 cwd 没有改写接口)。它占的 order 350 现在由一条纯分隔条接手——否则第 2 组的首项会变成内核自带的「归档对话」,而原生行不接受 separatorBefore,分组边界会消失。 | |
| 批量管理 | 进入批量模式:会话行首出现复选框,左栏下角(登录名上方)出现内联操作条——「全选 / 已选 N 项 / 取消」+「归档 / 删除」,形态对齐参考截图 |
| 在文件资源管理器中显示 | 复用内核能力 sessionController.openWorkspacePath({ path })——官方带路径校验与桌面可用性判断。⚠️ 不能传 action: 'reveal':那是「在父目录里选中它」(explorer /select,<path>),会让用户看到上级目录;省略 action 才是打开目录本身 |
| 复制工作目录 | 浏览器侧从会话摘要读 cwd 后写剪贴板 |
| 导出对话记录 | 复用内核已有的 GET api/session.export?sessionId=…&includeDescendants=true 下载 ZIP |
| 删除对话 | 宿主侧文件操作,默认软删除到 ~/.dsh/session-trash/,见下面 |
托盘菜单(new Tray() + setContextMenu)与应用菜单(Windows 上由主进程 Menu.buildFromTemplate(...).popup() 弹的原生菜单)
都由 Electron 主进程构建,而 DSH 插件跑在独立的宿主子进程(dsh-desktop-host,由主进程 fork)里:
Tray / Menu;lib/main.js 里全是硬编码模板)。所以两处「重启」只能注入 resources/app.asar 内的 lib/main.js。这是唯一的路径,没有替代方案。
关键事实(本机实测):
process.exit(1)
——所以绝不能只改内容不改哈希;<asar>.unpacked/ 读盘)不校验 integrity;EnableEmbeddedAsarIntegrityValidation fuse 是 DISABLED(OnlyLoadAppFromAsar 也是)。于是补丁做两件事:
main.js 写到 resources/app.asar.unpacked/lib/main.js;lib/main.js 条目改成 { size, unpacked: true, integrity },
并把 header JSON 用空格补齐到原字节长度后原地写回。只动 header 中长度不变的一小段(3 392 048 字节),不动 121 MB 数据区,也不重算别的文件哈希。 脚本内置:锚点必须唯一命中、写盘前备份、语法自检、写盘后复检。
| 位置 | 改动 |
|---|---|
| 托盘菜单 | 打开 DeepSeek Harness ─── 重启 DeepSeek Harness 退出 DeepSeek Harness |
| 应用菜单 | … ─── 重启 退出 |
| 实现 | 新增共用的 restartApplication()(app.relaunch() + 不询问退出),与内核崩溃恢复/开发态重启同一条语义 |
| 文案 | 复用了内核已有的 restartApplication(重启/Restart),只新增 restartApplicationFull 以匹配托盘的 打开/退出 句式 |
改动是纯新增:与原始 main.js 比,净增 24 行、删除 0 行。
「设置 → 体验优化 → 插件」页里有一张卡片,把「我装的是哪一版、线上有没有新版、 仓库在哪」三件事放在一处:
| 元素 | 含义 |
|---|---|
| 运行版本 | 插件加载时的 package.json version(进程活着就不变) |
| 磁盘版本 | 此刻磁盘上 package.json 的 version(link: 安装下会被你改) |
| 最新版本 | GitHub 上仓库的最新 tag(只显示已知值,查不到就不显示数字) |
| 状态徽标 | 🟢 已是最新版本 / 🟡 可升级到 x.y.z / ⚪ 无法确认最新版本 |
| 上次检查 | 上次成功查到的时刻(相对时间),持久化在配置里 |
| 检查更新 | 联网查一次(唯一由本卡片触发的出网动作) |
| 去 GitHub 点 Star | 新窗口打开项目地址 |
| 打开项目地址 | 同上;地址同时明文显示在下方一行 |
| 自动检查更新 | 间隔下拉:关闭(默认)/ 每 6 / 12 / 24 小时 |
GET .updateStatus 只读进程内缓存;出网只发生在
POST .updateCheck(你点了按钮)与自动检查定时器里。判据是「路由方法」,
见集成测试里那条「.updateStatus 必须只读」。link: / 目录复制安装下是常态:
你改了磁盘上的代码,浏览器里跑的还是加载时那一份。不提示的话,
用户会拿旧版本去比线上版本,得出错误结论。只读公开的 tag,不发送任何本机信息。三条候选路按顺序试,第一条成功即止:
| 顺序 | 端点 | 为什么需要它 |
|---|---|---|
| 1 | api.github.com/repos/<owner>/<repo>/releases/latest | 有发 release 时最准 |
| 2 | api.github.com/repos/<owner>/<repo>/git/refs/tags/v | 只打 tag 没发 release 时唯一可用的一条 |
| 3 | HEAD github.com/<owner>/<repo>/archive/refs/tags/v<下一个版本>.tar.gz | 免 API、不占限额的兜底 |
凭据:若环境里有 GITHUB_TOKEN / GH_TOKEN,只用它提高 GitHub 限流额度。
它的值不进配置文件、不进日志、不进任何接口回包 —— 界面只显示
「正在使用环境变量里的 GitHub 凭据」这一句,凭据本身永远不出现在界面上。
项目地址来自插件 package.json 的 repository 字段(可用配置 repository 覆盖,
只认 github.com 的 owner/repo)。设置页里它是只读的:
写路由因此没有「把请求导向任意地址」的入口。
# 下面命令里的 <DSH 安装目录> 与 <插件目录> 是占位符,替换成你自己的位置。
# (本仓库刻意不写真实安装路径:README 会随 npm 包发布出去。)
# 安装(二选一)
#
# ① 从 GitHub 装(推荐:锁定 tag,何时升级由你决定)
dsh plugin --profile desktop add "github:polan-prologue/dsh-experience-plus#v0.3.0"
#
# ② 从本地目录装(改插件源码时用这个)
dsh plugin --profile desktop add "link:<插件目录>"
#
# 卸载
dsh plugin --profile desktop remove dsh-experience-plus
#
# ⚠️ 装完**重启 DSH**:宿主半侧的改动(路由、配置字段)不参与热重载。
# 跑集成测试
#
# ⚠️ 这里刻意**不写条数**:以前写「N 项」,改一次测试就漂移一次(已经漂过两次)。
# 最后一行会打印实际通过/失败数,以那个为准。
#
# ⚠️ **必须用 DSH 桌面版的 exe 来跑**(下面这条命令就是)。原因:
# 有几条用例断言的行为**本身依赖运行环境**——是不是桌面版布局
# (`process.execPath` 指向桌面版 exe,因而 `resources/app.asar` 存在)。
# 用 plain `node tests/integration.test.mjs` 跑不算「失败」,但你会看到
# 另一些分支被走到(路由报「不是桌面版布局」、随包 preset 探测不到)。
# 那些分支**也有断言**(必须给出可读原因),所以两套环境都应当全绿;
# 只是要发布前验收时,请以本机这份桌面版命令的输出为准。
$env:ELECTRON_RUN_AS_NODE=1
& "<DSH 安装目录>\DeepSeek Harness.exe" tests/integration.test.mjs
# 跑会话日志瘦身算法测试
$env:ELECTRON_RUN_AS_NODE=1
& "<DSH 安装目录>\DeepSeek Harness.exe" tests/compaction.test.mjs
# 校验 preset 覆盖块(需要 DSH 桌面版:随包 preset 在 app.asar 里)
$env:ELECTRON_RUN_AS_NODE=1
& "<DSH 安装目录>\DeepSeek Harness.exe" tools/verify-preset-override.mjs
# 查看桌面补丁现状(只读)
& "<DSH 安装目录>\DeepSeek Harness.exe" --expose-internals tools/patch-desktop-main.mjs --check
# 手动应用 / 还原桌面补丁
tools\应用桌面补丁.cmd
tools\还原桌面补丁.cmd
desktopRestartPatch 的默认值是 off:
| 取值 | 插件启动时做什么 |
|---|---|
off(默认) | 只检测 + 报告:把补丁现状打进日志,绝不写盘 |
auto | 由用户显式选择后才在启动时维护补丁(没打过就补上,已打过就不动) |
为什么默认 off:这个补丁会改写 resources/app.asar 的 header——那是用户的应用本体。
契约 §二.4 / §六 要求「插件安装后不得静默改写用户的 profile / preset / asar」,
所以写盘必须由用户显式触发。想启用有四种方式(任选其一):
auto(写入本插件配置,属用户显式动作,
之后每次启动都会确保补丁在位);tools\应用桌面补丁.cmd;node tools/patch-desktop-main.mjs(可加 --app-root 指定安装目录)。无论哪种取值,写盘失败只记一条带步骤名的警告,绝不阻断 DSH 启动。
重启 DeepSeek Harness 后生效。
「重启」入口与补丁是同一件事:菜单里那一项由
app.asar内的主进程补丁提供, 补丁没打就没有入口。所以「默认不写盘」与「入口默认可见」天然互斥, 不存在中间态 —— 这就是为什么本插件提供一个一键的显式入口,而不是把默认值改成auto。
auto是契约 §二.4「不得静默修改用户环境」的显式例外: 例外成立的前提是「默认值是off」+「auto由用户主动选择」+「文档写明」。 三条缺一,这个选项就不该存在。契约文档docs/settings-platform.md§二.4 记着这条例外。
插件在「设置」里拥有一个分区「体验优化」(settings.section,id = experience-plus,
order = 20,排在「内置插件」的 15 之后),并为每个功能提供一张自包含卡片 + 一个独立开关。
experience-plus.item(契约,永不改名)分区本身只做一件事:renderSlot('experience-plus.item')。卡片内容完全自包含,
分区不感知任何卡片内部。任何插件/模块想加一张设置卡片,不改分区、不改宿主、不改已有卡片,
二选一即可(见下面的「两种接法」)。
registerFeatureCard —— 平台按字段渲染(推荐)// 经全局键取平台 API(本插件与卫星插件的客户端 bundle 无法互相 import):
const platform = window.__DSH_EXPERIENCE_PLUS__
if (platform?.apiVersion >= 1) {
platform.registerFeatureCard(ctx, {
id: 'my-plugin.card', // 全局唯一;兜底注册也用它
key: 'myFeature', // 本插件配置里的开关字段名(见下面「字段白名单」)
order: 60, // 卡片在体验优化分区里的位置
kind: 'toggle', // 控件形状:toggle / select / info
labelKey: 'settings.myFeature.title', // 标题(本插件字典里的键)
descriptionKey: 'settings.myFeature.description'
})
}
函数签名:registerFeatureCard(ctx, card, options?)。
card:{ id, key?, order?, kind?, labelKey?, descriptionKey?, requiresKernel?, hostCapability?, extra? };options:只有三个字段 —— slot、fallbackSlot、capabilities。
它们是给测试与特殊部署用的,日常调用请省略。⚠️ 卡片由平台渲染,不支持传自定义组件。 第三个参数是
options,不是 React 组件;传组件进去会被明确抛错(不是静默忽略), 错误信息会指向下面的「接法二」。这是刻意的设计取舍,理由见下一节。
平台按 labelKey / descriptionKey / kind 渲染卡片,并自己完成三件事:
订阅配置快照、调 .config 路由写盘、按可用性置灰。所以卡片天然满足
「不可用就置灰 + 写明缺什么」,不需要每个接入方自己实现一遍。
要自定义卡片长什么样(自带图表、按钮、整页详情……),不要走上面的助手, 按契约直接注入插槽即可:
ctx.effect(() => ctx.slots.inject('experience-plus.item', function* () {
yield ctx.slots.register({ name: 'experience-plus.item', id: 'my-plugin.card', order: 60, locale: NS }, MyCard)
}), 'my-plugin: settings card')
「网页搜索++」「推理等级++」就是这么接的。代价是自己负责渲染、置灰与写盘;
window.__DSH_EXPERIENCE_PLUS__.probeSlotDeclaration / availabilityOf 可以复用平台的判据。
registerFeatureCard 收组件两个理由,都是「不给自己留后门」:
.config 路由写盘,而那条路由只接受
lib/config.js 里 patchableFields 白名单内的字段。
平台渲染时字段名来自 card.key,白名单是唯一的守门人;
一旦允许任意组件直接写配置,白名单就被绕过了,
「开关看起来能用、实际没效果」会重新变成可能。所以需要完全自由的场景走「接法二」——那是显式的自定义, 而不是在助手背后塞一个组件、效果却由平台决定。
card.key 必须是本插件 lib/config.js 的 patchableFields 里的字段
(当前是 autoAnswer / autoAnswerMinutes / archivedBatchManage / scrollLoadEarlier /
instantDelete / desktopRestartPatch / enabled)。
写出白名单的字段会在写盘时被明确拒绝并在设置页显示原因——不会静默失败。
第三方插件想加自己的开关字段,需要同时改 patchableFields、schema 与归一化逻辑
(也就是改平台),而不是只加一张卡片。这是本版本的已知边界。
registerFeatureCard 把插槽选择、兜底、容错全封在里面:
| 情况 | 行为 |
|---|---|
experience-plus.item 已声明 | 注册进主插槽,状态 available |
主插槽未声明、plugins.item 已声明 | 退回 plugins.item,状态 fallback |
| 两个都还没声明 | 挂等声明控制器,状态 probe-failed(「无法确认」≠「不可用」) |
| 单个卡片注册抛错 | 只记日志,其余卡片照常(一个功能挂掉不连累别的) |
判据不是「回调未触发」:
ctx.slots.inject的回调不触发,只说明分区还没打开, 绝不能据此判定插槽不存在。声明状态一律正向查询——有ctx.slots.spec()就直接问它, 旧版内核没有spec就退化成「真的注册一次、看是否抛错,随即注销」。
这一页有两层内容,分开看才不会误解:
| 层 | 内容 | 来源 |
|---|---|---|
| 静态清单 | 「支持的扩展插件」:名称 / 一句话作用 / 项目链接 / 安装态 / 一键安装或卸载 | dsh-extensions.json(本仓库的声明文件) |
| 动态卡片 | 已安装扩展自己注册的设置卡片(如「网页搜索++」的优先级设置) | 运行期插槽注册 |
为什么必须有静态清单:动态卡片那一层「谁装进来谁才有」,所以只装体验增强++ 时 这一页原本是空的 —— 用户看不出这页是干什么的,也不知道有配套插件可装。 而客户端看不到「没装的插件」(它只能枚举已加载的),这份清单只能由平台静态声明。
安装态分三态,刻意不合并:
| 状态 | 含义 | 界面动作 |
|---|---|---|
| 已安装 | profile 的 dependencies 与 bundles 都有 | 卸载 |
| 已装未启用 | 依赖里有、bundles 里没有 → 不会加载 | 重新安装(并点名提示) |
| 未安装 | 依赖里没有 | 一键安装 |
「已装未启用」单独成一态,是因为把它显示成「已安装」最坑人: 用户在插件列表里找不到它,只会以为插件坏了。
一键安装 / 卸载执行的就是 dsh plugin --profile <你的 profile> add|remove <规格>,
与你在终端手敲的那条完全一样(不自己实现 pnpm 逻辑,避免内核升级后悄悄偏离)。
请求体只接受声明文件里的 id,不接受任意包名 —— 否则这就成了一个
「谁能让浏览器发一条 POST,谁就能往你 profile 里装任意包」的入口。
⚠️ 生效条件(如实说明,不粉饰):安装/卸载改的是 profile 的依赖与 bundle 层, 新增插件行需要重启 DSH 才会被加载 —— 这是宿主在启动时读取决定的, 插件侧做不到免重启。界面在这一页常驻写明这一点。
| 功能 | 配置字段 | 默认 | 说明 |
|---|---|---|---|
| 归档批量管理 | archivedBatchManage | true | 关闭后归档行不再出现复选框,右键入口会如实说明 |
| 滑动到顶部自动加载更早 | scrollLoadEarlier | true | 关闭后回到手点「加载更早」 |
| 删除即点即删 | instantDelete | true | 关闭后改为等宿主删完再关闭确认框(更慢,但每步都有回报) |
| 无人作答时自动选答案 | autoAnswer | false | 写入会替用户做决定,必须显式开启 |
| 桌面重启补丁 | desktopRestartPatch | off | 默认只检测不写盘;要写盘可点该行的「立即应用补丁」,或把取值显式改成 auto(见第三节「桌面「重启」补丁」) |
三个新开关的默认值都等于「加上开关之前的实际行为」,所以老用户升级后不写任何配置, 行为与升级前完全一致。只有主动去设置页关掉,行为才变。
「删除即点即删」是双向分派:开着走乐观路径(立刻收尾 + 后台请求,失败会回滚并提示),
关着走 confirmWaited(等宿主真正删完再关弹窗)。两条路都在,不存在「关掉就什么也不做」。
使用者会跑在不同版本的 DSH 上,而本插件依赖不少内核内部约定。
完整规格见 docs/settings-platform.md,这里是要点。
detectCapabilities(ctx),
客户端侧统一走 detectClientCapabilities(ctx) —— 升级后只需复查这两个函数。
宿主侧的依赖清单集中在 HOST_REQUIREMENTS 一张表里(新增功能只加一行),
客户端侧的依赖在 detectClientCapabilities 内逐个探测。
客户端侧的探测必须在 apply() 的最开头:早先它排在几处注册之后,
于是那份结论对它们毫无作用——旧内核没有 ctx.slots 时直接抛穿 apply(),
插件变成 FAILED。现在每一段注册都先查能力,缺哪项就跳过哪一项并记日志。try/catch,失败只打日志
(说明是哪个功能)与落诊断,绝不抛穿宿主。
客户端侧分两层:guardedEffect(能力前置检查 + ctx.effect 容错)
与 safeRegister(插槽注册容错)。两层缺一不可——
ctx.slots.inject 的回调在真实内核里是插槽声明之后才被调用的,
那时 apply() 早已返回,apply 的 try/catch 兜不住回调里的异常。tool-ask-user 的 timed 模式(要覆盖 preset-standard):只提供生成/校验工具,
安装后不动你的 profile(见第七节);app.asar):默认 desktopRestartPatch: off,
只检测不写盘;写盘的两条显式路径是「设置页点『立即应用补丁』」与
「显式把取值改成 auto」(见第三节「桌面「重启」补丁:默认不写盘」,
契约侧记在 docs/settings-platform.md §二.4)。
两处的共同点是默认不写盘,区别是第二处额外提供了一个一键的显式入口
(因为「重启」入口本身依赖补丁,不提供入口就等于功能不可达)。探测失败统一记一条可读日志,前缀是常量 CAPABILITY_LOG_PREFIX
(dsh-experience-plus: 能力缺失 -> <名字>)。用户反馈问题时搜这一个字符串就能定位。
宿主侧(lib/index.js)与客户端侧(lib/client.js)各有一份同名常量,
字面量逐字一致(客户端 bundle 无法 import 宿主模块),
并由测试「客户端降级」里的一条断言钉死两者不漂移。
| 功能 | 依赖 | 缺失时 |
|---|---|---|
| 管理路由 | connection.fetch.register | 全部 HTTP 入口不注册,记日志 |
| 归档批量管理 | workspaceController.archiveSession | 停用,卡片标注不可用 |
| 删除会话 | sessionController.list(退路 sessionPersistence.list) | 停用 |
| 在资源管理器中显示 | sessionController.openWorkspacePath | 停用 |
| 在项目里新建会话 | sessionController.create | 停用 |
| 恢复已归档 | workspaceRegistry.unarchiveSession | 停用 |
| 无人作答时自动选答案 | userQuestions.answer + agents.roots + sessionProjections.stateOf | 停用 |
| 设置卡片数据源 | settings.describe / settings.update | 只读展示,写开关时给出可读原因 |
| 设置分区 | settings.section 的 children 声明能力 | 退回 plugins.item(fallback 状态) |
客户端侧逐段注册,每段各自查能力 + 独立容错(见铁律 1、2)。
| 这一段注册 | 依赖 | 缺失时 |
|---|---|---|
| 样式注入 | 无(只依赖 document) | 跳过并记日志 |
| 字典与取词器 | ctx.locale.bind + ctx.locale.register | 退回本插件中文字典直查 |
| 会话菜单行 | ctx.slots + slots.inject + ctx.effect | 菜单行整段跳过 |
| 行首单元格(复选框/未读圆点) | 同上 | 跳过 |
| 归档行复选框(行尾动作区) | 同上 | 跳过 |
| 浮层(提示/确认框/批量条/门控观察者) | 同上 | 跳过 |
| 设置分区「体验优化」 | 同上,且注册可能被内核拒绝(旧版不接受 children) | 跳过分区并记日志,其余功能照常 |
| 滑动到顶部自动加载更早 | ctx.effect | 跳过 |
| 批量模式标记清理 / 行批量勾选 | ctx.effect | 跳过 |
| 会话行右键打开菜单 / 全局错误上报 | ctx.effect | 跳过 |
| 配置拉取 | ctx.effect | 跳过(功能退回默认值) |
优先复查(最脆的地方):
detectCapabilities(宿主,lib/index.js)与 detectClientCapabilities(客户端,lib/client.js);settings.section 的 children 声明形状与 experience-plus.item 子插槽是否仍被支持;sidebar.workspaces.session.menu.item、sidebar.session.row.leading
(仅当 !row.archived && !row.blank 时渲染)、sidebar.workspaces.session.row.action、shell.overlay;[class*="rowActions"],
绝不用完整哈希类名(如 hIlkoa_rowActions);session.v4.jsonl.zstd 的版本号与帧格式:zstdDecompressSync 只解第一帧,
对整文件调用会「成功」但只拿到一行 header,静默丢掉全部内容。
必须按 magic 28 B5 2F FD 扫描帧边界后逐帧解压。日志升到 v5 就必须重新适配
(tools/compact-session-logs.mjs 会探测文件名与格式,不匹配就停用并提示);tool-ask-user 的 preset 子插件行写法(顶层补丁会静默失效)。推荐的复查方式:跑两套测试 + 依赖缺失注入。 测试里已经内置了三组针对性关卡,内核契约一变就会直接变红:
apply() 不抛错、返回 undefined、日志写明缺什么;
其中「设置分区注册被拒」那一段刻意把 inject 回调推迟到 apply() 返回之后再跑,
因为真实内核就是这个时序——同步桩会让 apply 的 try/catch 顺手兜住,
于是 safeRegister 有没有都一样(这是本仓库实测到的假覆盖)。这三组关卡都是行为验证,不是文本匹配。本仓库在这上面栽过两次: 用「源码里出现过某个词」当判据的关卡,改坏实现照样全绿。 所以二选一形状的分派(如「删除即点即删」的
confirmInstant/confirmWaited) 是把源码里切出来的if/else真的执行一遍,看实际被调用的是哪一个分支。
DSH 的会话与项目(工作区)是这样绑定的:
sessionIds 账目 ∧ 会话 header 里的 cwd 经 realpath 后与工作区 path 相等;cwd 是创建时固化的只读字段,sessionPersistence 没有改写入口,sessionController 也没有 setCwd;workspaceController.insertSessionBefore 只重排已在账目内的会话,跨工作区会抛 workspace/move-invalid。所以本插件的这一项如实降级为:选一个目标项目 → 在那里新建一个会话(并自动打开)。 点开时对话框会明说这一点,不会假装移动成功。
不要尝试去重写 zstd 日志首行 header 的
cwd——那会破坏「仅追加、连续 seq」不变量, 并让投影缓存的身份比对失败。
内核的未读状态叫 completionUnread,由 api-session/status 单向推导,没有任何写入口。
因此未读标记由本插件自持(localStorage),并且:
sidebar.session.row.leading,而该槽位只在行的主状态为 idle 时挂载
—— 正在运行的会话、有待处理交互的会话、已归档的会话看不到这个点;内核没有任何删除入口(官方 README 明确写「无删除或保留接口」,且这是功能边界而非待办), 只能删文件。经评估后默认采用软删除:
sessionDeleteMode: 'trash'(默认)→ 会话目录移入 ~/.dsh/session-trash/<批次>/,可手动还原;sessionDeleteMode: 'purge' → 直接物理删除,不可恢复。两种模式都会清理 storages/session_projcache/sessions/<id>.json 与 workspace.json 里的登记。
绝不触碰 ~/.dsh/attachments/**——那是内容寻址、跨会话共享的。
删除前会拒绝三类会话:正在运行、仍有派生的子会话、找不到日志目录; 拿不到会话清单时一律拒绝(绝不盲删)。
因此确认框的文案与需求截图的「此操作不可恢复」有意不同——默认模式下它其实是可恢复的。 如果你要的就是物理删除,把配置改成
purge即可。
Config schema 必须是真的 schemastery。本机有过一次血泪事故:链接安装下解析不到
@deepseek-ai/schemastery,于是写了个手搓替身,结果 DSH 每次重启都要重置才能进——
因为宿主启动时会跑 settings.describe() → volatileForm() → plainSchema() → new z(schema.toJSON())。
lib/schema.js 里的多路径解析就是为这件事准备的,绝不要回退到自制 schema。
apply() 必须返回 undefined。cordis 会把返回值当 effect,返回对象会被判 Invalid effect
并让插件状态变成 FAILED。异步副作用一律 fire-and-forget。
路由用 ctx.connection.fetch.register,只认精确 pathname,所以客户端用点号分段
(/api/dsh-experience-plus.reveal)。用斜杠(…/reveal)会静默 404。
shell.overlay 的容器是 position:absolute; inset:0; pointer-events:none,
所以自己的根节点必须 position:fixed; inset:0 且显式 pointer-events:auto,
否则浮层会被点击穿透,表现为「弹不出来」。
读 asar 原始字节时 offset 是相对数据区起点的,必须加 dataOffset(漏加会读到无关字节且不报错)。
lib/desktop-patch.js 里还为此临时切换 process.noAsar——不能永久打开,宿主自己还要用 asar 加载模块。
批量替换必须复核目标函数自身(本插件最贵的一次事故)。
给 8 个行组件统一加「防崩护栏」时,一次 replace_all 把
const [, setMenuOpen] = useMenuOpenState() 换成了 const setMenuOpen = useMenuToggle(useMenuOpenState)——
连 useMenuToggle 自己的函数体也一起换了,于是它自己调自己:
function useMenuToggle(useMenuOpenState) {
if (typeof useMenuOpenState !== 'function') return () => {}
const setMenuOpen = useMenuToggle(useMenuOpenState) // ← 无限递归
return setMenuOpen
}
渲染期抛 RangeError: Maximum call stack size exceeded → 被行级错误边界静默吞掉 →
8 行一起消失、菜单只剩原生 4 项。而当时 21 项测试全绿,因为它们只检查「注册了没有」,
从不渲染组件。
现在的关卡:tests/integration.test.mjs 里有个迷你渲染器,会把每个已注册的行组件真的递归渲染一遍
(带深度上限,把无限递归变成可读失败)。这条关卡已用「注入旧 bug」验证过确实会红。
浏览器里的异常要主动送出浏览器。模型看不到浏览器 Console,所以本插件把启动自查、
行渲染异常、全局未捕获错误都 POST 给宿主,落盘到 <插件目录>/.runtime/client-diagnostics.jsonl。
上面那次事故就是靠这份诊断在 10 秒内定位的——没有它只能靠猜。
别猜宿主服务的字段名,能读到权威投影就别问宿主。
曾经照着直觉写 sessionController.list() 返回数组、SessionSummary.id,
真实契约却是 list(request, signal) → { items: [{ sessionId, … }] }
(而且 SessionSummary 根本没有 title)。后果是会话清单恒为空 →
批量管理空白、删除对话全部被误判成「找不到该会话」,而单测全绿(因为单测照着同样的错假设写的)。
现在的做法:列表一律用客户端权威投影(useSessions / useWorkspaces,它们带
displayTitle、cwd、sessionIds),宿主只保留「必须由宿主做的写操作」;
并且 .delete 的测试改成喂真实契约形状,作为这条回归的关卡。
删文件不会让宿主刷新它的会话清单——这是个容易踩的坑:
直接删日志目录,宿主不会重扫磁盘,那一行会一直留在工作区列表里,
看起来像「删除失败」,而诊断里 deleted=1, refused=[] 明明是成功的。
正确顺序是先归档、再删文件:归档是内核支持的写操作,会推送状态更新让列表即时刷新;
随后删磁盘上的日志,这条会话才彻底消失。另外 workspace.json 里的 archivedSessionIds
刻意不清——归档标记正是「别再显示它」的那个标志,清掉反而可能让它冒回来。
浮层要自己给被压住的滚动容器留位置。批量操作条是 fixed 元素,浮在会话列表之上;
不预留空间就会盖住最后几行(用户截图实测)。做法是从会话行向上找最近的可滚动祖先,
批量模式期间加 padding-bottom,退出时还原——比猜 CSS 类名稳,也不依赖具体内核版本。
桌面版没有「刷新页面」这个选项(网页版才有)。所以客户端改动靠 client-hmr 自动热重载,
宿主改动用插件管理器热重载;不要把「请刷新」写进交付说明。
排除 input 时要分清「录入」与「勾选」。整行点选处理器里我曾用
closest('input, …') 一律放行,结果点行首复选框时处理器直接 return,
事件继续冒泡去打开了会话——既没勾上、又跳走。正确写法是
input:not([type="checkbox"])。
lib/
index.js 宿主半侧:9 条管理路由 + 能力探测 + 启动时**检测**桌面补丁(默认只报告)
client.js 浏览器半侧:菜单行、浮层、设置分区、功能卡片、字典
config.js 配置字段的唯一事实来源(宿主与客户端共用)
schema.js Config schema(真 schemastery 多路径解析)
desktop-patch.js 桌面主进程补丁核心(CLI 与宿主共用同一份实现)
docs/
settings-platform.md 设置平台与跨版本兼容契约(改设计先改这里)
tools/
patch-desktop-main.mjs 桌面「重启」补丁 CLI:--check / --restore / --app-root
preset-paths.mjs 定位 profile 与随包 preset(**不硬编码任何本机路径**)
gen-preset-override.mjs 生成 preset 覆盖块:--apply / --profile / --source
verify-preset-override.mjs 与随包 preset 逐项对账:--profile / --source
compact-session-logs.mjs 会话日志瘦身(多帧 → 单帧):--apply / --restore
应用桌面补丁.cmd / 还原桌面补丁.cmd
tests/
integration.test.mjs 集成测试(不 mock 本插件自己的模块;条数以运行输出为准)
compaction.test.mjs 会话日志瘦身算法测试
preset-paths.mjs的自动探测顺序:--profile-file/DSH_PROFILE_FILE→--profile(在$DSH_HOME/profiles/下按名字找)→ 优先desktop。 随包 preset 依次尝试 profile 的node_modules、$DSH_HOME/node_modules, 最后直接从resources/app.asar里按条目读出(与包内路径无关,任何 DSH 桌面版都能用)。 三级都不通时列出已尝试的路径并报错退出——绝不写半成品配置。
路由清单(全部只认精确 pathname,自带内核的 Host/Origin 栅栏与 cookie 鉴权):
.diag / .reveal / .createInProject / .archive / .delete / .restore /
.desktopPatch / .capabilities / .config。
会话日志是多帧拼接的:每次增量写入都追加一个新的 zstd 帧,长期会