packages/dsh-task-board
zhu1090093659/dsh-web侧边栏多列任务看板:卡片交给真实 DSH 智能体会话执行,支持 cron 定时(Host 侧到点执行,关浏览器也生效)。
用全窗口短片替换内核启动页,片尾交叉溶解进入界面;素材池、进入方式与淡入时长都能在「设置 → 插件」里调整。
这是发布副本。 里面只有运行插件需要的东西。作者开发副本里的自检套件
(tools/verify-*)与绑定了开发机路径的安装脚本不随包发出——下文提到它们的地方属于开发记录。安装请从 INSTALL.md 开始。
下文中出现的绝对路径都是作者机器的开发记录,不是给你的操作指令。
DSH 启动加载动画:短片片尾交叉溶解进界面、多片随机、启动进度可见、启动失败有兜底,淡入时长 / 进入方式 / 素材池都在「设置 → 插件」里可调。不改 DSH 本体源码,升级 DSH 不受影响。
文档 · 给谁 · 内容 MANUAL.md · 用它的人 · 设置在哪、怎么换片子、徽章什么意思、出问题怎么办 AGENTS.md · 接手维护的人 / AI · 架构、不许破坏的规则(每条都是踩出来的)、改动后要不要重启、怎么自检 本文件 · 想追根究底的人 · 每个坑的完整来龙去脉与证据
下面这份是全景记录,篇幅长;日常使用看 MANUAL 就够。
设置 → 插件 → 「插件配置」,里面会多出一行「启动动画」——和其他插件一样是收起的,点一下才展开:
项 · 作用 开启启动动画 · 总开关。关掉就是完全不注入——没有覆盖层、不发任何素材请求,回到 DSH 原生启动页 播放影片声音 · 默认开。关掉是彻底静音:startClip 不再走"先试带声、被拒再静音"那条路,直接以静音起播;声音按钮根本不生成,audioRetry 也不会被武装 点鼠标直接进入 · 默认关。打开后指针按下就是进入,不再拿第一下去解锁音频;enter() 里那次"顺手再试一次开声音"也一并跳过——那个开关的意思正是"这一下只负责进" 显示底部提示文字 · 默认开。关掉只在覆盖层根节点上写 data-hint="0",由一条 CSS 规则隐藏提示行;标题和进度条不动 淡入时长 · 1 / 1.5 / 2 / 3 / 4 秒,默认 2 秒 进入方式 · 片尾交叉溶解(默认)/ 放完再淡 / 点击才进 素材池 · 勾选哪些片子参与随机轮播(读的是设置本身,所以勾完立刻变);每行会实际播放一遍给出结论:时长(读到元数据就有),外加只在有问题时才出现的徽章——浏览器播不了(解码被拒)、一直没有画面(能开始播但 8 秒没出画面)、未优化(moov 在文件尾)
三个开关的取值都是 `!== false` / `=== true` 的宽松判断,不是直接取字段:老 profile 的设置文档里没有这三个键,宽松判断让它们落在文档化的默认值上(声音开、直接进入关、提示显示),和 Host 侧 readField() 的逐字段回退是同一套语义。
总开关不是"脚本自己判断一下然后什么都不做",而是 Host 侧直接不加那两行注入。这样关掉之后是真的关掉:没有覆盖层占着视口,没有配置全局变量,也没有一次素材请求。verify-settings.mjs 断言了这一点——关掉时注入行数是 0,打开时是 2。
默认收起是有意的:这一页是插件清单,"哪张卡片展开"是用户的阅读动作,由卡片自己持有;一张进来就把控件摊开的卡片会把下面所有插件挤出视野。折叠头的字号、圆角、边框、层级底色和箭头动画都是从 harness 自己的卡片样式(ui-settings-plugins/src/client/PluginCard.module.css)抄来的,token 名同样是 --dsw-alias-label-* / --dsw-alias-bg-layer-*,所以它在清单里看起来是原生的而不是外来的。
还有一处性能上的理由:素材池要读一次清单、再给每条起一个 `` 探时长。这两个副作用都挂在「已展开」上,所以收起状态下这张卡片一次请求都不发。
改完刷新页面生效,不用重启 DSH(改了 entry.js 的那次除外),也不用重新构建。
下面这段记的是 0.2.0-rc.2 之前的接法,已经失效——保留它是因为症状仍然会以同样的面目出现。当前事实见紧随其后的「适配 DSH 0.2.0-rc.2」。
它是怎么接进去的:DSH 的设置页把「插件配置」页渲染成两份账本的交集——Host 提供(settings.register)的设置命名空间,和浏览器注册进 settings.plugin.item 槽位的卡片。两张卡片的配对键就是命名空间字符串(boot-animation),所以两半各在一边、互不相识。这也是 DSH 给本仓库之外的插件预留的路径,ui-settings-plugins 的槽位注释写得很明白。
卡片这一半我没法在这里验证。 客户端 React 组件在真机 GUI 里长什么样、点下去有没有反应,只有你刷新看一眼才知道——沙箱里起不了浏览器。我能做到的是:把lib/client.js加载进桩 React + 桩 DOM 真渲染一遍,断言三个小节渲染出来了、当前值被标为选中、点「4 秒」真的把fadeMs: 4000写进设置。这能拦住"接线错了/渲染抛了",拦不住"样式在真主题下难看"。
DSH 0.2.0-rc.2 删掉了 Host 侧 `settings.register(ns, schema)`:现在命名空间不是"注册"出来的,而是由插件条目本身派生的。规则有三条,缺一条卡片就不出现:
Config(schemastery schema)。RegistryService.plugin() 把它记成 runtime.Config,@deepseek-ai/dsh-settings 的 describe() 只挑正在运行、且解析得到 `Config` 的条目。cordis.patch.yml 里的 - insert: id: boot-animation。浏览器半侧在 src/client.js 的 SETTINGS_NAMESPACE 里写同一个字符串,通过客户端的 configForms 服务(whileServed + get)接上——没有第二本账,也不再有 settings.plugin.item 这个槽位(现在是 plugins.item)。describe() 内部先做一次 volatileForm(schema) 投影,只保留 volatile 字段;一个 volatile 字段都没有时它返回 undefined,条目被整个跳过——describe() 连这条都不返回,whileServed 自然永不触发。写入侧同样:write()(update/replace/mutate 全走它)对非 volatile 路径直接抛 Config field "..." is not volatile。所以七个字段全部标了。.volatile() 还有一个必须知道的后座力:它在 `apply(ctx, config)` 里给的不是值,是一个稳定引用({ get() },用 Symbol.for('cosmokit.volatile.write') 跨 ESM/CJS 副本识别)。要 .get() 才是当前快照——dsh-bash-local 也是这么读的(config.timeoutMs.get())。这也是"改完刷新就生效"的机制:只有 volatile 值变化时,@deepseek-ai/cordis-plugin-loader 会把新快照原地提交进同一个引用并发出 loader/volatile-update,不重启 fiber,所以下一次 index 渲染读到的就是新值。entry.js 里对应的是 readField()。
还有一处没有变、但更致命的依赖:Config 必须是模块命名空间对象上的属性,所以 import z from '@deepseek-ai/schemastery' 变成了静态导入(老的惰性 import 永远声明不出 Config)。代价是失败模式变了——schema 包解析不到,整个模块都求值不了,路由和开机画面一起没了。因此 package.json 里它是 **peerDependencies: { "@deepseek-ai/schemastery": "*" }(由宿主提供,不再声明 `dependencies`),而盘上那份链接必须指向内核用的副本(3.18.4 或更新)——工作区检出旁边那份 3.18.1 的 `lib/index.mjs` 与 `lib/index.cjs` 里 `volatile` 出现 0 次**,直接调用会让模块求值即抛。entry.js 的 LIVE_CAPABLE 先探测该方法:有就标 volatile,没有就退化成普通 schema,并在 apply 里留一条说明该改哪个链接的告警——最坏情况是"卡片不出现 + 一条可诊断的告警",而不是插件全灭。
设置命名空间需要一个 schema,用的是 DSH 自己的 @deepseek-ai/schemastery。它在 entry.js 里是惰性 `import`,而 ESM 解析不认 `NODE_PATH`,只从"导入文件所在目录"逐级往上找 `node_modules`——而且会先把符号链接解析成真实路径。
本插件是通过 junction 装进 profile 的,真实路径是 D:\DSH\dsh-boot-animation\package,上一级根本没有 node_modules,于是 import 必然失败(表现是"设置里没有这张卡",但插件本身照常工作,因为 registration 失败只损失设置面)。两处修好了:
package.json 声明了 dependencies: { "@deepseek-ai/schemastery": "^3.18.4" }——正规 pnpm 安装时,pnpm 会在包的真实路径旁建 node_modules,就能解析。node_modules/@deepseek-ai/schemastery → profile 那副本的链接,所以现在就生效。tools/verify-settings.mjs 专门验这件事:它从 profile 的链接位置导入 `entry.js`(和真实加载一致),然后断言 schema 真的注册上了、默认值/上下界/枚举都对。如果你哪天重装依赖把这个链接弄没了,这个套件会从"18/18"变成报告 schema path: not exercised。
这两条现在都过时了,但结论反过来更要紧。entry.js改成了静态导入(Config必须是模块导出对象的属性,惰性导入永远声明不出来),package.json里它也从dependencies移到了peerDependencies: { "@deepseek-ai/schemastery": "*" }。盘上那个可解析的副本仍然必须存在——但必须是内核用的那份 3.18.4+;指到工作区检出旁边的 3.18.1,LIVE_CAPABLE探测失败,卡片不出现并留一条告警(详见上一节)。
ctx.get 抢跑第一次装完卡片没有出现,而且控制台一个字都没有——最坏的一类失败。根因是 ctx.get('slots') 在客户端运行时读不到服务,代码按「服务不可用」静默 return,于是「服务迟到」和「服务不存在」是同一个值,无从区分。
查法是把整个部署里所有客户端产物扫一遍,比对可用写法,结论很干脆:
项 · 结果 用 ctx.get('slots') + ctx.get('settingsScope') 的产物 · 本插件是全 profile 唯一一个 用 ctx.inject([...], cb) 的产物 · image-gen(4) / git-graph(2) / model-selection(2) / genui / better-sidebar / harness 自己的包——全部 回调里怎么读服务 · owner.get('sessions') 这种形式,不是属性访问
两个原因叠在一起:本插件没有声明服务依赖,可能在 `ui-settings` 应用到之前就 `apply`,一次性 get 就抢跑了;而 ctx.inject 会等。已改成:
ctx.inject(['slots', 'settingsScope'], (owner) => {
const slots = owner.get('slots') // 不是 ctx.get,也不是 owner.slots
...
})verify-client-bundle.mjs 现在有一条断言:剥掉注释后,代码里不许出现 `ctx.get(`,且必须走 ctx.inject;桩上下文也只提供 get、不提供属性,所以退回属性访问会在这里就红。(断言必须先剥注释——产物是源码原样拼接,模块头解释旧写法的那段话会被正则误抓,这个坑今天踩了两次。)
dsh web 每次加载页面时:
淡入不是"放完再淡",是从片尾往前 `FADE_MS` 开始淡。 15 秒的片子、2 秒淡入,就从 13 秒开始溶解,到 15 秒两者一起结束——而不是先播完、停在最后一帧、再淡 2 秒。这是交叉溶解:影片在最后两秒逐渐让位给界面。
触发不是"排一个定时器",而是每帧检查 `片长 − 当前进度 ≤ FADE_MS`:
requestAnimationFrame 和 timeupdate 上:前者帧级精确但后台标签页会被节流,后者粗糙但那时还有效。两个都不能少。片子完整放完、停在最后一帧,再淡出。想要"一点不截断"就用这个。
短片循环播放(所以没有片尾可等,ended 根本不触发),直到你点一下才进。第一下点击开声音、第二下进入——这是最早那版的两步设计,现在作为可选方式保留。
方式 · 片子循环 · 谁触发进入 · 淡入起点 片尾交叉溶解 · 否 · 片尾 + 内核就绪 · 片尾前 FADE_MS 放完再淡 · 否 · 片子结束 + 内核就绪 · 片子结束后 点击才进 · 是 · 只能是你点 · 点击当下
淡入就是界面显形的过程,所以它只能在界面真能显示时才启动——启动慢时短片继续放,就绪那一刻立刻从当前位置开始淡。没有这个条件,片尾一到就会溶解出一个还没搭好的界面。例外是「点击才进」:它连内核就绪都不自动放行,因为那本来就是你说了算。
情况 · 为什么 片子短于 2 × FADE_MS(默认 4 秒) · 溶解得从片子还没出现时就开始,那是坏效果 时长未知(MediaRecorder WebM,duration 是 Infinity) · 没有片尾可以倒推;走 20 秒兜底计时器,不会永久卡住 内核在片尾窗口之后才就绪 · 只能从那一帧起淡
这张表是 2026-09-28 那次连续出问题之后补的:这个屏幕是装饰品,它没有任何一种失败可以合理地扣住用户。每一条都由 verify-enter-sequence.mjs 用假时钟真跑一遍。
卡在哪 · 谁来放开 素材清单请求挂住不回应 · 10 秒后当作「没有素材」,就绪即进 素材池是空的 · 同上,立刻放行 某段片子开始播了但一帧都出不来 · 6 秒后换下一段;全部失败则就绪即进 片子放完但内核还没就绪 · 停在最后一帧等 clientReady,就绪立刻进 片子时长未知(duration 是 Infinity) · 20 秒兜底 内核启动失败 · 提示行变琥珀色「启动失败,点击查看」 内核一直不就绪 · 15 秒提示「启动较慢,点击继续」(可点) 以上全部失效 · 按 Esc / 空格 / 回车 随时直接进
verify-exit-simple.mjs 断言 maybeEnter 的函数体里没有 `duration`、没有 `setTimeout`、没有毫秒阈值——哪天有人加一个"最多等 10 秒"之类的判断,8 秒和 15 秒的两段片子立刻就不同命了。verify-enter-sequence.mjs 则用桩 DOM 真跑时序,三种进入方式各有用例:离片尾 2.1 秒不淡、进 2 秒窗口才淡、用的是剩余时间而不是配置值;「放完再淡」进窗口也不提前淡;「点击才进」片子循环、内核就绪和片子放完都不进、按一下才进。还有一条兜底:全部素材取消勾选时忽略清单照常播——否则池子空了就永远到不了交接。
代价:进入前的等待等于片长。 抽到 15.10s 那段,不点的话就要看满 15 秒。想早点进随时点一下或按 Esc。
只有这一种。深海底、短片、底部进度条作为一个整体一起走,没有分屏、没有故障效果、没有配置项。
早期版本做过 random / fade / split-v / split-h / glitch 五种可选过渡(transition 配置键),已经全部删掉,只保留溶解:
tools/transition-sampler.mjs 里(本地样本页,端口 8880),要回看或提取随时可以,但它不在启动路径上。淡出的两条硬要求,分别由 verify-exit-simple.mjs(①)与 verify-audio-affordance.mjs(②)守住:
FADE_MS + 100)时整块节点从 DOM 移除,没有任何东西留到淡出之后。span(片子的剩余时间),声音和画面、和片子本身在同一帧结束。 上,斜坡=span + AUDIO_GRACE_MS(1300 ms)。那 1.3 秒本来就是这个元素一直享有的宽限——**过去是满音量放完再一刀切,现在改成在这 1.3 秒里淡完**,停止时刻(span + 1300`)与旧实现完全一致。摘走短片这一步仍然要做,但理由变了:不摘的话覆盖层连同里面的元素一起被移除,斜坡会被当场砍断——那正是这条淡出要消灭的硬切。尾部溶解那条路径不摘(摘了就没有交叉溶解了),它的暂停落在 span + 150。
这里早先的硬要求正好相反:「声音不能跟着淡出被掐掉」。那时摘走短片是为了让声音比画面多活 1.3 秒,理由是提前跳过时听见的那一拍不会断在转场里。那 1.3 秒本身是对的——错的是它满音量度过、末尾再一刀切掉。现在同样多活 1.3 秒,只是这段时间用来淡出:总斜坡=span + 1300,正好在旧实现的停止时刻降到 0。曲线用线性增益。画面那条opacity走的是 CSS 的ease,两者形状并不逐帧一致——要对齐的量是时长(这次复用的就是这一个量),而线性的电平轨迹也只由它决定;照抄缓动或换成等功率,等于给"什么时候降到一半"再塞一个看不见的参数,对 1–2 秒的淡出到静音没有可听收益。
帧循环负责平滑,另有一条与斜坡等长的兜底定时器负责保证归零——后台标签页会停掉 rAF,退场的定时器却照跑。
已经播完的片段不摘也不重播,也没有音轨可淡:video.ended时整段斜坡直接跳过。audioRetry()里那句play()对播完的元素等于回到 0 秒重放,自动交接恰好总是发生在片尾,不挡住它就会在淡出底下把整段片子重演一遍。
地址加 ?dshbootdiag=1,诊断行会显示这次的实际播放、音频与交接状态(done= 片子是否放完、boot= 内核是否就绪、fade= 淡出时长)。
Chromium 不允许没有用户手势的带声音自动播放(play() 返回 NotAllowedError)。短片以静音开始,声音靠一次点击解锁——但进入不再需要点击,因为片子放完自己会进:
操作 · 发生什么 点一下画面 · 开声音。这次点击是音频策略要的手势,画面留着继续演 声音已开时再点一下 · 提前进入(跳过剩下部分) 什么都不点 · 片尾 2 秒自动开始溶解,片子放完时正好进界面 按 Esc / 空格 / 回车 · 直接进入(不给键盘用户加多余一步)
提示行会跟着状态走:静音时是「点一下开声音 · 播完自动进入」,开声后变成「播完自动进入 · 点一下提前进」,不用猜为什么点了没反应。
为什么不把"开声音"和"进入"绑成一次点击:手势解锁声音需要时间起效,而"进入"会关掉画面——绑在一起就是"刚出声就进界面"。所以开声音那一下故意不进入,让片子继续放;进入交给片尾。提前跳过(而不是尾部溶解)时,短片会被摘到 body 上继续放:画面在退场的后半段淡出,音轨则从退场一开始就淡到 0,总时长是「这次淡出时长 + 1.3 秒」(那 1.3 秒是它一直享有的宽限,现在用来淡出),所以跳过时那半句音乐是收掉的,不会满音量压在界面上再被硬切。
「摘走短片」这一步在尾部溶解里故意不做:溶解要的就是影片和覆盖层一起变淡,把短片摘到 body 上会变成一块不透明的视频压在界面上,交叉溶解就没了。两条路径的区别正是这里。想完全免点击:多点几次第一次(积累媒体参与度 MEI),Chrome 记住这个来源后就会自动带声;或在 Chrome 地址栏左侧 → 网站设置 → 声音 → 改成 「允许」(注意 「自动」≠「允许」,前者只是交给 Chrome 判断)。