dsh-deepseek-web-login 0.6.15 → 0.6.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,49 @@
2
2
 
3
3
  本项目遵循大致语义化版本;日期为本地时间。
4
4
 
5
+ ## 0.6.16 — 2026-10-01
6
+
7
+ **修严重的现场 bug:链式模式下"一个窗口聊三句 ⇒ 网页端多出三个新会话,旧会话还被删掉"。**
8
+
9
+ ### 根因(全程靠插桩定位,没有猜)
10
+
11
+ 收尾"放过这个会话"的条件是:
12
+
13
+ ```ts
14
+ if (id === sessionId && complete && !poisoned && limit > 0) continue // 放过
15
+ retireSession(id); cleanup(id) // 否则退役 + 删除
16
+ ```
17
+
18
+ 链式模式只保证了 `limit > 0`(不轮换),但**还有 `complete && !poisoned` 这道门** ——
19
+ 而 `complete` 只在一个地方置真:底层流的迭代器自然结束(`item.done`)。
20
+ **消费方(DSH)读到终止事件就停止取值**,我们拿不到 `item.done`,`complete` 永远是 `false`,
21
+ 于是每一轮结束时这个会话都被 `retireSession + cleanup`(**弃用并删除**)、链也记不上
22
+ ⇒ 下一轮只能再新建一个会话。用户看到的就是"每轮一个新会话 + 旧会话在网页端消失"。
23
+
24
+ 插桩记录(`~/.dsh/web-login/diag-feed.jsonl`)是决定性证据:同一个归属键下
25
+ `命中旧槽=false → NEW session=…` 连着三轮,而中间那一轮 `命中旧槽=true → feed chained chars=133`
26
+ —— 同一份代码里两种结果都有,说明不是"永远连不上",而是成功的那一轮也被判成没跑完。
27
+
28
+ ### 修法
29
+
30
+ 1. **认服务端的显式终态**:`kind:'finish'`(`[DONE]` 时解析器补发的收尾事件)或
31
+ `response/status: FINISHED` 一到,`sawTerminal = true`;收尾判据改成
32
+ `roundOk = complete || sawTerminal`(会话保留 + 链记账都用它)。
33
+ 这与项目既有纪律一致("SSE 判卡住要用显式终态,不看有没有字节")。
34
+ 2. **N04 的意图不变**:真的提前中断(没等到终态)仍要退役会话 —— 那个会话可能停在半路。
35
+ 用例一正一反把这个边界钉住了。
36
+ 3. **加持久可见性**:收尾退役时记下原因(`extra-session` / `poisoned` / `not-finished` / `rotated`),
37
+ 挂在 `GET /context-mode` 的 `chain.lastRetire` 上 —— 这类"每轮新建会话"下次不用再插桩。
38
+
39
+ 用例:`check-context-chain` 新增两条(消费方读完终态就停 ⇒ 会话保留+链记上;终止前就停 ⇒ 仍退役),
40
+ **并做了反向验证**:把修复退回去(`roundOk = complete`)第一条立刻变红,改回来即绿。
41
+
42
+ ### 排查过程留下的工具
43
+
44
+ `dev/patch-diag.mjs`:往**已安装产物**里插 6 处只读诊断(不用发版/不占装机周期就能拿到
45
+ "每轮决策原因、租用键、是否新建会话、head 从第几个字符开始不同")。两个坑已写在脚本注释里:
46
+ 对象最后一个属性没有尾逗号(逗号要放开头)、备份源不能拿目标文件自己。
47
+
5
48
  ## 0.6.15 — 2026-10-01
6
49
 
7
50
  **界面两处修正(用户反馈):「一键重登」搬到账号库;删掉一堆误导性的"注释"。**
package/lib/index.js CHANGED
@@ -2986,6 +2986,14 @@ const contextChains = /* @__PURE__ */ new Map();
2986
2986
  /** 最近用过的链归属键 —— 面板只展示"当前那条",用它定位。 */
2987
2987
  let lastChainKey;
