dsh-deepseek-web-login 0.6.23 → 0.6.25

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,76 @@
2
2
 
3
3
  本项目大致遵循语义化版本;日期为本地时间。
4
4
 
5
+ ## 0.6.25 — 2026-10-01
6
+
7
+ **新增:投喂决策留痕(`feed-decisions.jsonl`)—— 把"这一轮为什么没走增量"变成可查的事实。**
8
+
9
+ ### 为什么要有
10
+
11
+ 判据只在**原因变化时**打 `链式投喂退回全量重发(原因=X)`,而**宿主日志不落盘**。
12
+ 2026-10-01 两次排查("每轮都发全量"、"同一个窗口又多一个会话")都被这一点卡住,
13
+ 最后只能往**已安装的产物**里插桩 —— 那是能查,但每次都要占一个发版周期。
14
+
15
+ ### 做了什么
16
+
17
+ 每一轮决策追加一行到 `~/.dsh/web-login/feed-decisions.jsonl`(环形保留 300 条,超 256 KB 自动裁剪):
18
+
19
+ | 字段 | 含义 |
20
+ |---|---|
21
+ | `reason` | `chained`(真的只发增量)或退回全量的具体原因 |
22
+ | `reused` | 这一轮的网页端会话是不是复用来的 |
23
+ | `session` / `account` | 会话与账号键前 8 位 —— **切号会让 `account` 变**,一眼看出"多出来的会话"是不是换号造成的 |
24
+ | `chainLen` / `entriesLen` | 链里已发的条目数 / 本轮条目数 |
25
+ | `tailSame` | **链尾是否仍在本轮的同一位置** —— `canExtendChain` 第二条判据的直接答案,`false` 就是"DSH 改写了历史"的实锤 |
26
+
27
+ ⚠️ 只记**结构性事实**(长度 / 原因 / 是否复用),不记任何内容。
28
+
29
+ ### 验证
30
+
31
+ - 新增一条端到端用例:跑三轮(起链 → 严格追加 → 历史被改写),
32
+ 断言落盘三条、`reason` 依次为 `new-session` / `chained` / `not-appended`,
33
+ 且 `tailSame` 为 `null` / `true` / `false`
34
+ - 变异反向验证:把 `tailSame` 写死成 `null` ⇒ 用例变红、`rc=1`
35
+ - `tsc --noEmit`、`npm run test`(60/60)、`check-smoke`、`check-bundle` 全通过
36
+
37
+ ## 0.6.24 — 2026-10-01
38
+
39
+ **修复:浏览器代理传输层不支持 `FormData` ⇒ 图片全部上传失败。**
40
+
41
+ ### 现象
42
+
43
+ 切到浏览器代理传输层(0.6.20 起,官方 DSH 桌面端拿不到 `electron.net.fetch` 时的默认路径)后,
44
+ 粘贴图片会得到:
45
+
46
+ ```
47
+ 有 1 张图片没能传给模型(DeepSeek 图片上传失败:浏览器代理传输层暂不支持
48
+ Blob/FormData/ReadableStream 请求体),本轮回答只基于文字内容。
49
+ ```
50
+
51
+ ### 根因
52
+
53
+ 0.6.20 的 `bodyToPageInit()` 只把 `string` / `Uint8Array` / `ArrayBuffer` 转成页面侧可重建的表达式,
54
+ 遇到别的类型直接抛错。而**图片上传走的正是 `FormData`(multipart)**
55
+ (`webapi.uploadImageFile` 里 `form.append('file', new Blob(...), name)`)。
56
+
57
+ ### 修法
58
+
59
+ `bodyToPageInit()` 现在支持 `FormData` 与 `Blob`,做法是把内容按**字节**搬到页面里重建:
60
+
61
+ - 字节用 **base64** 传(而非 JSON 数组)—— 体积约为 1/2.4,CDP 消息不会被大图撑爆;
62
+ - 页面侧注入两个小工具:`__dshB64ToBytes`(base64 → Uint8Array)与
63
+ `__dshRebuildFormData`(按 parts 重建 `FormData`);
64
+ - 文件部分用 **`new File(...)`** 而不是 `new Blob(...)` —— 必须保住 **filename**,
65
+ 因为服务端按**文件名后缀**判图片类型(见 `protocol.ts`);
66
+ - 语言无关地遍历 `FormData`(用 `forEach`,不用 `entries()` —— 后者要 `DOM.Iterable` lib)。
67
+
68
+ ### 验证
69
+
70
+ - 新增一条**真实往返**用例:启本地 HTTP 服务,用 200 KB 的假 PNG 走 `FormData` 上传,
71
+ 断言 `content-type` 带 boundary、`filename` 保住、PNG 魔数(8 字节)原样到达
72
+ - 变异反向验证:把 `FormData` 分支禁用 ⇒ 用例变红、`rc=1`,报错信息与用户截图**逐字一致**
73
+ - `tsc --noEmit`、`npm run test`(60/60)、`check-smoke`、`check-bundle` 全通过
74
+
5
75
  ## 0.6.23 — 2026-10-01
