## v0.2.25 ### 合并社区 PR #3(by [@zgbkdzj](https://github.com/zgbkdzj)) **渐进显示模型思考过程** —— thinking 档位下把思考**边生成边**渲染到 DSH, 而不是等整轮结束一次性冒出。 > 这正是 v0.2.15 里我标注为「缺失」的那一步。当时的原话: > 「诚实说明:这**不会**让它真正流式 —— 插件是从客户端一次性拿到完整文本的。 > 真正的逐字上屏需要客户端边生成边上报(**另一处改动**)。」 > **这个 PR 就是那处改动。** #### 实现 | 层 | 改动 | |---|---| | `host/adapter.ts` | `JobQueue` 新增 `appendThink` / `subscribeThink`;`streamOnce` 在 thinking 档位首次增量到达时开 reasoning 块,边等边实时 yield `reasoning-delta` | | `host/index.ts` | 新增 `PUT /job//think` 路由,接收客户端实时上报的思考增量 | | `client/index.ts` | drain effect 监听 hook 的 `dsh-webin:stream` 事件,计算 think 增量并实时上报(**任务切换时重置增量游标**)| #### 一个关键设计 **最终用全量 `done.think` 兜底补齐再关块** —— 断流或超时**绝不丢字**。 这很重要:流式渲染最怕的就是「流断了,已经渲染出去的内容不完整,而最终文本又没补上」。 作者把这条兜底写进了设计,并在测试里专门覆盖。 #### 测试 新增 `scripts/verify-streaming-thinking.mjs`(21 条断言):实时性 / 断流兜底 / 非 thinking 退化 / client 契约。 ### 验证 build + preflight(22 通过 / 0 失败 / 2 跳过)+ **40 个 verify 全绿**。 ### 合并说明 `lib/index.js`(构建产物)有冲突 —— 属于**无意义冲突**(两边都是编译结果, 只是构建时的提交号不同)。解决方式:产物取 PR 版,**随后重新构建以源码为准**。 ## v0.2.24 ### 合并社区 PR #1(by [@pawamamama](https://github.com/pawamamama)) 两个修复,都经过本地全量验证后合入。 #### 修复 1:非桌面宿主静默降级,且错误提示指向错误方向 **症状**:在没有官方 Desktop Electron 桥的宿主上,插件装得上、模型选择器里也能看到 LDW,但发消息后没人领 → 等满 `claimGraceMs` → 报 *「面板未连接 —— 标签没开 / 面板没挂载 / 页面没登录」*。 **问题在于那句错误提示**:它把用户引向「切标签、重新登录」,而这两件事**都帮不上忙**。 真正缺的是**宿主能力**,不是面板状态。 **修法**:客户端在无桥时发一次心跳带 `capability: 'no-desktop-bridge'`(复用现有端点, 不新增协议);host 侧 `/status` 暴露 `hostCapability`;adapter 区分「宿主无桥」与 「面板没开」两种情形,前者明确说明**这不是「标签没打开」**。 #### 修复 2:**没有竖线的裸 XML 工具标记**泄漏进正文(同一 bug 的第三种形态) **症状**:回复里出现几十行 `` / `` 残骸。 **根因**:前两版 `stripDsml` 都假定「标签里一定有竖线」(`<|DSML|tool_calls>` 或 `<|tool_calls|>`)⇒ 对这种无竖线残骸 `stripped = 0` ⇒ **整段原样漏进对话**。 **修法**:走**标签白名单**(只剥 `tool_calls` / `function_calls` / `invoke` / `parameter` / `tool_call` / `tool_use` / `tool_result`),**而不是「剥掉所有 XML」** —— 因为正文里合法出现 `
` / `` 是正常的(模型在讲 HTML),无差别剥会**吃掉正文**。 > 作者还预判了误报风险并加了回归锁:`div` / `span` / `a` / `img`、正文里的 `invoke` > 单词、数学比较 `${a < b}`、自定义元素 `` **一律不得被剥**。 #### 另外 - `scripts/preflight.mjs` 不再硬编码 `profiles/desktop`(在 `web` profile 上必然误报失败), 改为尊重 `--profile` / `DSH_PROFILE` / `DSH_PROFILE_DIR`;并新增 **[9] 宿主浏览器桥检查** —— 仓库此前从不检查这一项,而它正是本次根因。 - README / CHANGELOG 补充了对应说明。 ### 维护者补充 合入时发现一条**过紧的断言**(与 PR 无关):`verify-live` 用固定 900 字符窗口找 `scrape: payload.scrape`,PR 在处理器里加了两行(capability)就把它挤出了窗口, 导致**误报失败**。已放宽到 2000 字符并在整段 handler 里兜底查找。 > 教训与今天早些时候一致:**断言要盯语义,不要盯位置或写法。** ### 验证 build + preflight(22 通过 / 0 失败 / 2 跳过)+ **39 个 verify 全绿**。 # 更新日志 版本号从这一版起才真正开始动(之前一直是 `0.0.1`,导致设置页显示的版本号 永不变化,用户会以为「更新了但没生效」)。 判据仍以**提交号**为准(见 README「更新检查」一节)—— 版本号只是给人看的。 ## 未发布 ### 修复:无竖线的裸 XML 工具标记漏进对话(第三种形态) **用户实测**:回复里整段漏出这种残骸(一个竖线都没有): ``` …(重复几十次) ``` **根因**:现有 `stripDsml()` 的两条正则都要求标签里**至少有一个竖线** (`<|DSML|tool_calls>` 或 `<|tool_calls|>`)。用户这份是**纯 XML 形态** ⇒ `stripped = 0`,正则从未命中,整段原样交回 DSH。 **这是同一个 bug 的第三个变体**(前两次:只认单竖线 / 漏了双竖线)。 **修法**:新增**标签白名单**处理裸 XML 形态 —— 只剥工具调用专属标签名 (`tool_calls` / `function_calls` / `invoke` / `parameter` / `tool_call` / `tool_use` / `tool_result`),并允许 `antml:` / `dsml:` 命名空间前缀。 **为什么不能“剥掉所有 XML 标签”**:正文里合法出现 `
` / `` / `` 是正常的(模型在讲 HTML),无差别剥会**吃掉正文**。 **回归保护**:`scripts/verify-tools.mjs` 新增 16 项,其中 6 项专门锁**不得误伤**: 正文里的 `invoke` / `parameter` 单词、自定义元素 ``、 `` / `` / `` 均必须**原样保留**。 ### 修复:非 Desktop 宿主上「静默降级」,报错指向错误方向 **用户实测**:在非官方 Desktop 宿主(DSH-X 启动器 + 浏览器 Web GUI、或普通 Electron 壳)里装上插件后: - 安装**成功**、host 半**正常加载**、模型选择器里**出现 LDW**; - 但发任何消息都只能等到 `claimGraceMs` 宽限到期,然后返回 `WEBIN_FAILED`; - 而错误文案写的是「LDW 面板未连接 —— 标签没打开 / 被切走 / 未登录」, **把用户引向完全错误的方向**(切标签、重登录都无用)。 **根因**:插件依赖官方 Desktop 注入的 `globalThis.dshDesktop.browser`(Electron `` 通道)。 非官方 Desktop 宿主不提供该桥,client 半 `desktopBrowser()` 返回 `undefined` 后**直接 `return`** —— **一个字节都不上报**。于是 host 侧只能看到「没有面板来领活」,无法区分: | 情况 | 真实原因 | 修复方向 | |---|---|---| | A | 宿主没有桌面浏览器通道 | 只能用官方 Desktop 版 | | B | 面板没打开 / 被切走 / 未登录 | 切到 LDW 标签 | 这两种情况的**修复动作完全不同**,以前却报同一句话。 **修法**(不改 Desktop 下任何行为): 1. `src/client/index.ts`:无桥时**先发一次**既有心跳端点,带 `capability: 'no-desktop-bridge'`, 再显示状态(不新增协议);并在文案里给出可操作指引。 2. `src/host/index.ts`:heartbeat handler **显式**挑入 `capability`(该 handler 是手工挑字段写法, 漏挑就丢;此前 `scrape` 已经踩过同一个坑),新增 `/status.hostCapability` (`unknown` / `no-desktop-bridge` / `desktop-bridge`,多面板时**报最坏的**)。 3. `src/host/adapter.ts`:无人领活分支改为按 `hostCapability()` 区分两种原因, 情况 A 明确说「这不是标签没打开,换官方 Desktop 版」;情况 B 保持原文案不变。 回归保护:`npm run verify` 65/65 通过;Desktop 路径的周期性心跳 payload **不含** `capability`,行为不变。 ## v0.2.23 ### 修复:心跳判死阈值贴着节流后的实际间隔 ⇒ `online` 来回抖动 **怎么发现的**:用 `/status` 连续采样,拿到 ``` [1] ageMs=44199 online=1 [2] ageMs=None online=0 ← 被判死 [3] ageMs=2008 online=1 ← 又回来 ``` **根因**:心跳间隔是 5 秒(`setInterval(beat, 5000)`),但 **Electron 会对不可见 webview 里的定时器做节流**(可低到约 1 次/分钟)。 实测间隔约 44 秒 —— **贴着 45 秒的判死阈值**,于是在临界点来回抖动。 **后果不只是状态难看**:`online=0` 时任务可能**无人领取** (用户实测报过「任务无人领」)。 **修法**:`CLIENT_STALE_MS` 从 **45 秒提到 150 秒**(= 5 秒心跳的 30 倍), 足以覆盖节流到 ~1 次/分钟的最坏情况,同时仍能在客户端真死时及时回收。 > 判死阈值的语义是「这个客户端真的没了」,不是「它这一秒没说话」。 ## v0.2.22 ### 端到端自测跑通 + 未登录失败改为不可重试 **用户说「你自己测试吧,权限都给你了」** —— 用插件自带的 `POST /selftest/echo` 真跑了一遍完整链路: ``` 塞任务 → client 领活 → 填输入框 → 点发送 → 抓回复 → 交结果 ``` **实测结果**: ```json {"state":"failed","elapsedMs":20153, "error":"no-input: 找不到可见的输入框(页面结构可能已变,或还停在登录页)"} ``` **⇒ 整条链路是通的**(入队 → 领取 → 尝试发送 → 正确报告原因)。 唯一阻塞是**账号停在登录页**。 ### 顺带修的:未登录不该触发重试 `no-input`(因未登录)原被 `classifyFailure` 归为 `TRANSPORT`(**可重试**) ⇒ DSH 会**白重试 5 次**(每次都要等退避),而重试对「未登录」毫无帮助。 修法:含「登录页 / 未登录 / sign_in」字样的失败一律归 `WEBIN_NOT_LOGGED_IN` (自定义码,**不触发自动重试**),让用户看到明确提示去登录。 ### 自测端点的价值 这个端点(早期会话加的)能验证**除 DSH 派发之外的全部链路** —— 之前的测试全是离线单测,只能证明「代码里写了什么」,证明不了「真跑起来能不能回显」。 ## v0.2.21 ### 修复:登录页的文字被当成模型回复交付 **用户实测**:会话掉线跳到登录页后,DSH 对话里收到的「回复」是: ``` +86 注册登录即代表已阅读并同意用户的 用户协议 与 隐私政策,未注册的手机号将自动注册 密码登录使用 Apple 账号登录 微信扫码登录 微信扫码登录 浙ICP备2023025841号 · 联系我们 ``` **根因**:DOM 抓取是**兜底**路径,它只认「最后一个像回复的容器」。页面跳到登录页后, 它读到的东西**完全不是回复** —— 而这类内容交付后**看起来像正常文本**, 比报错更难察觉(用户第一反应是「账号怎么老是自己掉」,而不是「插件读错了」)。 **修法**:交付前检查页面 URL,命中登录页特征(`sign_in` / `sign_up` / `/login`) ⇒ **拒绝交付抓取内容**,改为明确报错「页面已掉线到登录页,本轮回复不可信, 请重新登录后重发」。 ### 关于「账号老是自己掉」——那是另一回事,根因已定位 登录态**主要靠 httpOnly cookie**,而 DSH 的 guest partition 是 `dsh-sidebar-browser-${随机UUID}`,**每次启动都是新的**。所以插件必须把 httpOnly cookie 存档到 `~/.dsh/webin/cookies/` 并在启动时写回。 而该目录**从未被创建** —— 因为存档循环被 `ready` 挡住(v0.2.18 已修)。 **需要用户登录一次**,存档才会生成;之后重启就能自动恢复。 ## v0.2.20 ### 修复:空回复报 `stop` ⇒ DSH 认为成功 ⇒ **永远不重试** **用户实测**:「依旧仅有思考,没有输出」—— 回复完全为空,而且一次都不重试。 **根因**:页面弹出「服务器暂时不可用」后自己停下,模型在思考之后**什么都没产出**。 插件交付了一个**空回复 + `finish: stop`** ⇒ DSH 认为「成功了」⇒ **不会重试** ⇒ 用户看到一个永远空着的回复。 **官方契约**(`dsh-llm-retry` 的 README):normal mode 对 **`EMPTY_RESPONSE`**、 `RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT` 最多重试五次 —— **空回复明确列在可重试集合里**。 **修法**:正文与工具调用**都为空**时(`trim()` 后判空),报 `finish: { kind: 'error', failure: { code: 'EMPTY_RESPONSE' } }`, 交给 DSH 的重试机制接手。 > 这与 v0.2.14(失败码合规)和 v0.2.17(挂载重试插件)是**一条链**: > v0.2.14 让失败可分类、v0.2.17 让分类生效、本次让**空回复也进入分类**。 ### 测试 `verify-host` 新增 4 条:判空用 trim、报 `EMPTY_RESPONSE`、以 error 结束、 非空回复仍走 `stop` / `tool-calls`。 ## v0.2.19 ### 修复:孤儿含工具调用时被整段吞进思考(**我 v0.2.17 引入的回归**) **用户实测**:「这次啥也没输出,就只有思考的内容」—— 思考完整了,**正文空了**。 **根因**:v0.2.17 把孤儿文本**整段**归给 `think`。而那个孤儿里**同时**装着思考和一个 工具调用(ldw 围栏)—— 整段归 `think` 就把围栏一起吞了,**正文与工具调用全丢**。 **修法**:按围栏切开。 - 围栏**之前**的部分(若有)⇒ `think`(开了思考时) - 围栏**起始**处及其后 ⇒ 一律 `text`(那里是工具调用 / 正文) - 没有围栏时才整段按 `thinkingEnabled` 归属 ### 顺带:审计第三次拦住我 本次又踩了反引号(在模板字符串内部写注释与正则)—— **`verify-escapes` 当场抓到**。 这已是它第三次实战拦截。 另外踩了一个新坑:**正则字面量里的反引号**(`/[`]{3}/`)同样会终止模板 —— 改用 `String.fromCharCode(96)` 拼装(代码库已有此惯例)。 ### 测试 `verify-hook-behavior` 新增 5 条:围栏部分进正文、围栏前的话进思考、 **围栏没被吞进思考**、无围栏时整段归思考、无围栏时正文为空。 ## v0.2.18 ### 修复:`markLive()` 不置 `ready` ⇒ 插件静默失效(群友定位,我逐行验证) **群友报告的症状链,我对照代码逐条验证后确认成立**: ``` markLive() 不置 ready ↓ 心跳循环 if (!ready) return ⇒ clients.online 恒为 0 发送循环 if (!ready) return ⇒ 领不到任务 快照循环 if (!ready) return ⇒ ~/.dsh/webin 永不创建 ↓ 无快照 ⇒ host 侧 loggedIn(platform) = false ↓ listModels() 返回 [] ⇒ 模型选择器里没有 LDW ``` **一个 `setReady` 缺失,同时造成三个症状。** **代码证据**:`setReady(true)` 原来只跟在**第三条** `markLive()` 调用点后面 (输入框就绪检查之后)。而另外**两条**调用点都会 `return`: | 行 | 分支 | 结果 | |---|---|---| | 1287-1288 | 「没有快照也要继续走就绪流程」 | `markLive()` → `return` ❌ | | 1314-1317 | 「回写无需 reload」 | `markLive()` → 落到 `return` ❌ | ⇒ 那两条路径永远走不到 `setReady` ⇒ **插件静默失效**。 **修法**:把 `setReady(true)` 放进 `markLive()` —— **一处覆盖三个调用点**。 输入框就绪检查**保留**,但它现在只用来**细化状态文案**,不再承担置位职责。 (`markLive()` 的三个调用点都在真实目标域,boot 分支不调用它,所以在此置位是安全的。) ### 测试 `verify-lifecycle` 新增 3 条:`markLive` 函数体内必须含 `setReady(true)`、 调用点数量 ≥ 3、输入框检查仍在。 ## v0.2.17 ### 修复 1:孤儿文本的兜底归属(**F25 修复里我自己留下的漏洞**) F25 引入了「开了思考但还没有 fragment 时先缓冲」。但**缓冲落盘时归给谁**这一步 第一版写错了:`flushOrphan()` 直接调 `route(piece)`,而 `route` 在**类型表为空**时 按 `currentIsThink()` 判定 —— 空表恒为 false ⇒ **孤儿文本又进了正文**。 等于把「缓冲」这道修复绕过去了。 **修法**(依据参考实现的真实帧观察):开了思考时,正常流的首帧快照**一定**带 THINK fragment ⇒「整轮都没出现 fragment 类型」说明帧丢了或流断了,此时孤儿文本 **极可能是思考** ⇒ 归到 `think`。未开思考时保持旧行为(归正文)。 ### 修复 2:「完全没有重试」的真根因在 **DSH 侧配置**(已代为启用) 用户实测:页面因「服务器暂时不可用」自己停了,DSH 那边**一次重试都没有**。 查证结果(**不是插件问题**): - `dsh-llm-retry` **未安装**在 profile 的 node_modules - **不在** `dsh.profile.bundles` 里 - **未挂载**在 `cordis.patch.yml` ⇒ **DSH 的官方重试执行器从未被挂载**,所以插件上报的失败码再正确也不会触发重试。 **已代为启用**:把 `@deepseek-ai/dsh-llm-retry` 加入 `~/.dsh/profiles/desktop/package.json` 的 `dsh.profile.bundles` (原文件已备份为 `package.json.bak-before-retry-<时间戳>`)。 该包在 DSH 应用包内存在,与 `dsh-base` / `dsh-web-app` 同源解析。 > 与 v0.2.14 的失败码修复是**一对**:v0.2.14 让失败可被分类,这次让分类真的生效。 ### 测试 `verify-hook-behavior` 新增 3 条:无 fragment + 开思考 ⇒ 归 think 且不进正文; 无 fragment + 未开思考 ⇒ 归正文(两个方向都钉住)。 ## v0.2.16 ### 第四项:HTTP 状态码走完四层链路,填进官方 `LlmFailure.status` **官方契约**(从 DSH 的 app.asar 提取): ```ts export interface LlmFailure { readonly message: string readonly code: string readonly status?: number // ← HTTP 状态码 readonly providerRetryAfterMs?: number } ``` **为什么必须走四层**:插件只从 DOM 读文本,**拿不到状态码**。唯一能拿到的地方是 hook —— 它包了页面的 `fetch` / `XHR`,`res.status` 与 `this.status` 就在手里。 ``` hook(读状态码)→ client(上报)→ host(存进任务)→ adapter(填进 LlmFailure) ``` **实现要点** - hook 用**统一变量** `lastHttpStatus`(三个发射点共用),而不是逐处传参 —— 本轮就踩过一次:加了 `xhrStatus` 却忘了声明,而**构建不做类型检查、照样过**。 - 拿不到时**不带** `status` 字段(不是填 0)—— 官方契约里它是可选的。 ### 本轮的两次自我纠正 1. **反引号(第 6 次)**:注释里写反引号导致构建失败 —— 而**上一轮加的审计当场抓到** (报 6 处)。这是那项修复的实战验证。 2. **清理过火**:我为了去掉反引号,脚本清了**整个文件**注释里的 128 个反引号, 误删了模板外的合法文档标记。**用 git 恢复该文件后只重做必要改动** —— 45 处合法注释反引号已回来。 ### 测试 `verify-host` 新增 12 条,逐层断言链路完整(含「无未声明的 xhrStatus」这条防回退)。 ## v0.2.15 ### 四项改进(用户要求「全做」) #### ① 转义审计:让「扫描器跑偏」可见,而不是静默返回 0 `verify-escapes` 靠状态机跟踪「在不在模板里」。状态一旦跑偏(漏配反引号、 把除法当正则),它会**静默返回 0 处** —— 而 0 处看起来就像「一切正常」。 真实教训:构建因模板里的反引号失败,而本审计报「发现 0 处」。 修法:`scan()` 返回**最终栈状态**,新增 `[1b]` 断言「每个文件扫完后模板必须全部闭合」。 跑偏时**大声报错**。同时用「注入真实反引号 → 看能否检出」验证过本审计确实有效。 #### ② `reasoning-delta` / `text-delta` 改为**分块**发射 官方 `StreamChunk` 契约(从 DSH 的 app.asar 提取)里两者都是**增量**事件。 原来整段一次性发,现在按 200 字符分块 —— 更符合契约语义,DSH 也能按块渲染。 > 诚实说明:这**不会**让它真正流式 —— 插件是从客户端一次性拿到完整文本的。 > 真正的逐字上屏需要客户端边生成边上报(另一处改动)。 #### ③ hook 提为**优先路径**(DOM 兜底让路) 有两条交付路径:**SSE hook**(权威,思考与正文在协议层分开)与 **DOM 抓取**(兜底)。 hook 一旦交付会清掉 pending;但若 hook 慢一步,DOM 会先按 `STABLE_MS` 交付, 用户拿到的就是可能混着思考的 DOM 那份(实测发生过)。 修法:hook 可用时把 DOM 的静默门槛**拉长到 3 倍**,给权威路径先手。 用「是否收到过 hook 事件」判断(免费信号,不额外跨进程调用)。 #### ④ `LlmFailure.status`(未做,说明原因) 官方契约里 `status?: number` 是 HTTP 状态码,但插件**拿不到** —— 它只从 DOM 读文本。 要拿到需要把 hook 补丁里的 `res.status` 一路传出来(hook → 事件 → client → host → adapter,四层改动)。**本轮未做**,留作独立一项。 ### 测试 `verify-audit` / `verify-sequence` 的静默判据断言改为盯语义(不盯变量名); 本轮还修掉一个自己引入的缺陷:分块改动**弄丢了 `block-start`**(被 `verify-thinking` 抓到)。 ## v0.2.14 ### 结合 DSH 官方源码的发现:失败码不合规 ⇒ 官方重试机制**从未启动** **背景**:用户要求结合三方(DSH 官方源码 / cuckoo / dsh-deepseek-web-login)做系统分析。 从 DSH 的 `app.asar` 里提取到了官方契约。 #### 官方契约(`dsh-llm/lib/typert.host.js`) ```ts export interface LlmFailure { readonly message: string readonly code: string readonly status?: number readonly providerRetryAfterMs?: number } ``` #### 官方重试插件(`dsh-llm-retry` 的 README) > 省略 `retryPolicy` 时使用 **normal mode**:对 `EMPTY_RESPONSE`、`RATE_LIMIT`、 > `SERVER`、`TIMEOUT` 与 `TRANSPORT` 最多重试**五次**,退避 500ms→10s 带抖动。 > 提供方给出的有效 **`Retry-After`** 会**替换**本地退避。 **⇒ DSH 自带重试机制,插件不该自己实现重试,而应上报正确的失败码。** #### 原来的问题 插件所有失败都发 `code: 'WEBIN_FAILED'` —— 一个**自定义码**, 官方重试策略认不出来 ⇒ **一次都不会重试**。 这正是用户实测「服务器暂时不可用从不重试」的根因。 #### 修法 新增 `classifyFailure(message)`,把客户端失败文案映射到官方码: | 失败文案 | 官方码 | 退避 | |---|---|---| | 服务器暂时不可用 / 请求过于频繁 | `RATE_LIMIT` | 120s(或页面给的「N 秒后」) | | 心跳 / 失联 / 超时 / 面板未连接 | `TIMEOUT` | 策略默认 | | 没发出去 / 输入框丢失 | `TRANSPORT` | 策略默认 | | 空回复 | `EMPTY_RESPONSE` | 策略默认 | | 其它 | `WEBIN_FAILED`(自定义) | 不触发自动重试 | **映射是保守的**:认不出的一律回退自定义码 —— 不让没把握的判断触发五次自动重试 (每次都是真实请求、真实计费)。 ### 测试 `verify-host` 新增 8 条:映射函数存在、发射时使用、四个官方码都能映射、 带 `providerRetryAfterMs`、认不出时回退。 ## v0.2.13 ### 真根因(照参考实现移植):无 fragment 时思考被无条件当正文 **用户第三次指路**:「你为啥不在看看 dsh-deepseek-web-login 怎么实现的呢, 有些调用工具、思考、执行命令等都不要当正文显示出来!」 读了 `dsh-deepseek-web-login-main/src/webapi.ts` 的 SSE 解析状态机 —— **它记录了一个和本插件症状完全吻合的真实事故(F25)**: > 思考的归属**完全依赖首帧快照里的 THINK fragment**。一旦快照没能进入状态机, > `fragments` 就一直是空的,而旧实现在**「无 fragment 可续」时无条件当正文发射** > ⇒ **整段思考上屏**。 > 实测后果:一轮 2062 字符、两轮 10526 / 4360 字符**全部进正文**, > 且正文块开头都缺几个字。 **dsh-webin 的 `route()` 有一模一样的缺陷**: ```js var fragmentTypes = []; var currentIndex = -1; function currentIsThink() { if (currentIndex < 0 || ...) return false; } function route(piece, forceKind) { var kind = forceKind ?? (currentIsThink() ? 'think' : 'text'); // 空表 ⇒ 'text' } ``` **空类型表 ⇒ 无条件判成 text ⇒ 整段思考进正文。** 这就是用户连续五轮报的同一个症状。 ### 修法(照参考实现) 1. `createExtractor(thinkingEnabled)` 接收本轮是否开了思考 2. 新增 `orphanBuffer`:**开了思考、但还没有任何 fragment 时,裸增量先缓冲** 3. fragment 类型表第一次有内容时(fragments APPEND / 快照 / 路径帧三条路径) **flush**,按刚确定的类型归属 4. 结束时若仍在缓冲 ⇒ **兜底落盘**(不能丢字) 5. 未开思考时**不缓冲**,保持旧行为 `thinking_enabled` 从请求体读:fetch 走 `init.body` / `input.body`, XHR 在 `send(body)` 里记下来。 ### 测试 `verify-hook-behavior` 新增 5 条:**快照丢失时思考不进正文**、缓冲归属到 think、 未开思考保持旧行为、正常流各归各位。 ### 参考实现的另一处关键信息 它的 SSE 协议注释确认了字段归属: {"p":"response/thinking_content","v":"…"} 思考(独立路径) {"p":"response/content","v":"…"} 正文(独立路径) {"p":"response/fragments/-1/content","v":"…"} {"v":"…"} 承接上一个 path 的续段 **思考与正文在协议层就是分开的** —— 这正是用户说的「调用 api 的形式就很顺畅」。 本插件走的是 DOM 抓取 + SSE 辅助,架构不同;但这条 F25 的修法可以直接移植。 ## v0.2.12 ### 真凶:泄漏在 **hook 路径**上,我前四轮都在改 DOM 路径 **决定性证据来自 `/status` 的抓取诊断**: selector: '.ds-markdown' count: 2 lastLen: 23 head: 'ldw\n\n```ldw\nconst r\n```' **DOM 侧读到的是干净的 23 字符工具调用** —— 而用户收到的却是**长长的思考**。 ⇒ **泄漏不在 DOM 路径上**,而在 **hook 路径**:hook 的 `done` 事件带 `{text, think}`,它先触发、清掉 pending、把 `text` **原样**交付 —— 而那个 `text` 里混着思考。 我前四轮一直在改 DOM 侧的排除(`render()` 的 `isThinkLabel`、 `readThink()` 的文案匹配),**方向从一开始就是错的**。 ### 修法:收口 + 两路都过 1. 「剔除混进正文的思考」抽成**唯一的** `stripThinkFromText(text, think, tag)` 2. **hook 路径**(`done` 事件)与 **DOM 路径**都调它 3. 旧的内联实现删除(`thinkTrim` 已归零)—— 判据分散是今天所有返工的根源 ### 为什么这轮才找到 前三轮我都在**改代码**,没去看**哪条路径在交付**。这一轮先查 `/status` 的 抓取诊断,一眼就看出 DOM 侧结果是干净的 —— 泄漏必然在另一条路径。 **教训:先确认「哪条路径在交付」,再改那条路径。** ## v0.2.11 ### 真根因:`readThink()` 硬匹配旧文案,导致思考无法被识别 **用户第三次报同一症状**(思考被当正文输出)。这次找到真根因: ```js if (wt.indexOf('已思考') === -1 || wt.length > 200) continue; ``` `readThink()` **硬匹配旧文案「已思考」** —— 而页面已改成「**已完成分析**」。 匹配不上 ⇒ `readThink()` 返回**空** ⇒ 上一版加的「正文混进思考就剔除」兜底 **根本没有东西可剔除** ⇒ 思考原样漏出。 **所以上一版没修好,是因为我改错了地方**:我只改了 `render()` 的排除, 漏了 `readThink()` 自己的文案匹配。 ### 修法:文案判据**收口到一处** 同一份「什么算思考区标签」的判据原来散落在三处(`render` 排除 / `readThink` 定位 / 前缀清理),改一处漏两处。现在全部改用统一的 `isThinkLabel()`: - 折叠条定位:`wt.indexOf('已思考')` → `!isThinkLabel(wt)` - 深层判据:同样改用 `isThinkLabel` - 前缀清理:列出全部已知文案(含「已完成分析」「正在分析请求」「深度思考」) - 新增:**逐行**去掉「思考 · 用户」这类分组标题行 ### 教训 这一轮的问题是**同一个判据散落多处**。修一处不够 —— 必须收口。 `isThinkLabel()` 现在只有一处定义,三处调用。 ## v0.2.10 ### 修复:DSML 剥离有两处问题(按用户要求参照 cuckoo) **用户实测**:DSML 标记照旧漏进对话。查下来是**两处都错了**。 #### 问题 1:正则匹配的格式是错的 我第一版按「单竖线 + tool_calls」写正则,而**真实格式是双竖线 + DSML 字样**: <| |DSML| |tool_calls> <| |DSML| |invoke name="write"> <| |DSML| |parameter name="path">... (上文的 `| |` 是全角竖线 U+FF5C 的示意) 所以那条正则**从来没有匹配过任何东西** —— 剥离计数恒为 0,标记原样漏出。 修法:形态容错 —— `<` 之后允许「任意数量的竖线 / DSML 字样」,但**至少一个竖线**。 后半句约束不能丢,否则 `
` 也会被当成 DSML 吃掉。两种形态(单/双竖线)都覆盖。 #### 问题 2:更根本的 —— 提示词没有明确禁止 **读 cuckoo 的实现后发现:它根本没有「剥离 DSML」的代码。** 它靠**提示词从源头禁止**,并且只认自己的围栏: > 将 JavaScript 代码输出在以 ```cuckoo 开头、以 ``` 结尾的代码块中, > **代码块外不要有任何文字** > **不要输出 JSON 格式的工具调用**,也不要对字符串做 JSON 转义 而本插件原来只写「不要用 XML/HTML 标记包裹」—— **太笼统**,模型照样吐 DSML。 修法:按 cuckoo 的写法把这条点名到 DSML:**在源头禁止,比事后剥离可靠**。 剥离保留为安全网(模型不一定听话)。 ### 测试 `verify-tools` 的 DSML 段扩到 11 条:双竖线真实格式、单竖线形态、正文保留, 以及**两条反向用例** —— HTML 标签与普通尖括号**不许**被误伤。 ## v0.2.9 ### 修复 1:`dsh.client.inject` 是空的 —— 槽位注册失败(群友反馈) `package.json` 里 `dsh.client.inject` 是 `[]`,而两个可用参考实现 (`dsh-deepseek-web-login`、以及 dsh-webin **自己的旧版备份**)都写着: ```json "inject": ["@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-slots"] ``` 空数组 ⇒ client 拿不到 `ctx.slots` ⇒ 槽位注册失败 ⇒ `clients.online: 0`、 LDW 标签不出现。**这是回归**(曾经有过、后来丢了),仓库里还留着 `package.json.bak-20261013-before-inject-fix` 作证。 修法:**只注入 `@deepseek-ai/dsh-client-ui-slots`**(群友的建议是对的)。 > ⚠️ 我第一版照旧版备份抄了**两项**(含 `dsh-client-runtime`)—— 被 `preflight` 当场拦下: > 它有一条专门的检查「`inject` 不含**已从 DSH 移除**的 `dsh-client-runtime`」。 > 说明那个包早就不存在了,`[]` 正是「移除它时清空过头」的结果 —— 正确做法是**保留 ui-slots**, > 而不是把整个数组清空。旧版备份反映的是**修复前**的状态,不能照抄。 ### 修复 2:思考又漏进正文 —— 页面改版换了文案 **用户实测**:思考被当正文输出,而且带出了**页面 UI 标签**: 已完成分析 ← 折叠条(新版文案) 思考 ← 分组标题(新版文案) 原来的排除只认「已思考(用时 N 秒)」这一种文案 —— **页面改版换词后全失效**。 修法:抽出统一的 `isThinkLabel()`,把已知文案列全(已思考(用时 / 已完成分析 / 正在分析请求 / 深度思考 / 单独成词的「思考」「用户」「分析」),并保留长度门槛。 两处排除点都改用它。 ### 附带修好的:DSH 一直「深度求索中」 **用户实测**:网页版早就生成完了,DSH 还转着「深度求索中,用时 5 分 40 秒」。 **与修复 2 同一根因**:思考一直在流式变化 ⇒ 正文永远「在变」⇒ 静默判据永不成立 ⇒ 永不交付。思考排除修好后,正文稳定下来,静默判据才能生效。 ### 测试 `verify-incomplete-detect` 新增 12 条 `isThinkLabel` 用例 —— 重点是**反向**: 正文里出现「思考」「用户」「深度思考」等词**不许**被误伤。 ## v0.2.8 ### 修复 1:面板活着却被判「心跳停止」—— 长任务被误杀 **用户实测**:一轮长生成(思考 + 写文件)超过 3 分钟,**文件其实已经写成功** (DSH 里能看到 `pelican-bicycle.html +283` 的 diff 卡片),任务却被判失败: > 本轮运行失败 执行面板中途失联(心跳停止)—— 任务没能完成,请重试 **根因**:`reapStaleClaimed` 的报错文案写着「心跳停止」,但**实现里压根没读心跳** —— 它只是「claim 后 3 分钟没结果」的**盲超时**。典型的「拿猜当结论」。 **修法**:传入**真实心跳**作为存活谓词,面板还活着就绝不回收。这样: - 面板真死了 ⇒ 心跳停 ⇒ 45 秒后判死 ⇒ 回收(文案终于名副其实) - 面板活着但回复很慢 ⇒ **不回收**,交给 adapter 自己的 `replyTimeout` 兜底 ### 修复 2:思考内容被当正文输出 **用户实测**:「感觉把思考内容也当正文输出了」—— DSH 对话里显示的是模型的英文思考。 **根因**:真实页面上 `ds-think-content` **嵌套在 `ds-markdown` 内部**(思考是回复的 一部分,只是折叠显示),递归渲染正文时会把思考一起读进去。hook 侧有两层排除, 但**都依赖页面特征**(类名 / 折叠条文案「已思考(用时」),页面一改版就失效。 **修法**:加一道**与页面特征无关**的兜底 —— `think` 是单独读出来的,若它出现在 正文里就删掉那段(先试直接子串,不中再按逐行定位,只对 ≥ 20 字的思考生效)。 交付与「内容变化」判定都改用剔除后的 `lastClean`。 ### 测试 - `verify-session-behavior` 新增 4 条回收判据(活着不回收、死了才回收、未超时不回收、 谓词缺省保持原行为) - `verify-thinking` 新增 6 条兜底断言(含「交付必须用 lastClean」) ## v0.2.7 ### 修复 1:「30 秒判没发出去」误报 —— 参照 cuckoo 重做判据 **用户连续三轮被同一条误报打断**:网页版还在生成,DSH 却报「消息似乎没有发出去」。 **我按用户要求去读了 cuckoo 的实现**(`src/bridge/loop/`),发现关键差异: | 维度 | cuckoo | dsh-webin(旧) | |---|---|---| | 完成信号 | **SSE 流静默**,阈值 **300 秒** | DOM 静默 3 秒 | | 「没发出去」判据 | **根本没有** | **30 秒**无动静就判失败 | | 错误检测 | 订阅**真实错误事件**(429 等) | 抓 DOM 文本猜 | | 重试 | 普通 10 次 / **429 专门 40 次 + 等 2 分钟** | 3 次 | cuckoo **压根没有「30 秒判没发出去」这道逻辑**。dsh-webin 这条判据过于激进, 而且**与发送时的确认自相矛盾**(`buildTriggerScript` 已经探过「输入框被清空」)。 **修法**(补上 cuckoo 式的「有真实依据才下结论」): 1. 窗口 **30 秒 → 120 秒** 2. 保留 `generating !== true` 否决 3. ⭐⭐ **新增 `scrapedOk === true` 前提** —— 抓取必须**真的看见了页面**才允许下结论 第 3 条是关键:抓取是瞎的时候(`ok=false`、一个回复容器都没找到), 「条数没增长 / 内容没变化」与「页面确实没动静」**完全无法区分**。 而思考阶段恰好就是这种状态(还没有 `.ds-markdown`)—— 这正是用户反复被误报的现场。 ### 修复 2:DSML 标记漏进对话 **用户实测**:「DSML 怎么也出来了?」 网页版的模型自带一套原生工具调用标记(DSML,标签名用全角竖线 | 包裹), 而插件此前**完全不认识它** ⇒ 整段标记原样漏进 DSH 对话。 **修法**:在「回复变成可见文本」的唯一入口(`parseToolCalls`)先剥离 DSML —— 整块调用删掉、散落标签删掉、**正文保留**,并留一条诊断便于观察出现频率。 ### 测试 - `verify-tools` 新增 7 条 DSML 用例(整块删掉、正文保留、正常文本不受影响、 留诊断、围栏协议不受影响) - `verify-send-reliable` 不再钉死 `= 30_000`,改钉「窗口够保守 + 两条否决都在」 - `verify-sequence` 的距离上界再放宽(该断言已因功能增长被迫放宽三次) ## v0.2.6 ### 修复:`isGenerating()` 判据错了很久 —— 「生成中不交付」闸门从未生效 **用户实测**:网页版还在生成,DSH 又报「消息似乎没有发出去」,而 v0.2.5 已经 加了 `generating` 一票否决。查下来发现 **`generating` 压根不是 true**。 **根因:两处判据自相矛盾,而错的是 `isGenerating()`** 旧写法只找 `aria-label` / `title` 含「停止 / stop」的元素。但**同一个文件里 `buildStopScript` 的注释早就写明了**: > DeepSeek 的停止键是**纯图标(无 aria-label、无文字)**,只能靠 class 特征区分。 `buildStopScript` 用的是校准过的 class 特征(`ds-button--outlined` 独有), 而 `isGenerating()` 用的是永远匹配不到的 aria-label ⇒ **恒返回 false**。后果: - **「生成中不交付」那道闸门从来没生效过** —— 网页版还在生成时也可能交付 (之前几次「半截回复」很可能有这个因素) - v0.2.5 的 `generating` 一票否决**白加了**(`generating` 不是 true) - 依赖它的其它判据全部失效 **修法**:改用与 `buildStopScript` 同源的校准特征 —— 发送键独有(`ds-button--filled` / `ds-button--primary`)排除、 停止键独有(`ds-button--outlined`)命中即判定生成中、保留语义兜底。 同时把交付闸门改成读**同一个**局部 `generating`(单一来源,避免两处再分叉)。 ### 测试 - `verify-incomplete-detect` 新增 9 条 `isGenerating` 行为用例(假 DOM): 校准特征命中、发送键排除、两者同屏仍判生成中、语义兜底、不可见不算、空页面 - `verify-live` 里那条**钉住错误判据**的断言(要求 `aria-label*="停止"`)已换成 校准后的 class 特征 —— 它正是这个 bug 能存活这么久的原因之一 - `verify-escapes` 扩展:新增「模板内的**注释行**里误写反引号」检测 (这个坑本轮踩了四次,构建报错又难懂) ## v0.2.5 ### 修复:网页版正在生成,DSH 却判「没发出去」直接失败 **用户实测**:网页版还在转(生成中),DSH 对话窗口直接报 > **本轮运行失败** 消息似乎没有发出去:发出后 30 秒内网页版没有任何反应 > (既没有新消息,也没有开始生成)。请确认 LDW 页面正常后重试。 **根因**:那条「早期放弃」判据只看 DOM 有没有动静: waitedMs > 30s && !countGrew && !contentChanged && think.trim() === '' 而网页版处在**思考阶段**(还没开始渲染正文)时,插件的 DOM 读取什么都看不到: `.ds-markdown`(回复容器)还没出现 ⇒ 条数没增长、内容没变化、思考区也没读到。 三个条件同时成立 ⇒ 30 秒就判失败 —— **而它压根没检查「页面是否正在生成」**。 **修法**:`generating`(停止按钮在)**一票否决**。 页面在生成就**证明消息送到了** —— 这比「DOM 有没有变化」可靠得多。其余三条 保持不变(真要判断「没发出去」,它们仍然是对的)。 ### 顺带清理 交付结果的对象字面量里 `text` / `think` / `status` / `via` **重复了两次** (v0.2.3 加 `deliveredText` 时漏删旧键)。JS 里后者覆盖前者、功能正确, 但读代码的人会以为交的是 `last` 而实际是 `deliveredText`。已清掉。 ### 测试 `verify-stuck` 新增一组断言:否决确实在早期放弃的同一条件里、诊断日志带上 `generating`、交付 body 没有重复的对象键。 ## v0.2.4 ### 修复 1:新会话的任务「无人领取」(WEBIN_FAILED) **用户实测**:新开一个 DSH 会话连发两条,界面报「LDW 面板未连接 —— 没有任何 客户端领取这个任务(20s 内无人领取)」。任务队列里的证据很干净: job-4 session-8b3f72ef claimedBy=(未领) failed job-5 session-8b3f72ef claimedBy=(未领) failed job-6 session-8b3f72ef claimedBy=已领 done ← 第三条才成功 **根因**:常驻宿主**全局只有一个**(挂 `shell.overlay`、`scope: 'root'`、 不传 `only` ⇒ 永不卸载),所以整个窗口只有一个领活循环。而 `sessionId` 来自 每个 DSH 会话各自的占位符(`conversation.view`),它们写进**同一个模块级 ref** —— **最后一个挂载的覆盖它**。于是唯一那个循环只拿得到一个 sessionId,其余 会话的任务永远匹配不上 ⇒ 20 秒超时。 **修法**:领活改成**两轮** —— 本会话的任务优先拿,本会话没有就**回退领走**。 回退是安全的:client 拿到任务后会按 `expectUrl` / `freshChat` 自己导航到该 会话绑定的网页对话,所以不会串台。新增 `claimedVia` 字段(`/status` 可见) 区分「会话匹配」与「回退」。 > 这与代码里 [5] 组记录的是**同一类问题的剩余部分**:上一轮修了「完全没身份」, > 没修「身份是错的」。原则一样 —— 宁可放行,也不能把功能卡死。 ### 修复 2:围栏被页面补闭合后,截断检测失效 v0.2.3 加的 `fenceOpen` 判据在真实页面上**失效了**:网页版的 markdown 渲染器 会给**未闭合的围栏自动补上闭合**,所以从 DOM 读出来的文本围栏永远成对。 抓取诊断原样证明了这点: "lastLen":45, "head":"ldw\n\n```ldw\nconst r = await tools.write({\n```" 围栏闭合,但代码块正文以左花括号结尾 —— 半截工具调用照样被交付。 **修法**:新增 `codeTrailingIncomplete` 判据 —— **代码块正文以不可能自然收尾的 token 结尾**(左花括号、左圆括号、逗号、箭头、逻辑与/或、点号、等号、冒号…)。 ⚠️ **只在代码块内部判**:正文以冒号、加号结尾极常见(「步骤如下:」), 拿它判会大量误报。 ### 测试套件 - `verify-session-behavior`:[1] [10] 两组原来断言「A 领不到 B 的任务」—— 那个方向正是事故来源,改成断言「本会话优先 + 匹配不上回退」,并保留 [2] 组 验证优先级仍然生效 - `verify-incomplete-detect`:新增 `lastCodeBlockIncomplete` 的 15 条用例 (含用户实测的真实样本,以及「正文以冒号结尾不误报」的反向用例) - `verify-audit` / `verify-live` / `verify-session-isolation`:领活断言跟着 新形态更新(盯不变量,不盯某一行怎么写) ### 踩坑记录(都记在代码注释里) - **模板字符串里的注释不能有反引号** —— 会提前终止模板。本轮又踩了三次 - **`\u007b` 转义没用** —— 打包器会把模板里的转义归一化成字面字符, 产物里照样出现括号。改用 `String.fromCharCode(123, 40, 91)` - **源码里不要出现不平衡的字面括号** —— 多个测试脚本用「朴素花括号配对」 从产物里抠函数,不平衡会把配对带偏(三个脚本直接崩) - **测试从产物里抠函数后要反转义一层**(模板源码比生成脚本多一层转义), 否则函数体内的正则会解释错 ## v0.2.3 ### 修复:交付半截工具调用 + 服务端错误不提醒不重试 **用户实测**:网页版服务端不可用时插一条「服务器暂时不可用」并**直接掐断生成**。 而 DSH 收到的是 ldw const r = await write( —— 工具调用断在半句,界面却写「已完成,用时 1 分 27 秒」。用户看到的是 「插件坏了」,而真实原因是服务端拒绝,也就不会想到重试。 **两个判据都没人检查过**(这是本质缺失,不是漏了一个分支): 1. **`fenceOpen`** —— 回复停在**未闭合的代码围栏**里 ⇒ 一定没写完。 判据极简且可靠:围栏标记计数为**奇数**。正常结束的回复围栏总是成对闭合。 工具调用整段都在围栏里,所以这条能直接挡住「半截工具调用」。 2. **`serverError`** —— 页面上明确写着服务端错误(「服务器暂时不可用」 「请求过于频繁」等)。 **处理**(在交付闸门之前新增「不完整闸门」): - **提醒用户**:面板状态栏 + 控制台 - **重试**(最多 3 次):先点页面自己的「继续生成」;点不到就**重发原 prompt** —— 服务端限流通常几秒恢复,新请求比死等有效 - **重试耗尽**:照常交付,但在正文**开头加一条明确警告**,说明本次不完整、 可能被截断、建议重发。宁可显得啰嗦,也不要让半截结果看起来像成功 三种补救手段(续写 / 看门狗 / 不完整重试)**计数各自独立**,互不占用额度。 ### 新增 `verify-incomplete-detect`(已接入 check 链) 覆盖两个方向:漏判(不完整却交付 ⇒ 用户拿到半截结果还以为成功)与误判 (完整却当不完整 ⇒ 白白重试浪费配额)。含用户实测的真实样本。 ### 测试套件修复:两处「窗口太小」的脆弱断言 - `verify-audit` 取 `clearPending` 函数体用 `{0,400}` 正则窗口 —— 给它加两行 「计数归零」后窗口够不到收尾花括号,函数明明正常却报红。改成**花括号配对** - `verify-thinking` 用 `indexOf('buildFillScript(prompt')` 定位发送 —— 而它是 新加的 `buildFillScript(promptRef.current` 的**前缀**,于是命中了重发调用 (位置比真正的发送早),拿错了参照点。收紧为带逗号 ## v0.2.2 ### 修复:网页版自己停了 ⇒ 点它自己的「继续生成」 **用户实测**:网页版频繁出现「服务器暂时不可用 → 已停止」,界面上留一个 「继续生成」按钮。而插件把**半截回复当成本轮结果交付**了 —— DSH 显示 「已完成,用时 6 秒」,任务其实没做完。 **根因是两点叠加**: 1. **触发太晚**。服务端停掉时页面 `generating === false`(停止键不在)⇒ 交付闸门放行;内容静默只等了 `STABLE_MS`(3 秒)就交付。而原有看门狗 要等 **12 秒**静默才动手,根本轮不到它。 2. **动作不对**。看门狗的动作是**发一条新消息**「请继续」—— 续写落在新的 assistant 消息里,正文被切成两段;服务端限流时新请求同样被拒,白费配额。 **修法**:在交付闸门**之前**加一道「续写闸门」——检测页面自己的 「继续生成」按钮并**点击它**(同一条消息的续写,语义正确、立即生效)。 点不到才退回原看门狗。 **判据保守**(点错的代价远大于不点):「继续生成」与「停止」在**同一个操作条** 上,只差一个词,所以逐条排除停止键 / 发送键 / 附件 / 深度思考 / 智能搜索; 只认强语义(≥ 80 分),不靠 class 特征猜;分数并列时不猜。 ### 新增 `verify-continue-button`(已接入 check 链) 用假 DOM 驱动生成的脚本,逐条覆盖**误点风险**而不是只测「能点到」: 停止键同屏时不误点、危险开关被否决、歧义不猜、不可见不点、发送键不认。 ## v0.2.1 ### 修复:两处模板转义吞掉反斜杠(一个让 SSE 路径从未生效,一个静默算错) 插件把注入页面的 JS **当字符串生成**,写在一个个模板字符串里。模板字符串会 **吃掉一层转义**:单写的斜杠转义退化成裸斜杠,字符类转义退化成普通字母。 **① SSE 观察路径从来没工作过。** `hook.ts` 的 `fragmentIndexOf` 生成出来是 var m = /^response/fragments/(-?d+)//.exec(p); 非法 JS ⇒ 注入页面的 hook 直接抛 `SyntaxError: Invalid regular expression flags` ⇒ 整个 hook 注入失败。而主路径(读 DOM)照常工作,所以这个故障 **一直静默存在**,直到用 CDP 打开真页面才暴露。修复后真页面实测 `[2] hook: installed`。 **② DOM 报告静默算错。** `dom-report.ts` 的 `dc.split(/\s+/)` 同样在模板里 ⇒ 生成 `split(/s+/)`,**按字母 s 切分** ⇒ `ds-` 类名一个都提取不到,诊断输出 莫名其妙是空的。这类比第一种更危险:没有异常、没有日志。 **为什么之前没被测出来**:只看源码看不出来(那两行正则长得完全正常); 只测「能否编译」也测不出第二种(`/s+/` 是合法正则)。 ### 新增三个常驻测试(已接入 `npm run check`) - `verify-scripts-parse`:把产物里 14 个生成器逐个调用,用 `vm.Script` **只编译不执行**地解析结果 —— 专治「源码看着对、生成出来非法」 - `verify-escapes`:扫描模板字符串内的单反斜杠转义(带**扫描器自检**, 避免它静默退化成空操作) - `verify-hook-behavior`:沙箱里喂合成 SSE 帧,断言「帧 → 事件」映射。 这条路径刚被启用、此前从未执行过,必须补行为覆盖 ### 测试套件修复 - `verify-audit` / `verify-sequence`:位置断言误报 —— `indexOf` 命中了 「催继续」路径那次触发调用;字符窗口也随功能增长失效。改成锚到主路径 与顺序断言 - `verify-coldstart` / `verify-e2e` / `verify-session-e2e` / `probe-*`: 适配三段式发送(`buildSendScript` 已删除),改用 `confirmed` 判据 - `verify-extract` / `verify-fence`:抠出的是模板源码、比生成脚本多一层 转义,补一次反转义 - `verify-live` / `preflight`:`execSync` 会经 `cmd.exe`,改 `spawnSync` 免掉 shell;子进程不可用时降级为进程内解析并**如实报 SKIP**(不假装通过) ## v0.2.0 ### 版本号回到手动 bump 上一版我把它做成「自动带提交数」(`0.1.0+5.def5678`),这是**过度设计** —— 对人没意义(想知道的是「第几版」,不是「距上个 tag 几个提交」), 还把「版本」和「构建元数据」混在一个字符串里。 现在回到最简单可预测的做法:**版本号手动 bump**,提交号与构建时间 单独注入(它们回答的是「跑的哪份代码、什么时候构建的」)。 ### 设置页 版本行显示成 `v0.2.0 · <提交号> · 3 分钟前构建`。 构建时间比提交号直观:`git pull` 之后忘了 `npm run build`, 时间戳会明显偏旧,一眼就能看出来。 ### 修复 - **未就绪提示挡住页面**:`.webin-note` 覆盖整个 guest 区域且会吃掉 鼠标事件,启动阶段(loadURL + 快照 + cookie 写回)最长几十秒 ⇒ 那段时间登录表单/二维码全都点不动。加 `pointer-events:none`。 - **更新检查方向搞反**(严重):`compare/<本地>...main` 的 `status` 里 `ahead` 表示**远端超前 = 我落后 = 有更新**,我写反了 ⇒ **用户永远不会看到更新提示**。改用真实 GitHub API 验证后修正。 - 更新检查加 10 秒超时(原来 GitHub 连不上时按钮永远转圈) - 更新检查缓存按仓库分键(原来改 repo 后会拿到上一个仓库的结果) - 404 不再当成网络错误(本地提交在远端不存在 = 本地更新,不是仓库没了) ### 重启免登录 根因:DSH 的 guest partition 是内存分区(无 `persist:` 前缀),退出即丢 cookie;而 DeepSeek 登录态主要靠 **httpOnly cookie**,页面 JS 看不见也 写不了 —— 所以「快照 localStorage + document.cookie」天生不完整。 现在 client 上报 partition 名,host(跑在 Electron 主进程)用 `session.fromPartition(name).cookies` 读写**含 httpOnly** 的完整 cookie, 写回时机在页面加载**之前**。 ### 读回复 - DOM 抓取成为主路径(拦 SSE 需要 preload,而 DSH 的 webview 门禁会删掉它) - 选择器用实测的真实类名(`ds-markdown` / `ds-think-content` / `ds-pow-response`) - 思考内容单独读且**不混进正文** - 交付判据:条数增长 **或** 内容变化,**且**静默 ≥ 3 秒(防交半截) ### 不再静默停摆 - 所有 `executeJavaScript` 走带超时的 `execJs()` - 所有对 host 的 `fetch` 走带超时的 `fetchJson()` - 长休改成分段睡,可被取消打断 --- ## v0.1.0 这一版把「网页版内容回显到 DSH 对话窗口」这件事真正做通了,并修掉一批 会让插件**静默失效**的缺陷。 ### 读回复:改成从页面 DOM 读(主路径) - **为什么改**:拦 SSE 需要在页面脚本执行**之前**注入 hook,唯一办法是 `preload`;而 DSH 的 webview 门禁会把 `webPreferences` 清空后只设固定几项, `preload` 不在里面 —— 于是只能等 `dom-ready`,那时请求早已发出, 永远「收到 0 条」。 - 从页面渲染出来的回复容器读内容,**不依赖任何注入时机**。 - 选择器用**实测的真实类名**(`ds-markdown` / `ds-think-content` / `ds-pow-response`),不是猜的。 - `
` 还原成围栏代码块(**工具调用靠它**)。
- 思考内容单独读,且**不混进正文**(嵌套结构下的真实 bug)。
- 交付判据:条数增长 **或** 内容变化,**且**内容静默 ≥ 3 秒
  (防流式暂停时交半截回复)。

### 发送:拆成三段,不再自证

- `buildFillScript`(填字)→ 客户端随机延迟 → `buildTriggerScript`(点火)。
- 等待放在**客户端**,单次跨进程调用只存活几十毫秒 —— 页面导航毁不掉整条链路。
- 点火前复查一次取消状态;被取消则清空输入框并放弃发送。

### 超时护栏:不再静默停摆

- **所有** `executeJavaScript` 走带超时的 `execJs()`。裸调用在页面导航时
  不 resolve 也不 reject,会让宿主永远等。
- **所有**对 host 的 `fetch` 走带超时的 `fetchJson()`。
- 长休(每 N 次请求休息)改成分段睡,可被取消打断。

### 登录态:重启免登录

- 根因:DSH 的 guest partition 是内存分区(`dsh-sidebar-browser-`,
  无 `persist:` 前缀),进程退出 cookie 全丢;而 DeepSeek 的登录态主要靠
  **httpOnly cookie**,页面 JS 看不见也写不了。
- 修法:client 上报 partition 名 → host(跑在 Electron 主进程)用
  `session.fromPartition(name).cookies` 读写**含 httpOnly** 的完整 cookie。
- 写回时机在页面加载**之前**,写完 reload 一次。

### 界面

- **未就绪提示不再挡住页面**(`pointer-events:none`)。
  用户实测报过「点开 LDW 会卡在这个界面点不了,只能等『真实浏览器已就绪』」——
  那层提示原本会吃掉鼠标事件,而启动阶段最长要几十秒。
- 设置页新增「检查更新」(四种状态分开说)、「点个 Star」。

### 更新检查

- 加 10 秒硬超时(GitHub 在墙内经常连不上但不立刻报错 ⇒ 按钮永远转圈)。
- 用 compare API 判断方向:本地自己提交过 / 远端 force-push 时
  **不再误报「有更新」**。
- 缓存按仓库分键(原来是一个模块级变量,改 repo 后会拿到上一个仓库的结果)。

### 防封号

- 每个 DSH 会话绑定独立的网页对话(重启后接回),不再全挤一个窗口。
- 差分发送:只发增量,不重发历史。
- 请求数上限 + 随机延迟 + 长休。

### 工具调用

- 模型输出 ` ```ldw ` 代码块 → 解析成 DSH 的 `tool-call` → 执行 → 结果回填。
- 斜杠命令 / Skill / MCP 均可通过工具调用触达。

### 测试

**1081 项离线测试**(`npm run check`,不需要浏览器、不消耗网页版额度)。

### 已知取舍

流式输出若中途停顿超过 3 秒再继续,会被判定为「已结束」而交付半截内容。
要根治需要「流是否真的结束」的信号,而 DSH 的 webview 门禁(删掉 `preload`)
让 hook 拿不到它。当前行为已被测试钉住,不是未知行为。