dsh-deepseek-web-login 0.6.22 → 0.6.24

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,87 @@
2
2
 
3
3
  本项目大致遵循语义化版本;日期为本地时间。
4
4
 
5
+ ## 0.6.24 — 2026-10-01
6
+
7
+ **修复:浏览器代理传输层不支持 `FormData` ⇒ 图片全部上传失败。**
8
+
9
+ ### 现象
10
+
11
+ 切到浏览器代理传输层(0.6.20 起,官方 DSH 桌面端拿不到 `electron.net.fetch` 时的默认路径)后,
12
+ 粘贴图片会得到:
13
+
14
+ ```
15
+ 有 1 张图片没能传给模型(DeepSeek 图片上传失败:浏览器代理传输层暂不支持
16
+ Blob/FormData/ReadableStream 请求体),本轮回答只基于文字内容。
17
+ ```
18
+
19
+ ### 根因
20
+
21
+ 0.6.20 的 `bodyToPageInit()` 只把 `string` / `Uint8Array` / `ArrayBuffer` 转成页面侧可重建的表达式,
22
+ 遇到别的类型直接抛错。而**图片上传走的正是 `FormData`(multipart)**
23
+ (`webapi.uploadImageFile` 里 `form.append('file', new Blob(...), name)`)。
24
+
25
+ ### 修法
26
+
27
+ `bodyToPageInit()` 现在支持 `FormData` 与 `Blob`,做法是把内容按**字节**搬到页面里重建:
28
+
29
+ - 字节用 **base64** 传(而非 JSON 数组)—— 体积约为 1/2.4,CDP 消息不会被大图撑爆;
30
+ - 页面侧注入两个小工具:`__dshB64ToBytes`(base64 → Uint8Array)与
31
+ `__dshRebuildFormData`(按 parts 重建 `FormData`);
32
+ - 文件部分用 **`new File(...)`** 而不是 `new Blob(...)` —— 必须保住 **filename**,
33
+ 因为服务端按**文件名后缀**判图片类型(见 `protocol.ts`);
34
+ - 语言无关地遍历 `FormData`(用 `forEach`,不用 `entries()` —— 后者要 `DOM.Iterable` lib)。
35
+
36
+ ### 验证
37
+
38
+ - 新增一条**真实往返**用例:启本地 HTTP 服务,用 200 KB 的假 PNG 走 `FormData` 上传,
39
+ 断言 `content-type` 带 boundary、`filename` 保住、PNG 魔数(8 字节)原样到达
40
+ - 变异反向验证:把 `FormData` 分支禁用 ⇒ 用例变红、`rc=1`,报错信息与用户截图**逐字一致**
41
+ - `tsc --noEmit`、`npm run test`(60/60)、`check-smoke`、`check-bundle` 全通过
42
+
43
+ ## 0.6.23 — 2026-10-01
44
+
45
+ **一个窗口 = 一条对话线:不再断链、不再分叉、不再删会话。**
46
+
47
+ 0.6.22 只堵住了"每轮换新会话",但**链仍然每轮断**——`parent_message_id` 一变成 `null`,
48
+ 网页端就把那条渲染成**同层消息**(「修改 / 重新生成」+ `n / n` 翻页)。这次治的是断链本身。
49
+
50
+ ### ① 续链判据:从"严格前缀"改成"容得下运行时注入被原地替换"
51
+
52
+ 旧判据要求 `prev[i] === next[i]` 对全部 i 成立;而 DSH **每一轮**都会重写它自己注入的那条
53
+ 运行时快照(原话:`Current runtime context. This snapshot supersedes earlier runtime-context
54
+ snapshots.`)——**替换式**,位置不变、内容每轮变。于是链几乎每轮都作废。
55
+
56
+ 新判据 `canExtendChain` 两条:
57
+ 1. 本轮条目必须**真的变长**(没变长说不清是重发还是历史被截断,保守退回);
58
+ 2. **链尾仍在原位**(`next[prev.length-1] === prev[prev.length-1]`)——同时挡住
59
+ "整体重写"和"中途插入/删除"(那会让增量切错位置)。
60
+ 满足时中间差异只可能是原位替换,`entries.slice(prev.length)` 切出来的正好是真正的新内容。
61
+
62
+ ### ② 退回全量时**不再发根消息**(这是"分叉"的直接来源)
63
+
64
+ `decideFeed` 里把收尾拆成两条路:
65
+ - `detach`(`parent: null`)——**只有真的没有链**时才走:新会话 / 换了 DSH 会话 / 换了账号;
66
+ - `replay`(`parent:` 链尾)——头部变了、历史被改写、增量空/超预算等,**重发全量但仍挂链尾**。
67
+
68
+ 代价只是服务端上下文里多一段重复历史,而分叉是**结构性**的坏。
69
+
70
+ ### ③ 槽位淘汰不再删网页端会话
71
+
72
+ 超限时旧实现 `retireSession + cleanup`(= 排队 DELETE)。现在只清内存槽与链:
73
+ 会话留着,回到那个窗口还能接着用;而且"删会话"本身是最强的机器特征之一。
74
+
75
+ ### ④ 顺带修正:投喂回执里的 `chained` 改按 `reason` 判
76
+
77
+ 旧写法 `chained: feed.parentMessageId !== null`,而 ② 之后"重发全量"也挂在链尾 ⇒
78
+ 会把"这一轮其实重发了全量"报成 `chained=true`,日志直接说谎。
79
+
80
+ ### 验证
81
+
82
+ - `tsc --noEmit`、`npm run test`(60/60)、`check-smoke`、`check-bundle` 全通过
83
+ - 新增 4 条用例(2 条 ★ 在 `decideFeed` 层、1 条 ★ 端到端、1 条 ★ 槽位淘汰)
84
+ - 三条修复各自**变异反向验证**:改回旧实现 ⇒ 对应用例变红、`rc=1`
85
+
5
86
  ## 0.6.22 — 2026-10-01
