🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

网页版模型桥接 cv-superding/dsh-webin

DeepSeek Harness
cv-superding18 天前有提交已提供中文文档扫描:含敏感能力

非官方 DSH 插件 —— 用真实浏览器驱动多个 AI 网页版DeepSeek / 通义千问 / …。

40.3/100
综合分
10
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/17 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

自动兼容检查有告警:未发布到 npm registry,仅可从源码安装。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add cv-superding/dsh-webin
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:已验证
是什么
dsh 原生插件 · browser
装得上吗
本站已在 dsh 0.2.1-rc.2 下真实安装成功
非静态推断
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 18 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安装兼容性检查⚠ 装前注意

未发布到 npm registry,仅可从源码安装

展开查看检查明细 ↓

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

✗npm 包dsh-webin(未发布到 npm,仅可源码安装)
✓Node 引擎要求 ^22.18.0 || >=24.11.0 · 基线 Node 22.19 满足
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明

验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/11/3 09:19:07

安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件发起外部网络请求
查看扫描证据(共 47 处,展示前 21 条)
  • 读取本地文件
    lib/index.js:2617
    if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  • 写入本地文件
    lib/index.js:2617
    if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  • 写入本地文件
    lib/index.js:2618
    writeFileSync(cookieFile(stateRoot, archive.platform), JSON.stringify(archive), "utf8");
  • 读取本地文件
    lib/index.js:2623
    if (!existsSync(f)) return void 0;
  • 读取本地文件
    lib/index.js:2625
    const raw = readFileSync(f, "utf8");
  • 发起网络请求
    lib/index.js:2724
    const res = await fetch(url, {
  • 读取本地文件
    lib/index.js:2939
    if (!existsSync(path)) return false;
  • 读取本地文件
    lib/index.js:2940
    return snapshotTextLooksLoggedIn(readFileSync(path, "utf8"));
  • 读取本地文件
    lib/index.js:2972
    if (!existsSync(dir)) return {
  • 写入本地文件
    lib/index.js:3008
    await mkdir(dir, { recursive: true });
  • 删除本地文件
    lib/index.js:3017
    await unlink(path);
  • 写入本地文件
    lib/index.js:3081
    await mkdir(stateRoot(), { recursive: true });
  • 写入本地文件
    lib/index.js:3084
    await writeFile(tmp, JSON.stringify(config, null, 2), "utf8");
  • 写入本地文件
    lib/index.js:3085
    await rename(tmp, path);
  • 删除本地文件
    lib/index.js:3884
    if (existsSync(path)) await unlink(path);
  • 发起网络请求
    lib/client.js:916
    return (await fetch(`${API_PREFIX}/auth/${slot}/snapshot`, {
  • 发起网络请求
    lib/client.js:928
    const res = await fetch(`${API_PREFIX}/auth/${slot}/snapshot`, { method: "GET" });
  • 发起网络请求
    lib/client.js:4266
    const data = await (await fetch("/dsh-webin/api/config")).json();
  • 发起网络请求
    lib/client.js:4422
    fetch(input, init).then((res) => {
  • 发起网络请求
    lib/client.js:5886
    fetch("/dsh-webin/api/client/heartbeat", {
  • 发起网络请求
    lib/client.js:7634
    const data = await (await fetch(`${LDW_API}/providers`)).json();
扫描于 2026/11/4(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 真实浏览器登录
  • 驱动网页版AI对话
  • 监听SSE抓取回复

非官方 DSH 插件:用 DSH 自带的 Chromium webview guest 在真实浏览器里驱动 AI 网页版(DeepSeek/Qwen/……)——真实登录、真实打字、真实发送,靠监听页面自身的 SSE 抓取回复。支持工具调用。

README

由 DeepSeek 最新模型翻译生成
dsh-webin 非官方 DSH 插件 —— 用真实浏览器驱动多个 AI 网页版(DeepSeek / 通义千问 / …)。 核心思路是不设计协议,操作界面:打开真浏览器 → 用真实输入把字打进输入框 → 页面自己发请求 → 从页面 DOM 把回复读出来 → 工具结果当普通消息打回聊天框。 没有任何一行在「调 API」 —— 没有 create_pow_challenge、没有 x-ds-pow-response、没有 x-hif- 回放。这是不封号的原因。 为什么读 DOM 而不是拦 SSE 插件同时有两条读回复的路,但主路径是 DOM: | | 怎么读 | 什么时候能用 | | DOM(主) | 从页面渲染出来的回复容器里读,并把 还原成围栏代码块 | 随时(内容在页面上,不依赖任何注入时机) | | SSE(辅) | 包一层 window.fetch / XMLHttpRequest 观察页面自己的流 | 只在请求发出之前就装上 hook 才行 | SSE 那条路有个绕不过去的时序问题:要在页面脚本执行之前注入 hook,唯一的办法是 preload。而 DSH 的 webview 门禁会把 webPreferences 清空后只设固定几项(lib/main.js),preload 不在里面 ——所以我们只能等 dom-ready,那时页面请求早就发出去了。
展开完整文档(README 剩余 1 节)
dsh-webin

非官方 DSH 插件 —— 用真实浏览器驱动多个 AI 网页版(DeepSeek / 通义千问 / …)。

核心思路是不设计协议,操作界面:打开真浏览器 → 用真实输入把字打进输入框 → 页面自己发请求 → 从页面 DOM 把回复读出来 → 工具结果当普通消息打回聊天框。

没有任何一行在「调 API」 —— 没有 create_pow_challenge、没有 x-ds-pow-response、没有 x-hif- 回放。这是不封号的原因。

为什么读 DOM 而不是拦 SSE

插件同时有两条读回复的路,但主路径是 DOM:

| | 怎么读 | 什么时候能用 |
| DOM(主) | 从页面渲染出来的回复容器里读,并把 还原成围栏代码块 | 随时(内容在页面上,不依赖任何注入时机) |
| SSE(辅) | 包一层 window.fetch / XMLHttpRequest 观察页面自己的流 | 只在请求发出之前就装上 hook 才行 |

SSE 那条路有个绕不过去的时序问题:要在页面脚本执行之前注入 hook,唯一的办法是 preload。而 DSH 的 webview 门禁会把 webPreferences 清空后只设固定几项(lib/main.js),preload 不在里面 ——所以我们只能等 dom-ready,那时页面请求早就发出去了。

后果就是「页面明明有回复,插件却收不到」。所以 DOM 成为主路径,网络拦截只是辅助信号。

选择器用的是实测出来的真实类名(ds-markdown / ds-think-content / ds-pow-response),后面才是语义兜底 —— 不靠猜。

各版本改了什么见 CHANGELOG.md。

前置条件:宿主必须提供浏览器 guest 桥

⚠️ 本插件需要一个受控的真 Chromium,而这个能力只有带 Electron 通道的宿主才有。

它取桥的方式是:

const carrier = globalThis.dshDesktop // src/client/index.ts:922
if (carrier?.protocolVersion !== 1) return undefined
const bridge = carrier.browser // acquire / release

| 宿主 | 有桥? | 说明 |
| 官方 DSH Desktop(Electron 应用) | ✅ | 设计目标环境 |
| DSH-X 启动器 + 浏览器里的 Web GUI | ❌ | 浏览器无法注入 Electron preload |
| 第三方 Electron 壳(未补桥) | ❌ | 默认没有桥 |
| 自建 Electron 壳(已补 webviewTag + preload 注入) | ✅ | 参见下方“没有官方 Desktop 时” |

在无桥宿主上的行为:插件仍然能安装并加载,模型选择器里也会出现 LDW,但发任何消息都会失败。现在它会明确告诉你原因(而不是误导成“标签没打开”),并在 /status 的 hostCapability 字段里报 no-desktop-bridge。

一条命令确认当前宿主有没有桥(在 DSH 页面按 F12)有桥 -> { protocolVersion: 1, browser: {...} } 无桥 -> undefined globalThis.dshDesktop

安装

从本地目录装进 DSH 的 desktop profile
dsh plugin --profile desktop add link:F:/Code/DSH-Code/dsh-webin/dsh-webin

或从 npm(若已发布)
dsh plugin --profile desktop add dsh-webin

--profile 要填你实际在用的那个。上面写 desktop 是因为官方 DSH Desktop 用的就是这个保留名;用 dsh web 的人则是 web。
装错 profile 的典型症状就是“插件看不见”。

装完重启 DSH(host 半没有热重载)。然后在对话区会出现 LDW 标签,模型选择器里出现「LDW · DeepSeek 网页版」。

首次使用:切到 LDW 标签 → 登录一次 → 回到对话选 LDW 模型。

没有官方 Desktop 时

两条路:

1. 装官方 DSH Desktop —— 它天生提供桥。
2. 给一个 Electron 壳补桥 —— 只需两步:

// 壳的 BrowserWindow:开 webviewTag + 注入 preload
webPreferences: { contextIsolation: true, nodeIntegration: false, webviewTag: true, preload: PRELOAD }

preload 向页面暴露 globalThis.dshDesktop = { protocolVersion: 1, browser: { acquire, release, onOpenRequested } },并在主进程用 session.on('will-attach-webview') 做租约门禁。

参考实现:官方 apps/desktop-host,或一份等价的本地实现(D:\deepseekwork\dsh-desktop-shell)。

用持久分区(persist: 前缀)而非内存分区,还能顺带解决下方 L1(重启免登录)。

设置页

侧栏 → 设置 → 「LDW 网页版」。

平台:

- 看每个平台的地址 / 模型 / 上下文窗口 / 登录状态
- 添加自定义平台:贴一个 Provider JSON 即可,不用改代码、不用重启
- 删除自定义平台(内置项删不掉,要改就用同 id 覆盖) 插件配置(11 项,改完立刻生效,不用重启):

| 分组 | 项 | 默认 | 说明 |
| 防封号 | 发送间隔 | 2~4 秒 | 网页版对请求密度敏感 |
| | 每 N 次请求后长休 | 关 | 工具循环连跑十几轮时压低密度 |
| | 单轮请求上限 | 不限 | 防止意外烧配额 |
| 行为 | 等回复超时 | 10 分钟 | |
| | 浏览器未打开时快速失败 | 20 秒 | 不让你干等满 10 分钟 |
| | 工具结果截断 | 4000 字符 | 太大顶爆输入框,太小模型看不到 |
| | 允许调用工具 | 开 | 关掉 = 纯聊天 |
| | 登录态快照间隔 | 30 秒 | |
| 诊断 | 详细日志 | 关 | 出问题时打开 |

每个数值都有边界。填超界会回退默认并告诉你哪项被修正 —— 不会静默生效,也不会把「发送间隔填 0」(= 秒发 = 必封)这种值存进去。

登录状态显示「浏览器未打开」是正常的 —— 切到 LDW 标签就会变「已登录,浏览器在线」。
插件只在浏览器真的活着时才知道登录态,不猜。

状态

| 能力 | 状态 |
| 真实浏览器驱动(DSH 自带 Chromium guest) | ✅ |
| 真实登录 + 登录态持久化(重启免登录) | ✅ |
| 真实发送(填框 + 点发送键 / 合成 Enter) | ✅ |
| 从页面 DOM 读回复(主路径,含思考) | ✅ |
| 还原 pre 代码块围栏(工具调用靠它) | ✅ |
| 观察页面自己的 SSE 取回复(辅助路径) | ✅ |
| 工具调用(JS 代码块协议) | ✅ |
| 多平台(DeepSeek / 通义千问 / 自定义) | ✅ |
| 每个 DSH 会话绑定独立网页对话(重启后接回) | ✅ |
| 差分发送(不重发历史) | ✅ |
| 设置页(平台 / 登录态 / 11 项配置 / 增删自定义) | ✅ |
| 离线测试 | 865 项 |

多平台架构

一个平台 = 一个 Provider 对象

平台差异(选择器、评分规则、SSE 路径、模型表、上下文窗口)全部收敛到一个 Provider(src/shared/providers.ts),驱动层只认接口。新增站点 = 加一个对象。

const MY_PLATFORM: Provider = {
id: 'my-platform', // ← 同时是 workspace 名,决定登录态隔离 name: 'LDW · 我的平台', homeUrl: 'https://example.com/', urlPattern: 'example\\.com',
input: { selectors: ['textarea[placeholder="输入"]', 'textarea'] },
send: {
selectors: ['button[type="submit"]', '[role="button"][aria-label="发送"]'],
signatures: [['send-button', 60]], // 站点特征加分
vetoes: ['toggle', '停止'], // 命中即否决
},
completionPath: '/api/v1/chat/completions',
models: [{ id: 'fast', name: '快速' }],
contextWindow: 131_072,
maxOutputTokens: 8_192,
}

登录态隔离靠 workspace,不改 DSH 一行

DesktopBrowserGuests.acquire(owner, workspace) 按 workspace 字符串缓存 partition
(main.js:9636-9641):

let partition = this.partitions.get(workspace);
if (partition === void 0) {
partition = dsh-sidebar-browser-${randomUUID()};
this.partitions.set(workspace, partition);
}

所以用 provider.id 当 workspace 名,各平台自然拿到不同 partition、不同 cookie / localStorage ⇒ 登录态天然隔离,零侵入。

三个层次的可扩展性

| 层次 | 做法 | 适用 |
| 内置表 | 在 BUILTIN_PROVIDERS 加一项 | 贡献新平台 |
| API 装载 | POST /dsh-webin/api/providers 贴一个 Provider JSON | 站点改版后修选择器,不改代码 |
| 磁盘持久化 | ~/.dsh/webin/providers/<id>.json | 跨重启存活,与登录态同一套存储 |

自定义 provider 覆盖同 id 的内置项(改坏了删掉即可回退),
且加载时逐个校验(validateProvider())—— 一条坏的不影响其它。

运行时校验

TS 类型运行时被擦除,"必须实现接口"靠 validateProvider() 真正强制。
校验覆盖:id 格式(会用作 workspace 名)、 URL 合法性、正则可编译、models 非空、contextWindow 为正数…

加一个平台(三步)

1. 先探测真实 DOM —— 不要猜选择器:

npm run probe:dom # dump 输入框/按钮结构 + 用当前选择器匹配

2. 照着 provider.example.json 填一份 JSON,然后:

$h = @{ Authorization = "Bearer <secret>" } # 见 ~/.dsh/.credentials.yaml $body = Get-Content provider.example.json -Raw Invoke-WebRequest "http://127.0.0.1:19387/dsh-webin/api/providers" -Method Post powershell -Headers $h -ContentType "application/json" -Body $body

装好后立刻生效(host 会热重载路由),新平台出现在模型选择器里,文件落在 ~/.dsh/webin/providers/<id>.json,跨重启存活。

3. 在 LDW 面板顶部的下拉里选中它,登录一次即可(登录态独立保存)。

移除: powershell Invoke-WebRequest "http://127.0.0.1:19387/dsh-webin/api/providers/my-platform" -Method Delete -Headers $h

查看装载情况(含校验失败原因): powershell Invoke-WebRequest "http://127.0.0.1:19387/dsh-webin/api/providers" -Headers $h

⚠️ 内置的 qwen 选择器尚未实测,第一次用前请跑 npm run probe:dom 校准。
未校准的 provider 不会静默失败:发送脚本找不到目标时会带诊断清单报错。

「只回一个字」的根因:裸文本帧没被处理

现象

网页版里回复是完整长文,DSH 对话里只有开头一个字。用户实测报障两次。

定位过程(npm run probe:frames)

先发现页面用 XHR 而非 fetch 发 completion(POST [XHR] /api/v0/chat/completion),于是把探测脚本改成拦 XHR 逐帧记录,拿到真实帧序列:

[0] {"request_message_id":1,...} ← 元信息
[2] {"v":{"response":{...fragments:[{content:"1"}]}}} ← 初始快照
[3] {"p":"response/fragments/-1/content","o":"APPEND","v":"."} ← 带路径增量
[4] {"v":" HTTP"} ← ⚠️ 裸文本帧:没有 p、没有 o
[5] {"v":"("} ← ⚠️ 正文主体全是这种,占 95% 以上
...
[131]{"p":"response/status","o":"SET","v":"FINISHED"}

正文的绝大部分是裸字符串帧(v 直接是要追加的文本,无路径)。
提取器只认「带 p 且路径匹配」的帧 ⇒ 每个回复只拿到快照里的第一个字。

早期测试没暴露是因为 8+8=? 的答案 16 恰好完整落在快照帧里。

修法

isBareTextDelta():无 p、o 缺省或 APPEND、v 为非空字符串 ⇒ text += v。
显式排除元信息帧(有 request_message_id/updated_at 的走不进这个分支)和错误信封(有 code/data)。o: 'SET' 的裸值帧不按增量处理。

回归:npm run verify:extract(13 项)把实测帧序列灌进产物里的真实提取器。

会话路由:每个 DSH 会话绑定自己的网页对话

缺陷(用户实测发现,封号级)

换了几个 DSH 窗口聊天,网页版里全落在同一个会话窗口 ——上下文互相串、多个话题混在一条流里,行为模式像机器人,不被封才怪。

语义

| 场景 | 行为 |
| DSH 会话首条 | 在当前页发,落地 URL 由结果带回 host 存进游标 |
| 同一会话后续任务 | job 带 expectUrl(= 上次落地页),URL 相同则不导航(零开销) |
| 切换 DSH 会话 | job 带各自的 expectUrl,client 先导航过去再发 |
| 老 client / 抓 URL 失败 | 不绑定,行为不退化(当前页照发) |

数据流

adapter.stream(sessionId)
└─ cursors[sessionId].chatUrl → job.expectUrl
↓ client
页面 URL ≠ expectUrl → loadURL(expectUrl) → 等输入框 → 发送 ↓ 结果 done → PUT result { text, chatUrl: location.href } ↓ host
cursors[sessionId].chatUrl = result.chatUrl

⚠️ 存 chatUrl 必须在 diff.commit() 之后 —— commit 会整体重写游标,放前面会被它用旧值覆盖(verify-session-route 的 ⭐ 断言就是抓这个的)。

实测

npm run verify:session-e2e:两次发送分别落在两个不同的对话 URL
…/s/8530a042… 和 …/s/92020348…)。

npm run verify:session-route(14 项):两会话互不串、切回仍回原对话、
无 chatUrl 时不退化。

每次重启新建对话 → 网页版侧栏堆一排会话

现象

用户侧栏出现「从1数到800」×3、「8加8等于16」×2 —— 每次重启/重挂载都新建对话。

(其中一部分是探测脚本直接用真实账号发的 —— 已知污染,抱歉。)

根因

boot 阶段无条件 loadURL(homeUrl)。首页被页面当成「开新对话」。
更严重的连带后果:host 的差分游标记着「这会话发过 N 条」,而页面里是空的新对话 ⇒ 下一轮只发增量,模型完全没有上下文。

修法

快照里存了 url(上次离开时所在页面)。boot 时优先导航到它,并做平台域校验(urlPattern 匹配 hostname+pathname 才用,防串平台)。
非法/无快照时才退回 homeUrl。

差分发送:为什么网页端聊天记录不该出现超长提示词

问题

第一版实现里,每发一条消息,网页端聊天记录里都堆着一条超长提示词。

根因是位置搞错了:DSH 是标准 LLM 宿主,每一轮都把完整历史(system + 全部 messages)交给 adapter,由 adapter 自己决定怎么变成服务商的请求体。对 HTTP 服务商这没问题 —— 每次请求本来就该带全量上下文。

但网页版不是无状态 API:页面自己维护着对话历史。把整段历史每轮都打进输入框有两个后果:

1. 网页端聊天记录里堆满重复的超长提示词(用户一眼就能看到);
2. 上下文被重复计数 —— 模型看到的是「历史 + 又一份历史」,有效上下文被砍半,还容易被判成刷屏。

为什么 harness 类工具没这问题

因为它们不是 LLM adapter,而是 harness —— 自己驱动 AI 页面,系统提示词只在进入项目时发一次:

| | harness 类工具 | 本插件(第一版) |
| 架构位置 | Electron 外壳,自己驱动 AI 页面 | DSH 的 llm adapter |
| 系统提示词 | 初始化时只发一次 | 每轮都随历史重发 |
| 每轮发送 | 只发新消息 | flatten 全量历史 |
| 页面角色 | 就是记忆体 | 被当成无状态 API |

本插件的做法

本插件处在 adapter 位置(DSH 要求这样),所以自己补上这层差分 —— src/host/adapter.ts 的 diffPrompt():

| 规则 | 原因 |
| 首轮发 system + 全部消息 | 页面需要一次拿到完整设定 |
| 后续轮只发新增消息 | 页面已有历史,不重复 |
| 跳过页面自己产生的 assistant 回复 | 那是页面吐出来的,重发会让记录里出现重复内容 |
| 历史变短(压缩/回退)→ 从头发 | 否则页面缺上下文 |
| system 指纹变了(换 agent/preset)→ 重发 | 新设定必须传达 |
| 增量为空 → 直接返回 stop,不发 | 纯重复没有发送意义 |
| 用 sessionId 做键 | 多会话并行不互相污染 |

实测各轮长度(verify-diff.mjs 第 3 组断言):

首轮 227 字符(含系统提示词)后续 33, 33, 33, 33 字符(只有增量)

回归保护:npm run verify:diff(30 项)锁死上述全部规则,包括「第二轮不含旧回复」「连续多轮每轮都跳过自己的回复」这类容易写错的地方。

投递失败不得推进游标

diffPrompt() 是纯函数,游标推进靠返回的 commit(),调用方只在投递成功后调。

早期版本在算增量的同时就推进游标,于是发送失败/超时后:DSH 认为「这段内容发过了」,下一轮只发之后的增量 —— 而页面里其实没有这段内容 ⇒ 中间那段上下文永久丢失,模型从此缺一段记忆。

不推进 ⇒ 下次调用重新算出同样的增量,内容不丢。

任务生命周期:三个会真实撞上的坑

1. 插队(最严重)

发送成功后 pendingJobRef 仍置着,而轮询每 1.5s 跑一次。
早期版本没有闸门 ⇒ 页面还在生成时又领了下一个任务,把新 prompt 打进输入框发送。后果:生成被打断、两个回复混在一起、done 分不清归属。

修法:pendingJobRef 有值 ⇒ 本轮只问 host「是否被取消」,不领新活。

2. 取消后任务挂着

用户按停止 → host 侧 signal.aborted 抛错退出,但任务仍留在队列里处于 claimed 状态:

- hasClaimed() 永远 true ⇒ /status 误报「面板在干活」
- 页面继续生成到结束 ⇒ 白烧配额
- 之后回来的 done 还可能算成下一个任务的回复 ⇒ 串台

修法:queue.cancel() 标记任务;client 每轮轮询带 ?running=<jobId>,
host 回 cancel 就去点页面的停止按钮。

3. 待办永久卡死

pendingJobRef 靠 done 事件清空。若事件丢失(页面导航 / hook 被清 / 回复永不来),它会一直挂着 ⇒ 发送循环永久罢工,用户看到「模型一直不回复」。

修法:5 分钟超时兜底,交回错误并解锁循环(比 adapter 的 10 分钟更早)。

停止键定位:靠实测,不靠猜

真浏览器 dump(npm run probe:stop)出来的关键事实:

生成中,输入框区域同时存在:发送键:ds-button--primary ds-button--filled ds-button--circle 停止键:ds-button--outlinedNeutral ds-button--outlined ds-button--circle ← 生成中才出现

| 特征 | 归属 | 用法 |
| --circle | 两者共有 | 只给 +10 分(不能靠它区分) |
| --filled / --primary | 发送键独有 | 命中即排除 |
| --outlined | 停止键独有 | +60 分(阈值 60) |

而且生成中发送键不会消失(早期假设它消失,是错的)。

分数并列时不敢猜 —— 宁可不停止(只浪费一次配额),也绝不能误点「深度思考」开关(那会改变后续所有请求的行为)。

实测结果:空闲时返回 no-stop-control(不误点),生成中命中 --outlinedneutral,score 90,候选唯一。

启动阶段机

早期用 documentCount === 1 / 2 / >= 3 计数决定做哪一步,有两个真实缺陷:

1. 某步没发生就永久卡住:没有快照时 === 2 分支直接 return 不 reload,计数停在 2 ⇒ hook 永不注入、ready 永不置位、插件永久失效。
2. 后续导航打乱计数(用户点链接 / 页面跳登录页)。

改成显式阶段机:

boot → navigating → restoring → reloading → live

每个阶段只做一次,不依赖「第几次 dom-ready」。另外:

- 每次 dom-ready 都重注入 hook(hook 是页面级的,导航后就没了;
buildDeepSeekHook() 自带幂等标记,重复注入无害)
- 加载失败回退到 boot(否则断网后永远不再前进)
- 渲染进程崩溃回退到 boot(OOM 后 webview 重载,阶段机要能重来)

工具调用(JS 代码块协议)

为什么用 JS 代码块,不用 JSON

网页版没有原生 function calling,所以要让模型把调用写成代码块。
设计上有两条路:JSON 协议,或 JS 代码块协议。本插件选后者,理由是 JSON 要求模型做转义,而转义是失败之源:

| | JSON 协议 | JS 代码块协议 |
| 多行文本 | 必须写成 \n(模型常写成真实换行 ⇒ 解析失败) | 反引号模板字符串,直接换行 |
| 值里的引号 | 必须转义为 \" | 不用管 |
| 反斜杠路径 | C:\\Users\\… 双层转义 | 不用管 |
| 需要补的补救条款 | 一大堆(引号、换行、反斜杠…) | 无需 |

所以协议里直接写死:多行文本用反引号模板字符串,不做任何转义。
少一整类失败模式。

协议

需要调用工具时,输出一个 ldw const r = await read({"path": "src/index.ts"});
log(r);

实测拿到的真实契约(没猜)

解压 ~/.dsh/sessions/.jsonl.zstd(分帧 zstd,逐帧解压)拿到 4951 行、 851 行含工具调用的真实数据:

// tool-call:assistant content 里的块,arguments 是 JSON 字符串 { "type": "tool-call", "id": "call_00_…", "name": "read_image", "arguments": "" }

关键:tool-result 不是 content block,而是 role: 'tool' 的独立消息:

{ "role": "tool", "toolCallId": "call_00_…",
"content": [{ "type": "text", "text": "…" }], "isError": false }

字段名猜错 = 静默失败,所以先 dump 真货再写代码。

chunk 契约(实测序列)

block-start(text) → text-delta → block-end
→ block-start(tool-call) → tool-call-delta → block-end
→ finish(tool-calls) ← 有调用时必须这个,否则 DSH 不执行

tool-call-delta 的字段名是 argumentsDelta,block-end.block 里是 arguments(不是 args)—— verify:tool-chunks 专门断言这些字段名。

项目初始化:只补 DSH 缺的那部分

harness 类工具在进入项目时会注入 6 类内容。逐条核对 DSH 已经给了什么(详见 docs/project-init-compare.md):

| # | 章节 | DSH 是否已给 | 本插件 |
| 1 | 平台信息 | ⚠️ system 只提了一次 Windows(讲 exit code) | 补(cmd/pwsh 差异) |
| 2 | 项目目录 | ✅ system 末尾 Your working directory is … | 不补 |
| 3 | 项目介绍 | ✅ AGENTS.md 走 user 消息自动进入 | 不补 |
| 4 | 工具清单 | ⚠️ 走 options.tools,需自己格式化 | 补 |
| 5 | 技能清单 | ✅ skill 工具在清单里 | 不补 |
| 6 | MCP 清单 | ✅ MCP 工具在清单里 | 不补 |
| — | 身份与原则 | ✅ system 提供 | 不补(重复会冲突) |

为什么不全补:① 本插件是 adapter(system 由 DSH 给),重复注入 = 两份冲突指令; ② 体量是封号风险(实测记录:单次输入涨到 293k token,四个账号两天内被限制)。

体量控制

首轮 prompt: system + 工具协议 + 运行环境 + 工具目录 + 首条消息后续轮 prompt: 只有增量(实测 11 字符) 60 个工具的工具段: 4,080 字符(对比 web-login 的 5 万)

工具结果超 4000 字符自动截断(防一次读大文件把 prompt 顶爆)。

当前进度

| 阶段 | 内容 | 状态 |
| P0 | 红框位置注册「LDW」标签 | ✅ 已完成 |
| P1 | LDW 面板内挂真实 Chromium guest + 加载 DeepSeek 网页版 + UA 洗白 + 登录态探测 | ✅ 已完成(实测可登录,无「环境异常」) |
| P2 | 注入 hook 观察页面自己的 SSE | ✅ 已完成(辅助路径 —— 见上文「为什么读 DOM 而不是拦 SSE」) |
| P3 | 注册 deepseek-webin provider,网页回复 → StreamChunk → 进「对话」 | ✅ 已完成 |
| P4 | 自动填输入框 + 触发发送 + 工具结果回传 | ✅ 已完成(三段式发送:填充 / 客户端延迟 / 触发) |
| P5 | 设置页(settings.section)+ 平台增删 | ✅ 已完成 |
| P6 | DOM 读回复成为主路径 + 围栏还原 + 思考读取 | ✅ 已完成 |
| P6 | 登录态持久化(重启免登录) | ✅ 已完成(快照 + 目标域回写,见下节) |
| P7 | 工具调用(JS 代码块协议) | ✅ 已完成(见「工具调用」节) |
| P8 | 每会话独立网页对话 + 重启接回 | ✅ 已完成(游标落盘 + 首轮开新对话) |

为什么重启 DSH 后不用重新登录

约束:分区一定是内存分区

DSH 的 guest partition 由 DesktopBrowserGuests.acquire() 生成(main.js:9638):

partition = dsh-sidebar-browser-${randomUUID()}; // ← 无 persist: 前缀

而 webview 门禁又强制 params.partition === lease.partition(main.js:9685):

if (lease === void 0 || lease.owner !== owner || lease.attached || params.partition !== lease.partition) {
event.preventDefault();
return;
}

所以插件不能自己换成 persist: 分区(会被门禁拒绝),重启后 storage 必然是空的。唯一出路是「快照 + 在正确源上回写」。

坑:restore 必须在目标域上执行

早期实现在第一次 dom-ready(页面还是 about:blank)上跑 restore,结果是每次重启都要重新登录。实测(npm run probe:origin):

[A] about:blank 上写 localStorage → SecurityError: Access is denied for this document. [B] 目标域上写 → 跨 reload 保留 ✓

两层原因:

1. about:blank 是 opaque origin,localStorage 直接抛 SecurityError;
2. localStorage 按源隔离 —— 在 A 源写的值 B 源读不到。

正确时序

1. loadURL(homeUrl) → 到目标域
2. dom-ready #2:restore → 此时 localStorage 可写、且源正确
3. reload() → 带着登录态重新初始化
4. dom-ready #3+:注入 hook/探针

restore 脚本现在会回读校验并如实报告,不再静默吞异常:

{"ok":true,"restored":13,"verified":13,"failed":0,"cookies":2,
"origin":"https://chat.deepseek.com"}

在错的源上会明确拒绝:

{"ok":false,"reason":"wrong-origin","origin":"null",
"hint":"restore 必须在目标域上执行(about:blank 是 opaque origin,storage 不可写)"}

回归保护:npm run verify:restore(27 项)锁死时序与源校验,
npm run probe:origin 在真浏览器上验证源隔离假设。

仍然存在的限制

- httpOnly cookie 无法用 JS 回写(浏览器安全模型)。本站登录态主要靠 localStorage.userToken,所以不受影响。
- 清空 ~/.dsh/webin/ 后需要重新登录(快照没了)。
- 登录态快照按平台分槽(auth/deepseek.json、auth/qwen.json),互不覆盖。

本轮修复(压缩死锁)

症状

会话发任何消息都卡住,compaction/start 后长时间无响应,最久一次卡 6 分 12 秒才被中止:

[11:12:00] turn/start 21 → compaction/start → (96s) → aborted
[11:14:10] turn/start 22 → compaction/start → (4s) → aborted
[11:14:36] turn/start 23 → compaction/start → (372s)→ aborted

根因(两个 bug 叠加)

1. contextWindow 写错,阈值低到每步都触发压缩(src/host/adapter.ts)

context: { contextWindow: 128_000 } // ❌ 网页版真实是 1 Mi

compaction-basic 的判定是:

thresholdTokens = min(contextWindow × 0.8, contextWindow − maxTokens − headroomTokens)

| | contextWindow | maxTokens | headroom | 阈值 | 该会话实测 359,267 tokens |
| 修复前 | 128,000 | 8,192 | 65,536 | 54,272 | 超阈值 6.6 倍 → 每步都压缩 |
| 修复后 | 1,048,576 | 32,768 | 65,536 | 838,860 | 远低于阈值 → 不触发 |

2. 压缩的摘要调用被路由回 LDW 自己

compaction-basic 的 summarizeWithLlm() 用 routedTarget(session) 选模型 —— 也就是最近一次实际路由的 provider。上一轮把会话切到 deepseek-webin/ldw-think 后,压缩就变成「必须经浏览器发消息」。而发送恰好是坏的 ⇒ 摘要任务进了队列没人能完成 ⇒ 干等满 timeoutMs(10 分钟)。

修法:waitForJob() 增加无人领活快速失败——超过宽限期(20s)仍没有 client GET /job/next,立即判 WEBIN_FAILED 并说明原因,不再干等。

3. 发送路径失效(send-failed: 未找到发送按钮,合成 Enter 也无效)

旧 chat.ts 的三个缺陷:

- 发送按钮只找 —— DeepSeek 新版用 div[role="button"],永远命中不到
- 兜底扫描只看 4 层祖先里的 —— 同样漏掉 div[role=button]
- 合成 Enter 只发 KeyboardEvent,没有二次尝试

用真 Chrome 加载真实页面 dump DOM 后(npm run probe:send),拿到的事实:

| 元素 | 真实结构 | 后果 |
| 输入框 | textarea[placeholder="给 DeepSeek 发送消息 "] | 旧选择器能命中 |
| 发送键 | div[role=button].ds-button--primary.ds-button--filled.ds-button--circle | 不是 、无 aria-label、无文字(纯图标)→ 旧代码必然找不到 |
| 附件键 | div.ds-button--iconLabelPrimary.ds-button--icon.ds-button--capsule | 与发送键同尺寸相邻,点错会弹文件选择框 |
| 开关 | div.ds-toggle-button(深度思考 / 智能搜索) | 点了会改发送语义 |

只用「几何位置 + 邻近度」评分时,发送键 32 分 vs 附件键 31 分 —— 只差 1 分。
加入站点特征评分后:

| 候选 | 修复前 | 修复后 |
| #1 发送键 | 32 | 214 |
| #2 | 31(附件键) | 39 |
| 分差 | 1 | 175 |

4. 发送确认判据不可靠 → 改成不确认

早期尝试用「多信号校验」确认发送成功,但每个信号都不可靠:

hasStopControl() 靠文本找「停止生成」—— DeepSeek 上是纯图标,测不到。
而 grew(页面正文变长)在 dry-run 里误报为 true(页面动画/懒加载也在改文本)。

更糟的是:校验本身要等待,于是「填充 → 等 2.4s → 校验 → 可能重试」被塞进一次 executeJavaScript —— 那是跨进程调用,页面一导航就整体死掉(不 resolve、不 reject)⇒ 宿主永远等 ⇒ 用户看到「一直深度求索中」。

现在的做法(读参考实现后重构):发送三段式,每段都是短调用 ——

| 段 | 做什么 | 耗时 |
| ① buildFillScript | 只把字填进输入框 | 几百毫秒 |
| ② 客户端 setTimeout | 随机延迟(防封号) | 2–4 秒 |
| ③ buildTriggerScript | 只点发送键(或合成 Enter) | 几十毫秒 |

等待放在客户端,不放在页面脚本里 —— 这样单次跨进程调用的存活时间极短,导航最多毁掉一次短调用,不会让整条链路僵死。

发送是否成功不再自证:由后续的 DOM 轮询来确认「页面上真的出现了新回复」。

5. 交付判据:条数增长 或 内容变化

判断「本轮回复来了」用两个判据取「或」:

| 判据 | 说明 |
| 条数比基线多 | 主判据 —— 只有真的新增 assistant 消息才会增长 |
| 内容与基线不同 | 兜底 —— 页面把回复渲染在选择器匹配不到的容器里时仍能交付 |

再加一道稳定性判据:连续两次读到相同内容才算生成完,避免把「流式输出中的半截回复」交回 DSH。

6. 放弃门槛必须跟配置走

早期硬编码「5 分钟放弃」,但配置里 replyTimeoutMs 默认 10 分钟,而且注释里记着「实测最慢一次网页回复 6 分 12 秒」——于是 6 分钟才回完的请求会在 5 分钟被提前掐断。

现在按 replyTimeoutMs * 0.8 算(留余量让 adapter 那次超时不会先触发),下限 60 秒。

7. 其他

| 问题 | 修法 |
| 路由 / adapter 未释放 → 重载撞 duplicate prefix route | 改用 ctx.effect() 交回 disposer |
| 无超时的 executeJavaScript 会静默死锁 | execJs() 统一加超时护栏(超时 resolve 成 sentinel,不 reject —— 抛出去会被外层 catch 吞掉反而变静默) |
| 「已发送 · 收到 0 条」+ DSH 一直等 | 异常分支必须交回结果(早期只打日志 ⇒ pendingJobRef 永挂着 ⇒ 循环僵死) |
| 发送循环被 loggedIn 门控,误判即全链路失效 | 去掉门控,改由发送脚本在页面上自行判断 |
| 登录误报(登录页也显示「已登录」) | 探针改用 __appKit_userInfo.id 做强证据 + 负面证据交叉判定 |
| __appKit_userInfo.id 解不出(明明登录却报 hasUserId:false) | 真实形状是 {"value":{"id":...}},id 嵌在 value 里;两种形状都认 |
| 空快照覆盖有效快照(重启后仍要重新登录) | 未登录时 userToken={"value":null} —— 早期只查键是否存在,于是登录页的空快照盖掉了有效快照。改为 snapshotLooksLoggedIn() 解出值再判断 |
| 快照存了 cookie 但回写时被丢弃 | buildRestoreScript 补上 cookie 逐条回写(跳过属性残留片段) |
| ctx.logger 畸形会让整个 apply() 抛错 → 插件装不上 | safeLogger() 显式判类型,10 种畸形形态全部安全降级 |
| 排查困难 | host 加 POST /client/heartbeat,/status 反映面板在线数 |

6. 压缩摘要不依赖浏览器面板

即使 contextWindow 修正后不再频繁触发压缩,摘要本身也不该走浏览器:摘要输入动辄几十万 token,塞进网页输入框会让页面卡死,网页版本身也有长度上限。

adapter 现在对 purpose === 'compaction' 的调用立即拒绝(实测 0ms 返回,不进队列),并给出可操作指引:

压缩摘要不能走 LDW 网页版:摘要输入体量远超网页输入框上限,且会把整轮对话锁在浏览器上。
请把 compaction-basic 的 summarizationProvider / summarizationModel 配成普通 HTTP provider(例如 wb-out / deepseek-v4.1-flash)。

根治方式是在 preset 的 compaction 组里显式钉死摘要模型:

- id: compaction-basicyaml
name: '@deepseek-ai/dsh-compaction-basic'
config:
summarizationProvider: wb-out # ← 空对时会回退到"最新已路由目标"(= LDW), summarizationModel: deepseek-v4.1-flash # 那就是死锁根源

dsh-compaction-basic/README.zh.md:70-71 原文:与 summarizationModel 一起设置;空对使用最新已路由请求目标,再回退到 AgentOptions 对。

验证

bash
npm run check # 构建 + 全部离线校验(355 项)
npm run verify:e2e # ⭐ 端到端:真浏览器 + 真登录态 + 真发送 + 真回复
npm run probe:chain # 全链路真实验证(会消耗一次网页版额度)

npm run check 覆盖:contextWindow = 1 Mi、压缩阈值复算、无人领活快速失败、 压缩摘要立即拒绝、心跳路由、任务队列领活/交结果全链路、 卸载后重装不撞重复路由、10 种畸形 logger 下仍能加载、 差分发送的全部规则(30 项,含「第二轮不含旧回复」)、 多平台路由与模型不撞名、以及两个挂载点声明完整。

npm run verify:e2e 是真浏览器端到端验证 —— 起一个真 Chromium、注入真登录态(含 cookie)、注入真 hook、用真发送脚本发一条消息、轮询真 drain 脚本抓回复,最后断言 5 项全绿:

[1b] 登录态回写: {"restored":13,"cookies":2} [4] 发送: stage=sent | signals: {"requestSent":true,...} [5] stream×4 → DONE status=finished len=2 网页版回复: 12 ✓ hook 注入成功 ✓ 发送被确认 ✓ 收到 stream 增量 ✓ 收到 done 事件 ✓ done 带正文 5/5 通过

| prompt | 回复 | 结果 |
| 1+1=? 只回答数字 | 2 | 6/6 |
| 2+2=? 只回答数字 | 4 | 6/6 |
| 3+3=? 只回答数字 | 6 | 6/6 |

单次输出形如:

[2] hook 注入: installed [3] 探针: {"loggedIn":true,"hasUserId":true} [4] stage: sent | signals: {"requestSent":true,...} [5] ✓ hook 捕获到 completion 请求(计数 1) [6] stream 事件数: 4 抓到的回复: 6 ✓ hook 注入成功 ✓ 发送被确认 ✓ 捕获 completion 请求 ✓ 收到 stream 增量 ✓ 收到 done 事件 ✓ done 带正文 6/6 通过

另外几个探测脚本(只读或可指定 prompt):

| 命令 | 作用 |
| npm run probe:dom | dump 真实页面结构 + 用插件选择器匹配,验证选择器是否失效。加 --url <地址> 可校准任意平台 |
| npm run probe:send | dry-run 跑真实 buildSendScript()(拦截点击),验证候选评分 |
| npm run probe:login | 分阶段验证登录判据(裸访问 / 注入后 / 延时探测),排查 loggedIn 误报 |
| npm run probe:done | 逐秒 dump 事件队列,排查 done 事件为何不来 |

⚠️ 这三个脚本都要求本机有 Chrome/Edge,并会读取 ~/.dsh/webin/auth/default.json 的登录态。页面改版后先跑 probe:dom,比看 minified bundle 猜可靠得多。

架构

┌─ DSH Electron 主进程(app.asar/lib/main.js:9617-9803)──────────────┐ │ DesktopBrowserGuests(宿主自带,未修改) │ │ ipcMain.handle('dsh-desktop:browser-acquire') → { lease, partition }│ │ ipcMain.handle('dsh-desktop:browser-release') │ │ will-attach-webview:校验 about:blank#<lease> + partition │ └──────────────────────────────────────────────────────────────────────┘ ▲ window.dshDesktop.browser(宿主 preload 已暴露给主窗口主 frame) │ ┌─ dsh-webin client 半(本插件)──────────────────────────────────────┐ │ │ │ ① 常驻宿主 shell.overlay id='ldw-host' ← root scope,永不卸载 │ │ └─ acquire(workspace) → <webview src="about:blank#<lease>"> │ │ → 真 Chromium guest → loadURL(chat.deepseek.com) │ │ │ │ ② 占位符 conversation.view id='ldw' order=20 ← 用户要的红框位置 │ │ └─ 只量自己的 rect 并广播(不含 webview) │ │ │ │ ① position:fixed 贴到 ② 的 rect 上;② 卸载时 rect=null → ① 移出视口 │ └──────────────────────────────────────────────────────────────────────┘

为什么拆成「常驻宿主 + 占位符」两半

因为 conversation.view 切标签会卸载组件。 四环证据:

| # | 证据 | 位置 |
| A | conversation.view 声明为 kind: 'list' | ui-conversation/lib/client.js:18209 |
| B | 渲染点传 { only: viewId } | ui-conversation/lib/client.js:16412-16421 |
| C | renderer 对 only 是 filter + 单元素 map,不是 CSS 隐藏 | ui-renderer/lib/client.js:1193-1196 |
| D | 全部 client bundle 里 keepAlive / Offscreen 零命中 | — |

React 语义下,兄弟元素 key 从 e<chat> 变成 e<ldw> ⇒ 旧子树整体卸载。
若把 <webview> 直接放里面,用户一切到「对话」看回答,guest 就被销毁、 lease 被释放、轮询全停 —— 生成中的回答直接中断。这正是「把 LDW 当模型用」的核心场景。

shell.overlay 则是 kind: 'list' + scope: 'root',渲染点
renderSlot('shell.overlay', {}) 不传 only ⇒ 全部注册项常驻
(ui-layout/lib/client.js:312,617)。官方 ui-chat 的额度浮层就是这么挂的(ui-chat/lib/client.js:12473)。

⚠️ 宿主容器绝不重新挂载:Electron 的 <webview> 一旦从 DOM 移除再插回, guest 会重新加载。所以宿主只改坐标(left/top/width/height + visibility), DOM 节点自始至终不动。切走时也不用 display:none(会让 webview 停止布局),而是移出视口 + visibility:hidden。

运行时可用 cordis_inspect_query(client / Slots / listSubTree)确认两个挂载点:

shell.overlay → occupants 含 { id: "ldw-host", order: 20, active: true } conversation.view → occupants 含 { id: "ldw", order: 20, active: true }

为什么必须走 webview,不能自己开窗

插件跑在 dsh-desktop-host 子进程里,require('electron') 直接失败(已实测:Cannot find module 'electron',process.type === undefined)。
所以拿不到 BrowserWindow / WebContentsView。

DSH 自带的 webview guest 通道是唯一可用且零新增内核的真浏览器路径。

guest 的三条硬约束(来自 main.js:9682-9707)

const id = params.src.startsWith("about:blank#") ? params.src.slice(12) : "";
const lease = this.leases.get(id);
if (lease === undefined || lease.owner !== owner || lease.attached
|| params.partition !== lease.partition) { event.preventDefault(); return; }

1. src 必须精确等于 about:blank#<lease>
2. partition 必须等于 acquire 返回的 partition
3. 一个 lease 只能挂载一次(lease.attached 后再挂会被拒)→ 重开必须重新 acquire

开发

npm install # 首次
npm run check # 构建 + 全部校验(355 项)

拆开跑:

| 命令 | 内容 |
| npm run build | 构建 lib/index.js + lib/client.js |
| npm run check | 全部离线测试(865 项,不需要浏览器) |
| npm run verify:scrape | ⭐ DOM 读回复(53 项,真实 DOM 树桩) |
| npm run verify:stuck | 防僵死 / 交付判据(87 项) |
| npm run verify:fill | 填充与换行归一化(30 项) |
| npm run probe:dom | 真实页面结构 dump(只读) |
| npm run probe:send | 发送脚本 dry-run(只读,拦截点击) |
| npm run probe:chain | 全链路真实验证(⚠️ 真发送,消耗额度) |

⚠️ 需要真浏览器的探针脚本尚未适配发送流程重构。

probe:send / probe:chain / probe:done / probe:frames / probe:stop /
verify:e2e / verify:coldstart / verify:session-e2e 仍引用已删除的
buildSendScript()(改成三段式发送后不存在了)。

它们的文件头已写明怎么改(把 buildSendScript(...) 换成
buildTriggerScript(...)),但替换后需要真浏览器验证,所以没有自动改动 ——
用未验证的代码替换已运行的代码只会更糟。离线测试(npm run check)不受影响。

装进 profile(Desktop):

// ~/.dsh/profiles/desktop/package.json
"dependencies": { "dsh-webin": "link:F:/Code/DSH-Code/dsh-webin/dsh-webin" },
"dsh": { "profile": { "bundles": [ ..., "dsh-webin", ... ] } }

并建 junction:

New-Item -ItemType Junction -Path "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-webin"
-Target 'F:\Code\DSH-Code\dsh-webin\dsh-webin'

⚠️ profile 的 bundles 只在 Host 启动时读一次 —— 必须重启 DSH 才会加载本插件。

改完哪一半要怎么生效

| 改了什么 | 生效方式 |
| src/client/(面板、发送脚本、hook) | HMR 自动,刷新页面即可 |
| src/host/(provider、路由) | 必须重启 DSH(host 半没有 HMR) |

⚠️ 用插件管理器「禁用再启用」不能替代重启:旧版本(无 disposer)留下的幽灵路由不会被回收,会撞 webserver: duplicate prefix route "/dsh-webin/api"。
新版本已用 ctx.effect() 修好,但已经在跑的旧实例留下的路由仍要重启才清得掉。

排查「模型不回复」

按顺序看这三处:

1. 面板在不在线(online: 0 = 没面板在跑,任务必然无人领取)

$h=@{Authorization="Bearer <secret>"} # secret 见 ~/.dsh/.credentials.yaml 的 client-connection/browser-session
Invoke-WebRequest "http://127.0.0.1:19387/dsh-webin/api/status" -Headers $h -UseBasicParsing

2. 队列里有没有积压

Invoke-WebRequest "http://127.0.0.1:19387/dsh-webin/api/job/next" -Headers $h -UseBasicParsing

3. 切到 LDW 标签,点面板上的「诊断」按钮 —— 会 dump 页面输入框与按钮结构。

挂载点自证

// cordis_inspect_query: platform=client, provider=Slots, method=listSubTree
{ "root": "shell.overlay" } // 应含 { id: "ldw-host", order: 20, active: true } { "root": "conversation.view" } // 应含 { id: "ldw", order: 20, active: true }

网页版自己停了怎么办:点它自己的「继续生成」

网页版会在服务端主动停止时把回复截断,界面上显示「已停止」并留一个「继续生成」按钮。这时如果直接把已抓到的内容交付,用户看到的是「任务没做完,DSH 却报已完成」。

处理顺序:

1. 点页面自己的「继续生成」(优先)—— 这是同一条消息的续写,正文连续、立即生效。最多 3 次。
2. 点不到(没有该按钮 / 分数并列不敢猜)⇒ 退回原看门狗:静默 12 秒且只有思考时,发一条新消息「请继续」,最多 3 次。
3. 都不行 ⇒ 正常交付(有什么交什么)。

两道闸门都放在交付之前,且计数独立 —— 一个手段失败不消耗另一个的额度。

回复不完整 / 服务端不可用时怎么办

网页版在服务端不可用(「服务器暂时不可用」「请求过于频繁」)时会直接掐断生成,留下半截内容。插件不会把这半截当成功交付,判据两条:

| 判据 | 含义 |
| fenceOpen | 回复停在未闭合的代码围栏里 ⇒ 一定没写完(工具调用整段在围栏里,所以能直接挡住「半截工具调用」) |
| serverError | 页面上明确写着服务端错误 |

命中任一条 ⇒ 本轮不算成功,按顺序处理:

1. 提醒用户:面板状态栏 + 控制台
2. 重试(最多 3 次):先点页面自己的「继续生成」;点不到就重发原 prompt —— 服务端限流通常几秒就恢复
3. 重试耗尽 ⇒ 交付现有内容,但在正文开头加一条明确警告,说明本次不完整、可能被截断、建议重发

三种补救手段(续写按钮 / 看门狗「请继续」/ 不完整重试)计数各自独立 ——一个手段失败不消耗另一个的额度。

新会话的任务没人领?(WEBIN_FAILED)

常驻宿主全局只有一个(挂 shell.overlay、root scope、永不卸载),所以整个窗口只有一个领活循环;而 sessionId 来自每个 DSH 会话各自的占位符,写进同一个模块级 ref —— 最后一个挂载的覆盖它。

于是只拿得到一个 sessionId,其余会话的任务永远匹配不上 ⇒ 20 秒无人领 ⇒ 「LDW 面板未连接」。

现在的行为:领活分两轮 —— 本会话的任务优先拿,本会话没有就回退领走。
回退是安全的,因为 client 会按 expectUrl / freshChat 自己导航到正确的网页对话。

/status 里每条任务带 claimedVia:

| 值 | 含义 |
| session | 会话匹配(正常) |
| fallback | 会话对不上,为了不让任务没人领而回退领走 |

频繁出现 fallback 说明会话身份长期对不上,值得查。

网页版在生成,DSH 却报「没发出去」?

「早期放弃」判据(发出后 30 秒无动静就报错)必须被 generating 一票否决:

| 信号 | 含义 | 可信度 |
| generating(停止按钮在) | 页面正在生成 ⇒ 消息一定送到了 | 高 —— 直接反映页面状态 |
| DOM 有没有变化 | 可能读不到(思考阶段还没有回复容器) | 低 —— 会误判 |

网页版处在思考阶段时,.ds-markdown 还没出现,插件的 DOM 读取什么都看不到(条数没增长、内容没变化、思考区也没读到)—— 三个条件同时成立就会误判成「没发出去」。所以只要页面在生成,就绝不放弃。

「页面是否在生成」靠什么判断

不能靠 aria-label —— DeepSeek 的停止键是纯图标(无 aria-label、无文字),

aria-label="停止" 这类选择器永远匹配不到。曾经因此让 isGenerating()
恒返回 false,连带「生成中不交付」闸门从未生效。

正确判据是校准过的 class 特征(与 buildStopScript 同源):

| 特征 | 含义 |
| ds-button--outlined | 停止键独有 ⇒ 命中即判定生成中 |
| ds-button--filled / ds-button--primary | 发送键独有 ⇒ 排除(生成中它不会消失) |
| aria-label / title 含 stop / 停止 | 语义兜底(页面改版后可能补上) |

找不到任何特征时返回 false(不阻止交付 —— 宁早勿卡)。

已知限制

L1:guest partition 不持久(需重启后重新登录)

main.js:9638:

partition = dsh-sidebar-browser-${randomUUID()}; // ⚠️ 没有 persist: 前缀

没有 persist: 前缀 ⇒ 内存分区,连磁盘都不落。官方 README 原文:「Cookie 与 Web storage 不跨应用重启保留」(ui-sidebar-browser/README.zh.md:110)。

本插件用纯插件方案兜底:登录后每 30s 把 guest 的 localStorage(userToken / appKit / hif_*)+ 非 httpOnly cookie 存到 ~/.dsh/webin/auth/default.json,下次启动导航前回写。

⚠️ httpOnly cookie 用 JS 读不到 —— 若 DeepSeek 的登录态主要靠它,这条路会不完整。
那种情况下只能改 DSH 那一行(9638 加 persist: 前缀),代价是升级会被覆盖。

L2:LDW 必须全局单例

并发多窗口实测 6 分钟封 1 天。单轮体量同理,继续用 DSH 自己的 compaction 压上下文(现已修正 contextWindow,见上)。

设计取舍:为什么不用现成的传输层方案

早期实现走过「手工构造 HTTP 请求」的路线,后来全部推倒 —— 那条路是封号之源:

| 丢掉的东西 | 为什么 |
| 伪造的 x-deepseek-harness 头 | 最高风险单点,每个请求都发 |
| about:blank 代理 + 页面内 PoW | 需要 --headless=new --no-sandbox 等自动化 flag,指纹特征明显 |
| 手工拼 origin/referer/cookie | 在页面里会被浏览器静默丢弃,实际发出去的是 Origin: null + 无 Cookie |
| 传输层开关(HTTP / 浏览器二选一) | 真浏览器方案里不存在这个概念 |

换真浏览器也解决不了的:并发多窗口(实测双窗口 6 分钟封 1 天)与单轮体量。
LDW 必须全局单例,并继续用 DSH 自己的 compaction 压上下文。

致谢

本项目的设计思路受益于以下项目,在此致谢:

- cuckoo-code(GPL-3.0-only)
- dsh-deepseek-web-login(Apache-2.0)

本项目为独立实现,全部代码自行编写,许可为 Apache-2.0(见 LICENSE),与上述项目的许可不冲突。

许可

Apache-2.0(见 LICENSE)。

与 DSH 能力的适配

LDW 作为 DSH 的模型 adapter 接入,因此 DSH 的能力自动可用,无需本插件逐个实现:

- 工具调用:DSH 把当前会话可用的全部工具(内置工具、MCP 工具、Skill 提供的工具)以统一 schema 传给 adapter;LDW 将其转换成工具目录发给网页模型,模型输出的调用由 DSH 执行后回灌 —— 你装的 MCP 服务器、启用的 Skill 在 LDW 模型下同样能用
- 上下文管理:会话历史、压缩(compaction)由 DSH 管理,LDW 只做增量发送
- 斜杠命令:由 DSH 的命令系统处理,与模型无关,天然可用
- 多会话:每个 DSH 会话绑定独立的网页对话(见上文会话路由)

免责声明## 免责声明

本项目仅供个人学习与研究使用。使用者需自行承担以下风险:

- 用自动化方式驱动 AI 网页版可能违反服务条款,存在账号被限制的风险
- 本项目通过真实浏览器 + 真实用户操作降低风险(不发伪造请求头、不做 PoW 计算、串行发送、差分发送控制体量),但无法保证不被风控识别
- 请勿用于商业用途、批量操作或任何违反服务条款的场景

使用即表示你已知悉并接受上述风险。
预览分享卡片长什么样 ↓cv-superding/dsh-webin
分享 · DeepSeek Harness Hub
cv-superding/dsh-webin

非官方 DSH 插件 —— 用真实浏览器驱动多个 AI 网页版DeepSeek / 通义千问 / …。

这是你的仓库?认领条目

回到顶部