6
76
 
7
77
  **一个窗口 = 一条对话线:不再断链、不再分叉、不再删会话。**
package/lib/index.js CHANGED
@@ -5109,6 +5109,33 @@ let sentRefIdsSession;
5109
5109
  * 同时"哪一轮开始退回全量、为什么"又一定能看见。
5110
5110
  */
5111
5111
  let lastFeedReason;
5112
+ /** 投喂决策留痕的路径(与 gate.json 同目录)。 */
5113
+ function feedDecisionLogPath() {
5114
+ return join(process.env.DSH_HOME || join(homedir(), ".dsh"), "web-login", "feed-decisions.jsonl");
5115
+ }
5116
+ /** 超过这个字节数就裁剪一次(`statSync` 很便宜,不必每次读全文件)。 */
5117
+ const FEED_DECISION_MAX_BYTES = 262144;
5118
+ /**
5119
+ * 记一轮投喂决策。
5120
+ *
5121
+ * 为什么必须有它(2026-10-01 两次排查都被它卡住):
5122
+ * 判据只在**原因变化时**打 `链式投喂退回全量重发(原因=X)`,而**宿主日志不落盘** ——
5123
+ * 真机上根本查不到"这一轮为什么没走增量",最后只能往已安装的产物里插桩。
5124
+ * 把决策要素落成 jsonl 之后,直接读文件就能回答,不必再占一个发版周期。
5125
+ *
5126
+ * ⚠️ 只记**结构性事实**(长度 / 原因 / 是否复用 / 链尾是否还在原位),不记内容。
5127
+ */
5128
+ function noteFeedDecision(note) {
5129
+ try {
5130
+ const file = feedDecisionLogPath();
5131
+ mkdirSync(join(file, ".."), { recursive: true });
5132
+ appendFileSync(file, `${JSON.stringify(note)}\n`, "utf8");
5133
+ if (statSync(file).size > FEED_DECISION_MAX_BYTES) {
5134
+ const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
5135
+ writeFileSync(file, `${lines.slice(-300).join("\n")}\n`, "utf8");
5136
+ }
5137
+ } catch {}
5138
+ }
5112
5139
  /** 丢弃当前的链(会话退役/测试隔离用)。 */
5113
5140
  function resetContextChain() {
5114
5141
  contextChains.clear();
@@ -5308,6 +5335,20 @@ async function openCompletion(auth, params, signal, transport) {
5308
5335
  };
5309
5336
  }
5310
5337
  const sessionId = lease.sessionId;