6
87
 
7
88
  **修复:一个窗口聊两句、网页端多出三个会话;以及选「不删」反而触发一次批量删除。**
package/lib/index.js CHANGED
@@ -3426,17 +3426,43 @@ function effectiveReuseLimit(maxTurns, mode) {
3426
3426
  if (!(maxTurns > 0)) return maxTurns;
3427
3427
  return mode === "chained" ? Number.POSITIVE_INFINITY : maxTurns;
3428
3428
  }
3429
- /** 严格前缀:prev 是 next 的前缀(含相等时不算"追加")。 */
3430
- function isStrictPrefix(prev, next) {
3429
+ /**
3430
+ * 本轮能不能接着链往下发(替代旧的"严格前缀"判据,2026-10-01)。
3431
+ *
3432
+ * ## 旧判据为什么会毁掉整个链式投喂
3433
+ *
3434
+ * 旧实现要求**严格前缀**:`prev[i] === next[i]` 对全部 i 成立。只要有**一条**被改写,
3435
+ * 整条链就作废 ⇒ `parent=null` ⇒ 网页端出现同层根消息(「修改 / 重新生成」+ `n / n`),
3436
+ * 或者(0.6.10~0.6.21)直接换一个新会话。
3437
+ *
3438
+ * 而实测 DSH **每一轮**都会重写它自己注入的那条运行时快照,原话就在会话日志里:
3439
+ * `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.`
3440
+ * —— 它是**替换式**的(位置不变、内容每轮变),所以"严格前缀"在 DSH 下几乎每轮都失败。
3441
+ *
3442
+ * ## 新判据(够严,但不至于被一条注入打穿)
3443
+ *
3444
+ * 1. **必须真的变长** —— 长度没增长时说不清是"重发"还是"历史被截断/压缩",保守退回;
3445
+ * 2. **链尾仍在原位**(`next[prev.length-1] === prev[prev.length-1]`)——
3446
+ * 这条同时挡住了两种真正危险的情况:整体重写、以及在中途**插入/删除**条目
3447
+ * (那会让增量切错位置、漏掉内容)。
3448
+ *
3449
+ * 满足这两条时,中间的差异只可能是**原位替换**(就是那条运行时快照),
3450
+ * 而增量按 `next.slice(prev.length)` 切出来的正好是**真正的新内容** —— 长度对齐,
3451
+ * 被替换的那条不会挤进增量里。
3452
+ */
3453
+ function canExtendChain(prev, next) {
3431
3454
  if (next.length <= prev.length) return false;
3432
- for (let i = 0; i < prev.length; i += 1) if (prev[i] !== next[i]) return false;
3433
- return true;
3455
+ return next[prev.length - 1] === prev[prev.length - 1];
3434
3456
  }
3435
3457
  /**
3436
3458
  * 决定本轮发什么。**纯函数**:不读文件、不看时间、不改全局状态。
3437
3459
  *
3438
- * 判据宁可保守:只要能续链就发增量,任何一处不确定都退回"全量 + parent=null"
3439
- * (退回去只是多花点 token,和以前行为一致;错续链则会让模型上下文错位,代价大得多)。
3460
+ * 判据宁可保守:只要能续链就发增量,任何一处不确定就**重发全量**。
3461
+ *
3462
+ * ⚠️ 但"重发全量"**不等于**发根消息(0.6.23):只要链还在(同一个网页端会话、同一个账号),
3463
+ * 重发的那条仍然挂在**链尾**(见 `replay`)—— 发 `parent=null` 会让网页端把它渲染成
3464
+ * 同层的另一条消息(「修改 / 重新生成」+ `n / n`),也就是"聊着聊着分叉了"。
3465
+ * 只有**真的没有链**时(新会话 / 换了会话 / 换了账号)才发根消息(见 `detach`)。
3440
3466
  */
3441
3467
  function decideFeed(input) {
3442
3468
  const full = input.full;
@@ -3454,7 +3480,8 @@ function decideFeed(input) {
3454
3480
  next: void 0,
3455
3481
  reason: "no-parts"
3456
3482
  };
3457
- const restart = (reason) => ({
3483
+ /** 没有可用的链(= 这个网页端会话的第一条消息)⇒ 只能发根消息,这是唯一合法的情况。 */
3484
+ const detach = (reason) => ({
3458
3485
  prompt: full,
3459
3486
  parentMessageId: null,
3460
3487
  next: {
@@ -3465,20 +3492,40 @@ function decideFeed(input) {
3465
3492
  },
3466
3493
  reason
3467
3494
  });
3468
- if (!input.reused) return restart("new-session");
3495
+ /**
3496
+ * 「链还在,但这一轮不能只发增量」⇒ 重发全量,但仍**挂在链尾**。
3497
+ *
3498
+ * 🔴 这是 2026-10-01 用户明确要求的取舍:**宁可多发一次,也绝不发根消息**。
3499
+ * 旧实现这里一律 `parentMessageId: null` —— 网页端会把这条渲染成**同层的另一条消息**
3500
+ * (「修改 / 重新生成」+ `n / n` 翻页),用户看到的就是"聊着聊着分叉了"。
3501
+ * 挂在链尾的代价只是服务端上下文里多一段重复历史(多花一点 token),
3502
+ * 而分叉是**结构性**的坏:它会永久破坏"一个窗口一条对话线"这个形态。
3503
+ */
3504
+ const replay = (reason) => ({
3505
+ prompt: full,
3506
+ parentMessageId: chain.parentId,
3507
+ next: {
3508
+ head,
3509
+ entries: entries.slice(),
3510
+ sessionId: input.sessionId,
3511
+ accountKey: input.accountKey
3512
+ },
3513
+ reason
3514
+ });
3515
+ if (!input.reused) return detach("new-session");
3469
3516
  const chain = input.chain;
3470
- if (!chain) return restart("no-chain");
3471
- if (chain.sessionId !== input.sessionId) return restart("session-changed");
3472
- if (chain.accountKey !== input.accountKey) return restart("account-changed");
3473
- if (chain.head !== head) return restart("head-changed");
3474
- if (!isStrictPrefix(chain.entries, entries)) return restart("not-appended");
3517
+ if (!chain) return detach("no-chain");
3518
+ if (chain.sessionId !== input.sessionId) return detach("session-changed");
3519
+ if (chain.accountKey !== input.accountKey) return detach("account-changed");
3520
+ if (chain.head !== head) return replay("head-changed");
3521
+ if (!canExtendChain(chain.entries, entries)) return replay("not-appended");
3475
3522
  const appended = entries.slice(chain.entries.length);
3476
3523
  const meaningful = appended.some((line) => isContinuationCue(line)) ? appended : appended.filter((line) => !isAssistantTranscriptEntry(line));
3477
3524
  const echoDropped = meaningful.length > 0 ? appended.length - meaningful.length : 0;
3478
3525
  const delta = (meaningful.length > 0 ? meaningful : appended).join("\n\n");
3479
- if (delta.trim().length === 0) return restart("empty-delta");
3526
+ if (delta.trim().length === 0) return replay("empty-delta");
3480
3527
  const cap = input.maxChars;
3481
- if (typeof cap === "number" && Number.isFinite(cap) && cap > 0 && delta.length > cap) return restart("delta-too-long");
3528
+ if (typeof cap === "number" && Number.isFinite(cap) && cap > 0 && delta.length > cap) return replay("delta-too-long");
3482
3529
  return {
3483
3530
  prompt: delta,
3484
3531
  parentMessageId: chain.parentId,
@@ -5143,16 +5190,27 @@ async function leaseSession(auth, signal, transport, maxTurns, cleanup, forceNew
5143
5190
  reused: false
5144
5191
  };
5145
5192
  }
5146
- /** 槽数超过上限时,把**最久没用**的那些连同它们的网页端会话一起淘汰掉。 */
5193
+ /**
5194
+ * 槽数超过上限时,把**最久没用**的那些从内存里淘汰掉。
5195
+ *
5196
+ * 🔴 0.6.23:这里**不再删网页端会话**(旧实现做 `retireSession` + `cleanup`,也就是排队 DELETE)。
5197
+ *
5198
+ * 两个理由,都是用户明确提的:
5199
+ * 1. **"回到原来的窗口还要能接着聊"** —— 把会话删了,等于把他那个窗口的上下文扔了;
5200
+ * 他只能得到一个空白的新会话。槽是内存态、淘汰本来就不该有"销毁用户数据"的副作用。
5201
+ * 2. **"删"本身是最强的机器特征之一**:真人不会每开一个新窗口就顺手删掉一个旧对话。
5202
+ * 网页端留着的会话由用户自己管理(面板有「立即清理」,设置里也有清理策略)。
5203
+ *
5204
+ * 链一并清掉:槽没了的窗口下一轮会拿到**新会话**(`reused=false` ⇒ `detach('new-session')`),
5205
+ * 旧链再留着只会指向一个这个窗口已经不用的会话。
5206
+ */
5147
5207
  function evictIdleSlots() {
5148
5208
  if (reuseSlots.size <= 6) return;
5149
5209
  const idle = [...reuseSlots.values()].sort((a, b) => a.at - b.at);
5150
5210
  for (const slot of idle.slice(0, reuseSlots.size - 6)) {
5151
5211
  reuseSlots.delete(slot.key);
5152
- retireSession(slot.sessionId);
5153
- try {
5154
- slot.cleanup?.(slot.sessionId);
5155
- } catch {}
5212
+ contextChains.delete(slot.key);
5213
+ if (lastChainKey === slot.key) lastChainKey = void 0;
5156
5214
  }
5157
5215
  }
5158
5216
  /** 把某个会话从复用槽里摘掉(会话失效 / 请求失败时调用,下次会新建)。 */
@@ -5254,7 +5312,7 @@ async function openCompletion(auth, params, signal, transport) {
5254
5312
  lastFeedReason = feed.reason;
5255
5313
  params.onContextFeed?.({
5256
5314
  reason: feed.reason,
5257
- chained: feed.parentMessageId !== null,
5315
+ chained: feed.reason === "chained",
5258
5316
  promptChars: feed.prompt.length,
5259
5317
  echoDropped: feed.echoDropped ?? 0
5260
5318
  });
@@ -8980,12 +9038,78 @@ async function shutdownBrowserTransport() {
8980
9038
  activeSession = void 0;
8981
9039
  launchPromise = void 0;
8982
9040
  }
8983
- function bodyToPageInit(body) {
9041
+ /** 页面上下文里的辅助函数名(把 base64 还原成字节)。 */
9042
+ const B64_HELPER = "__dshB64ToBytes";
9043
+ /** 页面上下文里的辅助函数名(把序列化过的 parts 重建成 FormData)。 */
9044
+ const FORMDATA_HELPER = "__dshRebuildFormData";
9045
+ /** 页面里用到的两个小工具(随每次 evaluate 一起注入,保持无状态)。 */
9046
+ const PAGE_HELPERS = `
9047
+ const ${B64_HELPER} = (b64) => {
9048
+ const bin = atob(b64);
9049
+ const out = new Uint8Array(bin.length);
9050
+ for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
9051
+ return out;
9052
+ };
9053
+ const ${FORMDATA_HELPER} = (parts) => {
9054
+ const form = new FormData();
9055
+ for (const p of parts) {
9056
+ if (p.t === 'text') form.append(p.name, p.value);
9057
+ else form.append(p.name, new File([${B64_HELPER}(p.b64)], p.filename, { type: p.type }));
9058
+ }
9059
+ return form;
9060
+ };
9061
+ `;
9062
+ /**
9063
+ * 把 Node 侧的请求体转成"能在页面里重建它"的表达式。
9064
+ *
9065
+ * 🔴 0.6.20 的第一版只支持 string / Uint8Array / ArrayBuffer,遇到 FormData 直接抛
9066
+ * "暂不支持" —— 而**图片上传走的就是 FormData(multipart)**(见 `webapi.uploadImageFile`),
9067
+ * 于是"切到浏览器代理传输层之后图片全传不上去"(2026-10-01 用户报,报错文案就来自这里)。
9068
+ *
9069
+ * 0.6.24:FormData / Blob 都按**字节**搬过去,在页面里重建。
9070
+ * 细节:
9071
+ * - 用 **base64** 而不是 JSON 数组传字节(体积约为 1/2.4,CDP 消息不至于被图片撑爆);
9072
+ * - 用 `new File(...)` 而不是 `new Blob(...)` —— 必须保住 **filename**,
9073
+ * 服务端是按**文件名后缀**判图片类型的(见 protocol.ts 的说明);
9074
+ * - 语言无关地遍历 FormData 的 entry:`TextPart` 原样作为字段,其余一律当文件。
9075
+ */
9076
+ async function bodyToPageInit(body) {
8984
9077
  if (body === void 0 || body === null) return "undefined";
8985
9078
  if (typeof body === "string") return JSON.stringify(body);
8986
- if (body instanceof Uint8Array) return `new Uint8Array(${JSON.stringify(Array.from(body))})`;
8987
- if (body instanceof ArrayBuffer) return `new Uint8Array(${JSON.stringify(Array.from(new Uint8Array(body)))})`;
8988
- throw new Error("浏览器代理传输层暂不支持 Blob/FormData/ReadableStream 请求体");
9079
+ if (body instanceof Uint8Array) return `${B64_HELPER}(${JSON.stringify(Buffer.from(body).toString("base64"))})`;
9080
+ if (body instanceof ArrayBuffer) return `${B64_HELPER}(${JSON.stringify(Buffer.from(new Uint8Array(body)).toString("base64"))})`;
9081
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
9082
+ const raw = [];
9083
+ body.forEach((value, name) => {
9084
+ raw.push([name, value]);
9085
+ });
9086
+ const parts = [];
9087
+ for (const [name, value] of raw) {
9088
+ if (typeof value === "string") {
9089
+ parts.push({
9090
+ t: "text",
9091
+ name,
9092
+ value
9093
+ });
9094
+ continue;
9095
+ }
9096
+ const blob = value;
9097
+ const b64 = Buffer.from(new Uint8Array(await blob.arrayBuffer())).toString("base64");
9098
+ parts.push({
9099
+ t: "file",
9100
+ name,
9101
+ filename: blob.name || name || "blob",
9102
+ type: blob.type || "application/octet-stream",
9103
+ b64
9104
+ });
9105
+ }
9106
+ return `${FORMDATA_HELPER}(${JSON.stringify(parts)})`;
9107
+ }
9108
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
9109
+ const b64 = Buffer.from(new Uint8Array(await body.arrayBuffer())).toString("base64");
9110
+ return `new Blob([${B64_HELPER}(${JSON.stringify(b64)})], { type: ${JSON.stringify(body.type || "application/octet-stream")} })`;
9111
+ }
9112
+ throw new Error("浏览器代理传输层不支持的请求体类型(支持 string / Uint8Array / ArrayBuffer / FormData / Blob)");
8989
9113
  }
8990
9114
  function createBrowserFetch() {
8991
9115
  return async (input, init) => {
@@ -9000,7 +9124,7 @@ function createBrowserFetch() {
9000
9124
  else if (Array.isArray(init.headers)) for (const [key, value] of init.headers) headers[key] = value;
9001
9125
  else for (const [key, value] of Object.entries(init.headers)) headers[key] = String(value);
9002
9126
  }
9003
- const bodyExpr = bodyToPageInit(init?.body);
9127
+ const bodyExpr = await bodyToPageInit(init?.body);
9004
9128
  const requestId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
9005
9129
  const headersPromise = new Promise((resolve, reject) => {
9006
9130
  const state = {
@@ -9026,6 +9150,7 @@ function createBrowserFetch() {
9026
9150
  });
9027
9151
  const expression = `
9028
9152
  (async () => {
9153
+ ${PAGE_HELPERS}
9029
9154
  const requestId = ${JSON.stringify(requestId)};
9030
9155
  try {
9031
9156
  const init = {
@@ -9952,7 +10077,7 @@ async function checkForUpdate(current, fetchImpl) {
9952
10077
  * 兜底常量与 package.json 的一致性由 `tests/check-smoke.mjs` 守着,不会漂。
9953
10078
  */
9954
10079
  /** 与 package.json 保持一致的兜底版本(由测试保证不会漂)。 */
9955
- const FALLBACK_VERSION = "0.6.22";
10080
+ const FALLBACK_VERSION = "0.6.24";
9956
10081
  let cached;
9957
10082
  /** 本插件版本(如 `0.1.26`)。 */
9958
10083
  function pluginVersion() {