2988
2988
  /**
2989
+ * 最近一次"收尾把会话退役"的原因(0.6.16)。
2990
+ *
2991
+ * 为什么要有它:2026-10-01 那次"每轮新建一个网页端会话(旧会话还被删了)"查了一整轮 ——
2992
+ * 判据只在"原因变化时"写宿主日志、而宿主日志不落盘,最后靠**往产物里插桩**才定位到。
2993
+ * 现在原因直接挂在 `/context-mode` 的 `chain` 上:`not-finished` 就是那条 bug 复发。
2994
+ */
2995
+ let lastRetireReason;
2996
+ /**
2989
2997
  * 服务端**已知**的图片 file_id(0.1.83):本会话内已经随请求发出去过的那批。
2990
2998
  *
2991
2999
  * 只用于链式投喂:走 `chained`(有父链)时服务端会回溯历史、那张图已经在它的上下文里,
@@ -3016,7 +3024,8 @@ function contextChainInfo() {
3016
3024
  sessionId: chain.sessionId,
3017
3025
  turns: chain.entries.length,
3018
3026
  parentId: chain.parentId,
3019
- slots: reuseSlots.size
3027
+ slots: reuseSlots.size,
3028
+ ...lastRetireReason ? { lastRetire: lastRetireReason } : {}
3020
3029
  };
3021
3030
  }
3022
3031
  /** 凭证摘要:只用来判断「是不是同一个账号」。不做安全用途、不落日志。 */
@@ -3317,6 +3326,14 @@ async function* streamWebCompletion(auth, params, transport = defaultTransport)
3317
3326
  let iterator;
3318
3327
  let body;
3319
3328
  let complete = false;
3329
+ /**
3330
+ * 本轮见过服务端的**显式终态**(`kind:'finish'`)吗。
3331
+ *
3332
+ * 与 `complete` 的分工:`complete` 需要"底层流的迭代器自然结束"(消费方必须多取一次),
3333
+ * 而消费方读完终止事件就可能不取了 ⇒ 只认 `complete` 会把**成功的一轮**误判成没跑完,
3334
+ * 于是会话被退役+删除、链也记不上(2026-10-01 的"每轮新建会话"就是这条)。
3335
+ */
3336
+ let sawTerminal = false;
3320
3337
  let poisoned = false;
3321
3338
  let timer;
3322
3339
  /** 本轮实际发出去了什么(链式投喂据此记账;见 finally 里的链更新)。 */
@@ -3417,6 +3434,7 @@ async function* streamWebCompletion(auth, params, transport = defaultTransport)
3417
3434
  break;
3418
3435
  }
3419
3436
  if (item.value.kind === "error") poisoned = true;
3437
+ if (item.value.kind === "finish" || item.value.kind === "status" && item.value.value === "FINISHED") sawTerminal = true;
3420
3438
  yield item.value;
3421
3439
  }
3422
3440
  } catch (error) {
@@ -3431,12 +3449,15 @@ async function* streamWebCompletion(auth, params, transport = defaultTransport)
3431
3449
  if (body && !body.locked) body.cancel().catch(() => {});
3432
3450
  });
3433
3451
  finalized = true;
3452
+ const roundOk = complete || sawTerminal;
3453
+ lastRetireReason = void 0;
3434
3454
  for (const id of owned) {
3435
- if (id === sessionId && complete && !poisoned && limit > 0) continue;
3455
+ if (id === sessionId && roundOk && !poisoned && limit > 0) continue;
3456
+ lastRetireReason = id !== sessionId ? "extra-session" : poisoned ? "poisoned" : !roundOk ? "not-finished" : "rotated";
3436
3457
  retireSession(id);
3437
3458
  cleanup(id);
3438
3459
  }
3439
- if (sentFeed?.next && complete && !poisoned && typeof responseMessageId === "number") {
3460
+ if (sentFeed?.next && roundOk && !poisoned && typeof responseMessageId === "number") {
3440
3461
  contextChains.set(slotKey, {
3441
3462
  ...sentFeed.next,
3442
3463
  parentId: responseMessageId
@@ -9508,7 +9529,7 @@ async function checkForUpdate(current, fetchImpl) {
9508
9529
  * 兜底常量与 package.json 的一致性由 `tests/check-smoke.mjs` 守着,不会漂。
9509
9530
  */
9510
9531
  /** 与 package.json 保持一致的兜底版本(由测试保证不会漂)。 */
9511
- const FALLBACK_VERSION = "0.6.15";
9532
+ const FALLBACK_VERSION = "0.6.16";
9512
9533
  let cached;
9513
9534
  /** 本插件版本(如 `0.1.26`)。 */
9514
9535
  function pluginVersion() {