5338
+ {
5339
+ const chainEntries = contextChains.get(slotKey)?.entries;
5340
+ const currentEntries = params.promptParts?.entries;
5341
+ noteFeedDecision({
5342
+ at: Date.now(),
5343
+ reason: feed.reason,
5344
+ reused: lease.reused,
5345
+ session: String(sessionId).slice(0, 8),
5346
+ account: accountKey(auth),
5347
+ chainLen: chainEntries ? chainEntries.length : null,
5348
+ entriesLen: currentEntries ? currentEntries.length : null,
5349
+ tailSame: !chainEntries || !currentEntries ? null : currentEntries.length <= chainEntries.length ? false : currentEntries[chainEntries.length - 1] === chainEntries[chainEntries.length - 1]
5350
+ });
5351
+ }
5311
5352
  if (feed.reason !== lastFeedReason) {
5312
5353
  lastFeedReason = feed.reason;
5313
5354
  params.onContextFeed?.({
@@ -9038,12 +9079,78 @@ async function shutdownBrowserTransport() {
9038
9079
  activeSession = void 0;
9039
9080
  launchPromise = void 0;
9040
9081
  }
9041
- function bodyToPageInit(body) {
9082
+ /** 页面上下文里的辅助函数名(把 base64 还原成字节)。 */
9083
+ const B64_HELPER = "__dshB64ToBytes";
9084
+ /** 页面上下文里的辅助函数名(把序列化过的 parts 重建成 FormData)。 */
9085
+ const FORMDATA_HELPER = "__dshRebuildFormData";
9086
+ /** 页面里用到的两个小工具(随每次 evaluate 一起注入,保持无状态)。 */
9087
+ const PAGE_HELPERS = `
9088
+ const ${B64_HELPER} = (b64) => {
9089
+ const bin = atob(b64);
9090
+ const out = new Uint8Array(bin.length);
9091
+ for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
9092
+ return out;
9093
+ };
9094
+ const ${FORMDATA_HELPER} = (parts) => {
9095
+ const form = new FormData();
9096
+ for (const p of parts) {
9097
+ if (p.t === 'text') form.append(p.name, p.value);
9098
+ else form.append(p.name, new File([${B64_HELPER}(p.b64)], p.filename, { type: p.type }));
9099
+ }
9100
+ return form;
9101
+ };
9102
+ `;
9103
+ /**
9104
+ * 把 Node 侧的请求体转成"能在页面里重建它"的表达式。
9105
+ *
9106
+ * 🔴 0.6.20 的第一版只支持 string / Uint8Array / ArrayBuffer,遇到 FormData 直接抛
9107
+ * "暂不支持" —— 而**图片上传走的就是 FormData(multipart)**(见 `webapi.uploadImageFile`),
9108
+ * 于是"切到浏览器代理传输层之后图片全传不上去"(2026-10-01 用户报,报错文案就来自这里)。
9109
+ *
9110
+ * 0.6.24:FormData / Blob 都按**字节**搬过去,在页面里重建。
9111
+ * 细节:
9112
+ * - 用 **base64** 而不是 JSON 数组传字节(体积约为 1/2.4,CDP 消息不至于被图片撑爆);
9113
+ * - 用 `new File(...)` 而不是 `new Blob(...)` —— 必须保住 **filename**,
9114
+ * 服务端是按**文件名后缀**判图片类型的(见 protocol.ts 的说明);
9115
+ * - 语言无关地遍历 FormData 的 entry:`TextPart` 原样作为字段,其余一律当文件。
9116
+ */
9117
+ async function bodyToPageInit(body) {
9042
9118
  if (body === void 0 || body === null) return "undefined";
9043
9119
  if (typeof body === "string") return JSON.stringify(body);
9044
- if (body instanceof Uint8Array) return `new Uint8Array(${JSON.stringify(Array.from(body))})`;
9045
- if (body instanceof ArrayBuffer) return `new Uint8Array(${JSON.stringify(Array.from(new Uint8Array(body)))})`;
9046
- throw new Error("浏览器代理传输层暂不支持 Blob/FormData/ReadableStream 请求体");
9120
+ if (body instanceof Uint8Array) return `${B64_HELPER}(${JSON.stringify(Buffer.from(body).toString("base64"))})`;
9121
+ if (body instanceof ArrayBuffer) return `${B64_HELPER}(${JSON.stringify(Buffer.from(new Uint8Array(body)).toString("base64"))})`;
9122
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
9123
+ const raw = [];
9124
+ body.forEach((value, name) => {
9125
+ raw.push([name, value]);
9126
+ });
9127
+ const parts = [];
9128
+ for (const [name, value] of raw) {
9129
+ if (typeof value === "string") {
9130
+ parts.push({
9131
+ t: "text",
9132
+ name,
9133
+ value
9134
+ });
9135
+ continue;
9136
+ }
9137
+ const blob = value;
9138
+ const b64 = Buffer.from(new Uint8Array(await blob.arrayBuffer())).toString("base64");
9139
+ parts.push({
9140
+ t: "file",
9141
+ name,
9142
+ filename: blob.name || name || "blob",
9143
+ type: blob.type || "application/octet-stream",
9144
+ b64
9145
+ });
9146
+ }
9147
+ return `${FORMDATA_HELPER}(${JSON.stringify(parts)})`;
9148
+ }
9149
+ if (typeof Blob !== "undefined" && body instanceof Blob) {
9150
+ const b64 = Buffer.from(new Uint8Array(await body.arrayBuffer())).toString("base64");
9151
+ return `new Blob([${B64_HELPER}(${JSON.stringify(b64)})], { type: ${JSON.stringify(body.type || "application/octet-stream")} })`;
9152
+ }
9153
+ throw new Error("浏览器代理传输层不支持的请求体类型(支持 string / Uint8Array / ArrayBuffer / FormData / Blob)");
9047
9154
  }
9048
9155
  function createBrowserFetch() {
9049
9156
  return async (input, init) => {
@@ -9058,7 +9165,7 @@ function createBrowserFetch() {
9058
9165
  else if (Array.isArray(init.headers)) for (const [key, value] of init.headers) headers[key] = value;
9059
9166
  else for (const [key, value] of Object.entries(init.headers)) headers[key] = String(value);
9060
9167
  }
9061
- const bodyExpr = bodyToPageInit(init?.body);
9168
+ const bodyExpr = await bodyToPageInit(init?.body);
9062
9169
  const requestId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
9063
9170
  const headersPromise = new Promise((resolve, reject) => {
9064
9171
  const state = {
@@ -9084,6 +9191,7 @@ function createBrowserFetch() {
9084
9191
  });
9085
9192
  const expression = `
9086
9193
  (async () => {
9194
+ ${PAGE_HELPERS}
9087
9195
  const requestId = ${JSON.stringify(requestId)};
9088
9196
  try {
9089
9197
  const init = {
@@ -10010,7 +10118,7 @@ async function checkForUpdate(current, fetchImpl) {
10010
10118
  * 兜底常量与 package.json 的一致性由 `tests/check-smoke.mjs` 守着,不会漂。
10011
10119
  */
10012
10120
  /** 与 package.json 保持一致的兜底版本(由测试保证不会漂)。 */
10013
- const FALLBACK_VERSION = "0.6.23";
10121
+ const FALLBACK_VERSION = "0.6.25";
10014
10122
  let cached;
10015
10123
  /** 本插件版本(如 `0.1.26`)。 */
10016
10124
  function pluginVersion() {