dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
DSH plugin: keep session projection values (titles) alive across a Host generation reset, so the session list never falls back to the untitled placeholder.
English | 简体中文
三个修复的集合(宿主半边 + 浏览器半边,不修改 DSH 安装目录里的任何文件):
prefers-reduced-motion: reduce)。症状:会话列表里的标题会短暂变成「未命名」(英文界面是 Untitled),随后自行恢复。
这是一个 DeepSeek Harness(DSH)插件,包含宿主半边与浏览器半边。
会话列表闪一下、标题短暂变成「未命名」,本质是连接被重建时投影(projection)被清空:
ConnectionController 重建 connection generation。SessionManager.handleConnected() 对每一个常驻投影 store 调用 store.clear()。ProjectionValueStore#clear() 的实现是删除每一行,于是标题丢失,回落到 t("session.untitled") = 「未命名」,直到 refreshList() 返回才恢复。本插件修的是第 2–3 步:投影清空的动作本身。第 1 步(为什么会重建连接)不在本插件范围内,见文末《重连从哪里来》。
关键代码锚点(已在 0.2.1-alpha.2 上核对):
@deepseek-ai/dsh-api-session-controller/lib/client.js:3566 — SessionManager.handleConnected() 里的 store.clear() 循环@deepseek-ai/dsh-api-session-controller/lib/client.js — ProjectionValueStore#clear()(删除每一行的实现)2026-10-15 更正:本节此前把「客户端主线程停顿 > 4 s ⇒ 宿主心跳判死连接」列为第 1–2 步的因果链。 该判断已被证伪,已删除。理由与证据见文末《重连从哪里来》——WebSocket 控制帧不在渲染器主线程上处理, 主线程卡顿不会触发心跳判死。为免误导,这里不再复述错误链条。
替换 ProjectionValueStore.prototype.clear,保留每一行的值,只丢弃序号水位:
function retainValues() {
for (const [key, row] of this.rows) {
if (row.kind === "sequenced") this.rows.set(key, { kind: "cached", value: row.value });
this.changed(key);
}
}
buildListSnapshot() 不再回落到「未命名」。refreshList() 送来的 cached 块可以照常覆盖。sequenced 行原地降级为 cached 行。为什么不能简单地改成空操作(no-op):
apply()会拒绝 seq 不更新的帧 (if (row?.kind === "sequenced" && seq <= row.seq) return;), 保留旧水位会把新一代连接的低 seq 帧永久挡住 —— 所以必须保留「重置水位」的语义。
同时它也会覆盖懒创建的 store(包装 manager.projectionStore),并在卸载时完整回滚。
在 DSH profile 目录里用 pnpm 添加本仓库:
dsh plugin --profile <你的 profile> add github:iuuuuuuuu/dsh-projection-persist
或者直接进 profile 目录手工装:
cd "$DSH_HOME/profiles/<你的 profile>"
pnpm add github:iuuuuuuuu/dsh-projection-persist
安装后把包名加进 profile 的 package.json → dsh.profile.bundles:
{
"dsh": {
"profile": {
"bundles": [
"dsh-projection-persist"
]
}
}
}
改 dsh.profile.bundles 会触发热重载,通常不需要重启。
把本仓库复制成 $DSH_HOME/profiles/<你的 profile>/node_modules/dsh-projection-persist/,
再把 "dsh-projection-persist" 追加进同一 profile package.json 的 dsh.profile.bundles。
插件生效时,宿主半边会在 <DSH_HOME>/logs/projection-persist.log 追加一行安装记录:
{"kind":"install","ok":true,"revision":9,"installed":"instance","wrapped":true,"swept":42,"prototypePatched":true,"stores":42,"framePatch":"patched","spinner":"installed","watch":"watching"}
也可以直接查诊断端点(GET 返回当前 revision):
curl http://127.0.0.1:3080/dsh-projection-persist
# {"ok":true,"name":"projection-persist","revision":9}
两种方式都是热生效,任选其一:
node_modules/dsh-projection-persist/cordis.patch.yml 里的 - insert: 行;dsh.profile.bundles 里移除 "dsh-projection-persist",或直接删掉整个包目录。插件卸载时会自动恢复原 clear() 与 manager.projectionStore。
sessions 服务,不声明任何 peerDependencies,因此不会被兼容性检查判为不兼容。@deepseek-ai/dsh-api-session-controller 0.2.0-rc.2。seqOf 有其它消费者)。SessionManager 的列表 Notifier 原本用 markDirty()(microtask 合帧)。控制帧是一条条独立的 socket task,
microtask 根本合不掉它们,于是 8–15 个控制帧 ⇒ 8–15 次 projectList() 全量重建 + 无条件 list.set()。
插件把列表 notifier 的 markDirty 换成上游预留但从未启用的 markFrameDirty()(rAF 合帧,last-write-wins 不变,
ensureFresh() 保证同步读仍然新鲜)。Session.notifier 不动 —— 它的同步契约由 beginSubmission/finishSubmission 依赖。
实测(交错 A/B × 3 轮,8 s/窗):
| 触发次数 | 整表重建 | list.set | 每次触发重建 | |
|---|---|---|---|---|
| 打补丁 | 176 | 14 | 34 | 0.080 |
| 未打补丁 | 288 | 145 | 165 | 0.503 |
约 6.3×(按观测帧率折算约 18/s → 约 1.8/s)。
在 WebView2 宿主里 matchMedia("(prefers-reduced-motion: reduce)") 恒为 true,
命中 StateDot.module.css 的 @media (prefers-reduced-motion: reduce) { .spinnerMotion, .spinnerArc { animation: none } },
于是会话正在跑的时候不转圈。插件注入一段自有 <style>,在同一个 media 块内用 !important 重声明关键帧
(选择器限定 svg[data-state="ongoing"] > g 与 circle[class*="spinnerArc"],不误伤另外 9 个合法的 reduced-motion 块),
并在 <head> 上挂一个 childList MutationObserver,元素一旦被谁删掉就立刻重装(实测 healedAtMs: 83)。
单独关掉转圈修复:localStorage["dsh-projection-persist.spinner"] = "off"。
本插件解决的是「重连已经发生时标题不丢」。至于重连为什么会发生,是独立的另一条链路。
宿主 dsh-api-gateway 确实有一个 WebSocket 心跳,但它不会被客户端主线程卡顿触发:
lib/index.js:172 MAX_MISSED_HEARTBEATS = 2(硬编码常量,非配置项)lib/index.js:236-253 每 heartbeatIntervalMs 递增 missed,只有收到 pong 才归零(:209-211),达到 2 即 socket.terminate()heartbeatIntervalMs = 2000 ms ⇒ 稳态容差 ~6 s(而非 4 s);typert-gateway 行的 websocketHeartbeatIntervalMs 可调但 ping/pong 这条控制帧通路不经过渲染器主线程:
third_party/blink/renderer/modules/websockets/websocket_channel_impl.cc 没有任何 ping/pong 处理,
它只通过 Mojo data pipe 收数据帧(ApplyBackpressure / RemoveBackpressure)。net/websockets/websocket_channel.cc:766-772(case kOpCodePing: → SendFrameInternal(... kOpCodePong ...)),
归属网络服务 services/network/websocket.cc。network.mojom.NetworkService 工具进程,与 renderer 进程分离;
卡死的 renderer 本身不持有 TCP 连接。⇒ 渲染器主线程即使在长同步任务里卡住,pong 仍照常应答,心跳判死不会由客户端卡顿触发。
调大 websocketHeartbeatIntervalMs 只是把「对端真死」的容差从 ~6 s 放宽到更大,属一般性健壮性余量。
两者互不替代:
MIT