dsh-webin
Health Warn
- License — License: NOASSERTION
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 6 GitHub stars
Code Fail
- process.env — Environment variable access in scripts/asar.mjs
- spawnSync — Synchronous process spawning in scripts/build.mjs
- exec() — Shell command execution in scripts/check-ua.mjs
- process.env — Environment variable access in scripts/diagnose-turn.mjs
- network request — Outbound network request in scripts/e2e-echo.mjs
- Hardcoded secret — Potential hardcoded credential in scripts/e2e-echo.mjs
- process.env — Environment variable access in scripts/lib/isolate.mjs
- execSync — Synchronous shell command execution in scripts/preflight.mjs
- process.env — Environment variable access in scripts/preflight.mjs
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
Unofficial DSH plugin: drive AI web UIs (DeepSeek/Qwen/...) in a REAL browser via DSH's own Chromium webview guest — real login, real typing, real send, reply captured by observing the page's own SSE. Includes tool calling.
dsh-webin
Unofficial DSH plugin — 用真实浏览器驱动多个 AI 网页版(DeepSeek / 通义千问 / …)。
核心思路是不设计协议,操作界面:打开真浏览器 → 用真实输入把字打进输入框 →
页面自己发请求 → 从页面 DOM 把回复读出来 → 工具结果当普通消息打回聊天框。
没有任何一行在「调 API」 —— 没有 create_pow_challenge、没有 x-ds-pow-response、
没有 x-hif-* 回放。这是不封号的原因。
为什么读 DOM 而不是拦 SSE
插件同时有两条读回复的路,但主路径是 DOM:
| 怎么读 | 什么时候能用 | |
|---|---|---|
| DOM(主) | 从页面渲染出来的回复容器里读,并把 <pre><code> 还原成围栏代码块 |
随时(内容在页面上,不依赖任何注入时机) |
| 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。
安装
# 从本地目录装进 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
装完重启 DSH(host 半没有热重载)。然后在对话区会出现 LDW 标签,
模型选择器里出现「LDW · DeepSeek 网页版」。
首次使用:切到 LDW 标签 → 登录一次 → 回到对话选 LDW 模型。
设置页
侧栏 → 设置 → 「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 `
-Headers $h -ContentType "application/json" -Body $body
装好后立刻生效(host 会热重载路由),新平台出现在模型选择器里,
文件落在 ~/.dsh/webin/providers/<id>.json,跨重启存活。
3. 在 LDW 面板顶部的下拉里选中它,登录一次即可(登录态独立保存)。
移除:
Invoke-WebRequest "http://127.0.0.1:19387/dsh-webin/api/providers/my-platform" `
-Method Delete -Headers $h
查看装载情况(含校验失败原因):
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 自己决定怎么变成
provider 的请求体。对 HTTP provider 这没问题 —— 每次请求本来就该带全量上下文。
但网页版不是无状态 API:页面自己维护着对话历史。把整段历史每轮都打进
输入框有两个后果:
- 网页端聊天记录里堆满重复的超长提示词(用户一眼就能看到);
- 上下文被重复计数 —— 模型看到的是「历史 + 又一份历史」,
有效上下文被砍半,还容易被判成刷屏。
为什么 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 计数决定做哪一步,有两个真实缺陷:
- 某步没发生就永久卡住:没有快照时
=== 2分支直接 return 不 reload,
计数停在 2 ⇒ hook 永不注入、ready永不置位、插件永久失效。 - 后续导航打乱计数(用户点链接 / 页面跳登录页)。
改成显式阶段机:
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 代码块,块外不要有任何文字:
```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": "{\"file_path\":\"C:\\\\…\"}" }
关键: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 保留 ✓
两层原因:
about:blank是 opaque origin,localStorage直接抛SecurityError;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 的三个缺陷:
- 发送按钮只找
<button>—— DeepSeek 新版用div[role="button"],永远命中不到 - 兜底扫描只看 4 层祖先里的
<button>—— 同样漏掉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 |
不是 <button>、无 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-basic
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对。
验证
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; }
src必须精确等于about:blank#<lease>partition必须等于 acquire 返回的 partition- 一个 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
- 切到 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 }
已知限制
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 计算、
串行发送、差分发送控制体量),但无法保证不被风控识别 - 请勿用于商业用途、批量操作或任何违反服务条款的场景
使用即表示你已知悉并接受上述风险。
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found