@nightpainters/dsh-ling 1.5.2 → 1.5.3

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/lib/client.js CHANGED
@@ -27,6 +27,67 @@
27
27
 
28
28
  var API = '/api/dsh-ling';
29
29
 
30
+ // ---------------- 导入切批(纯函数,无副作用) ----------------
31
+ // S9(2026-09-29 导入批失败 4MiB):服务端 readBody() 按【字节】封顶 4MiB
32
+ // (lib/host/api.js:139),而旧客户端按【条数】切批(batch = 200)⇒ 两端判据不同单位:
33
+ // 186 条 / 4.89MB 被打成整批一发 ⇒ 超限 ⇒ 连接被 reset ⇒ 界面错报「批失败(网关)」。
34
+ // 现改为【条数 × 字节】双限:字节上限留 1MiB 余量给数组封套与 JSON 转义。
35
+ // ⚠️ 这两个函数必须留在 factory 作用域(不能塞进 bGo.onclick):V1 用例经
36
+ // tests/vm-light.mjs 的 contextify 加载本文件后,从 exports.__test__ 取名直调。
37
+ var BATCH_MAX_BYTES = 3 * 1024 * 1024; // 服务端 4 MiB;留 1 MiB 余量给封套与转义
38
+ var BATCH_MAX_ITEMS = 200; // 保留旧条数上限(小条目时行为与旧版一致)
39
+ // 精确 UTF-8 字节数。⚠️ 不许用 s.length 冒充(CJK 3 字节、emoji 代理对 4 字节),
40
+ // 也不许依赖 TextEncoder/TextDecoder —— 本文件要能在测试沙箱
41
+ // (tests/vm-light.mjs 的白名单式 sandbox,无 TextEncoder)里跑。
42
+ function utf8Len(s) {
43
+ var t = s === null || s === undefined ? '' : String(s);
44
+ var n = 0;
45
+ for (var i = 0; i < t.length; i++) {
46
+ var c = t.charCodeAt(i);
47
+ if (c < 0x80) n += 1;
48
+ else if (c < 0x800) n += 2;
49
+ else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < t.length) {
50
+ var d = t.charCodeAt(i + 1);
51
+ if (d >= 0xDC00 && d <= 0xDFFF) { n += 4; i += 1; } // 代理对(emoji 等)
52
+ else n += 3; // 落单高代理 ⇒ U+FFFD(3 字节)
53
+ } else n += 3; // BMP 3 字节 / 落单低代理 ⇒ U+FFFD
54
+ }
55
+ return n;
56
+ }
57
+ // 字节 ⇒ 人读口径(C2 进度文案用)
58
+ function fmtBytes(n) {
59
+ var b = Number(n) || 0;
60
+ if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
61
+ return (b / 1024 / 1024).toFixed(1) + ' MB';
62
+ }
63
+ /** 服务端**回执里的**单次上限 ⇒ 人话(B / 2026-09-29:413 文案要说清"上限是多少")。
64
+ * ⚠️ 数字**只许来自回执**(`r.limit`):客户端不镜像服务端的那个常量(C5 / C-1 的同一条命门 ——
65
+ * 两处各写一个"4 MB",将来服务端改上限时必漂移);回执没带就如实说"没给",绝不猜一个数顶上。 */
66
+ function fmtLimitMB(limit) {
67
+ var n = Number(limit);
68
+ if (!(n > 0)) return '上限未随回执返回';
69
+ return (Math.round(n / 1024 / 1024 * 10) / 10) + ' MB';
70
+ }
71
+ // 从 from 起切一批 ⇒ { list, next, bytes }。bytes 口径 = JSON.stringify(每条)的
72
+ // UTF-8 字节 + 1(数组逗号),外加 2(方括号)—— 与契约一致(偏保守:末条逗号也计入)。
73
+ // 前进性硬保证:首条即使自己就超 BATCH_MAX_BYTES 也单独成批,绝不返回空 list
74
+ // (空批 ⇒ 调用方 idx 不前进 ⇒ 死循环)。
75
+ function takeChunk(items, from) {
76
+ var list = [];
77
+ var bytes = 2; // 数组封套 []
78
+ var i = from;
79
+ while (i < items.length && list.length < BATCH_MAX_ITEMS) {
80
+ var n = 0;
81
+ try { n = utf8Len(JSON.stringify(items[i])); } catch (e) { n = 0; } // 循环引用等:交给服务端拒
82
+ var cost = n + 1; // 数组逗号
83
+ if (list.length && bytes + cost > BATCH_MAX_BYTES) break;
84
+ list.push(items[i]);
85
+ bytes += cost;
86
+ i += 1;
87
+ }
88
+ return { list: list, next: i, bytes: bytes };
89
+ }
90
+
30
91
  // ---------------- 第三人称代词(跟随 persona.pronoun) ----------------
31
92
  // 界面文案里写死的「她」全部经 she() 出口 —— 用户改成「他 / TA / 自定义」后文案随之改变;
32
93
  // **只用于文案**,绝不用于用户原话或记忆数据(那会把数据改错)。
@@ -101,13 +162,49 @@
101
162
  return parts.length ? '?' + parts.join('&') : '';
102
163
  }
103
164
 
165
+ // ── 手术门 · 本机通行票据(2026-09-30 深夜;规格 §1.2 的客户端那一半)──────────────
166
+ // 宿主每启动轮换一枚 32 字节票据;**只有本机直连**拿得到(带代理头的请求会被拒)。
167
+ // 这一半只做"取一次 + 缓存 + 随手术请求带上",**不动任何弹窗与文案结构**(Phase 1 的边界)。
168
+ // 取不到(经反代打开的页面 / 旧宿主)= 空串 ⇒ 手术请求照旧发,由宿主那边给出明确拒绝。
169
+ var surgeryTicket = '';
170
+ var surgeryTicketTried = false;
171
+ function fetchSurgeryTicket() {
172
+ surgeryTicketTried = true; // 无论成败都只试一次(失败不重试,免得把拒绝变成刷屏)
173
+ return fetch(API + '/surgery/ticket', { method: 'POST', headers: { 'content-type': 'application/json' } })
174
+ .then(function (r) { return r.json().catch(function () { return null; }); })
175
+ .then(function (j) {
176
+ if (j && j.ok && typeof j.ticket === 'string' && j.ticket) { surgeryTicket = j.ticket; return surgeryTicket; }
177
+ return ''; // 远端 / 未定型:没有票,照常走后面的拒绝路径
178
+ })
179
+ .catch(function () { return ''; });
180
+ }
181
+
104
182
  function api(path, opts) {
105
183
  opts = opts || {};
184
+ var headers = { 'content-type': 'application/json' };
185
+ if (surgeryTicket) headers['x-dsh-ling-surgery'] = surgeryTicket;
106
186
  return fetch(API + path, {
107
187
  method: opts.method || 'GET',
108
- headers: { 'content-type': 'application/json' },
188
+ headers: headers,
109
189
  body: opts.body ? JSON.stringify(opts.body) : undefined,
110
- }).then(function (res) { return res.json().catch(function () { return null; }); });
190
+ }).then(function (res) {
191
+ return res.json().catch(function () { return null; });
192
+ }).then(function (j) {
193
+ // 宿主说"票据不对/没票"⇒ 现场取一次,原样重发一次(只重发一次,不做循环)。
194
+ // 票据每启动轮换 ⇒ 页面放久了会遇到这一支,那时用户不必手动刷新。
195
+ if (j && j.ok === false && j.surgery === 'surgery-ticket' && !surgeryTicketTried) {
196
+ return fetchSurgeryTicket().then(function (t) {
197
+ if (!t) return j;
198
+ var h2 = { 'content-type': 'application/json', 'x-dsh-ling-surgery': t };
199
+ return fetch(API + path, {
200
+ method: opts.method || 'GET',
201
+ headers: h2,
202
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
203
+ }).then(function (r2) { return r2.json().catch(function () { return null; }); });
204
+ });
205
+ }
206
+ return j;
207
+ });
111
208
  }
112
209
 
113
210
  // ---------------- tiny store ----------------
@@ -314,7 +411,7 @@
314
411
  // 缺陷③ 修复(2026-09-27 真机目视"小字略显拥挤,大字行距是对的"):上一轮我把小字**并进正文那一档**换来统一,
315
412
  // 层级就没了 ⇒ 小字挤。正解 = 行距写**绝对 px**(取旧块的计算行距 20.8px):绝对行距在子元素上继承的是
316
413
  // **计算值**,不随各自字号相乘 ⇒ 字号可以分三档,行距仍恒等 —— "统一"与"层级"同时成立。
317
- // (判据:_probe/check-e3-static.cjs 二c,逐档算实际行距并要求取值唯一、且等于旧块的计算值。)
414
+ // (判据:已由一次性静态探针逐档核算实际行距,要求取值唯一、且等于旧块的计算值。)
318
415
  '.ling-e3{margin:8px 0 2px;padding:8px 10px;border:1px solid var(--lg-border-soft);border-radius:10px;' +
319
416
  'background:var(--lg-rowbg);font-size:13px;line-height:20.8px;}' + // 正文档 13px(同旧块)+ **绝对**行距 = 旧块的计算行距
320
417
  '.ling-e3 .e3-h{font-weight:700;opacity:.95;margin-bottom:3px;}' + // 标题:正文档(继承 13px)
@@ -390,14 +487,23 @@
390
487
  }
391
488
 
392
489
  /** `ms` 可选:不传时沿用原样 3.2s(既有调用全部不传)。需要长一点的是**覆盖/归档**这类
393
- * "数据已经变了"的回执 —— 3.2s 读不完一句带归档目录的话(审计 E10)。 */
394
- function toast(msg, ms) {
490
+ * "数据已经变了"的回执 —— 3.2s 读不完一句带归档目录的话(审计 E10)。
491
+ *
492
+ * `wrap` 可选(B / 2026-09-29):`.dsh-ling-menu .mi` 是 `white-space:nowrap`(菜单项必须单行),
493
+ * 而**说得清一件事的长文案**在 nowrap 下会顶破 70vw 的盒子、文字溢到屏幕外 —— 那时"文案改好了"
494
+ * 反而读不到。故长文案的调用显式传 `wrap = true`:换成换行渲染 + 更宽的盒子 + 更长的寿命。
495
+ * 不传 = 与既有全部调用逐字同形(短文案本来就只占一行,样式分支根本不进)。 */
496
+ function toast(msg, ms, wrap) {
395
497
  var t = el('div', 'dsh-ling-menu');
396
- t.style.cssText = 'top:16px;left:50%;transform:translateX(-50%);cursor:default;min-width:auto;max-width:70vw;text-align:center;';
498
+ t.style.cssText = 'top:16px;left:50%;transform:translateX(-50%);cursor:default;min-width:auto;max-width:70vw;text-align:center;'
499
+ + (wrap ? 'width:620px;max-width:86vw;' : '');
397
500
  // 后端回执里的 `message` 允许带 `**强调**` 标记(如「**覆盖了上一版主脉记忆**」),
398
501
  // 而 toast 是纯文本容器 ⇒ 显示层去掉标记,别把星号糊给主人。
399
502
  // (非字符串原样交给 el(),不改变任何既有调用的行为:全文件现有 toast 文案里没有 `**`。)
400
- t.appendChild(el('div', 'mi', (typeof msg === 'string') ? msg.replace(/\*\*/g, '') : msg));
503
+ var mi = el('div', 'mi', (typeof msg === 'string') ? msg.replace(/\*\*/g, '') : msg);
504
+ // pre-line:只认 `\n` 换行,不动空格/缩进 —— 文案里手写的分行原样落地
505
+ if (wrap) mi.style.cssText = 'white-space:pre-line;line-height:1.65;text-align:left;align-items:flex-start;';
506
+ t.appendChild(mi);
401
507
  document.body.appendChild(t);
402
508
  setTimeout(function () { t.remove(); }, Number(ms) > 0 ? Number(ms) : 3200);
403
509
  }
@@ -689,6 +795,13 @@
689
795
  // 老后端没有这个字段 ⇒ undefined ⇒ 原样跳过,store 里也就没有 audit(各处一律按缺失处理)。
690
796
  // ⚠️ 刻意**不**把它加进 pollSig:writes/lost 每拍都在变,加进去等于把轮询永久钉在快节拍上。
691
797
  if (r.audit !== undefined) patch.audit = r.audit;
798
+ // E2 可见化(2026-10-01):原文表里出现**未知来源**时,后端在 /state 顶上给一句人话
799
+ // (`rawUnknownSources`,形状见 lib/host/api.js 的 rawUnknownSourcesOf)。
800
+ // 与 audit 同一条路搬进 store ⇒ 面板不必重开也看得见。
801
+ // ⚠️ 与 audit 的 `!== undefined` **不同**:这里显式归零(`|| null`)。audit 是新后端恒有、
802
+ // 老后端恒无;而这个字段**干净时就该消失**,留一个过期的"有未知来源"比不显示更坏。
803
+ // ⚠️ 刻意**不**进 pollSig(同 audit 的理由:它极少变,进去只会把轮询钉在快节拍上)。
804
+ patch.rawUnknownSources = r.rawUnknownSources || null;
692
805
  store.set(patch);
693
806
  }
694
807
 
@@ -1142,6 +1255,15 @@
1142
1255
  // 上面四行印的是 store 那一份(实时);store 里没有(老后端 / 首拍还没回来)才退回打开时那份 ⇒ 标快照。
1143
1256
  if (!auditLive) lines[lines.length - 1] += SNAP_TAG;
1144
1257
  }
1258
+ // E2 可见化(2026-10-01):原文表里出现了**我没见过的来源**时,如实说一句人话。
1259
+ // 后端(api.js rawUnknownSourcesOf)把 kv 里那条留痕翻成**一整句话**放在 `text` 里,
1260
+ // 这里**原样**印出来(前面加一个 ⚠)—— **不在这里拆字段拼第二遍**:两处各拼一遍必然漂,
1261
+ // 而"读数怎么算"的规矩是单一来源(见 guard.test.mjs 的 E2 护栏)。
1262
+ // ⚠️ 没有告警时后端**整个字段都不给** ⇒ 这里一个字符都不渲染(与上面 audit 同一条规矩:
1263
+ // 绝不拿"未知/正常"冒充读数)。老后端同理:字段缺失 = 静默。
1264
+ // ⚠️ 只加这一行:不动任何既有行、不动徽标/布局/文案风格(主人原话"告诉前台到底发生啥了就行")。
1265
+ var ru = cur.rawUnknownSources || r.rawUnknownSources;
1266
+ if (ru && ru.text) lines.push('⚠ ' + ru.text + (cur.rawUnknownSources ? '' : SNAP_TAG));
1145
1267
  // §M:以下一整块(连面板标题里那个自称)**只有打开那一刻那一份** —— 用一行分隔说明覆盖到面板末尾,
1146
1268
  // 比逐行贴标签干净(标题在上面的 h3 里,同属这份应答)。
1147
1269
  lines = lines.concat([
@@ -1353,7 +1475,8 @@
1353
1475
  var sealedNow = false; // 服务端定型状态
1354
1476
  var unlocked = false; // 本次会话承诺已验证(仅存于内存)
1355
1477
  var unlockPhrase = '';
1356
- var sealPhrase = ''; // 定型承诺句明文(服务端回显用;锁只造庄重,不保密)
1478
+ var sealPhrase = ''; // 定型承诺句明文(**服务端只对本机直连回显**;经反代/远端打开时为空)
1479
+ var sealHidden = false; // 服务端明说"这句只在本机显示"⇒ 解锁框走优雅退化文案,而不是报错/空白
1357
1480
 
1358
1481
  function row(label, node) {
1359
1482
  var r = el('div', 'row');
@@ -1788,7 +1911,9 @@
1788
1911
  sealBar.appendChild(bRelock);
1789
1912
  sealBar.appendChild(bUnseal);
1790
1913
  } else {
1791
- st.textContent = she('🌱 她已自由生长中 —— 若需对她做手术,亲手敲出当初的承诺句即可开启(≥10 字;输入框禁粘贴;界面会回显原句)。');
1914
+ // §6.1(a):"界面会回显原句"只在**本机**成立 ⇒ 远端(经反代打开)时补一句说清,而不是让主人以为界面坏了
1915
+ st.textContent = she('🌱 她已自由生长中 —— 若需对她做手术,亲手敲出当初的承诺句即可开启(≥10 字;输入框禁粘贴)。')
1916
+ + (sealHidden ? '承诺句只在本机显示,人格手术也请在本机做。' : '');
1792
1917
  var bUnlock = el('button', null, '🔓 对人格做手术…');
1793
1918
  bUnlock.onclick = doUnlock;
1794
1919
  sealBar.appendChild(st);
@@ -1805,12 +1930,16 @@
1805
1930
  }
1806
1931
  function doUnlock() {
1807
1932
  var hintPhrase = sealPhrase;
1933
+ // §6.1(a) 优雅退化:远端页面拿不到回显 ⇒ 说清"这是渠道问题"并给出可执行的路,
1934
+ // 绝不因此报错或留空(旧文案会把它误说成"旧档案没记录明文"—— 那是另一回事)。
1808
1935
  lingAsk({
1809
1936
  title: '对人格做手术(仪式)',
1810
1937
  body: (hintPhrase
1811
1938
  ? '你定型时写下的承诺句是:\n「' + hintPhrase + '」\n\n手术前请郑重地亲手敲一遍以确认(输入框禁粘贴,逐字敲出)。'
1812
- : '档案已定型,但旧档案未记录明文承诺。输入一句 ≥10 字的新承诺,它将被采纳为定型承诺句并开启本次手术。\n\n若想重新立誓,也请在下面输入你的新承诺。'),
1813
- placeholder: hintPhrase ? '手动敲出上方承诺句…' : '写下新的定型承诺句…',
1939
+ : (sealHidden
1940
+ ? '承诺句只在本机显示 —— 这个页面是经反代/远端打开的,所以看不到提示句。\n\n人格手术也请回到运行器灵的这台电脑上做(远端一律不给)。若你人就在本机,用本机地址(如 127.0.0.1:3080)打开面板即可看到提示句。'
1941
+ : '档案已定型,但旧档案未记录明文承诺。输入一句 ≥10 字的新承诺,它将被采纳为定型承诺句并开启本次手术。\n\n若想重新立誓,也请在下面输入你的新承诺。')),
1942
+ placeholder: hintPhrase ? '手动敲出上方承诺句…' : (sealHidden ? '(远端页面看不到提示句 —— 请回本机操作)' : '写下新的定型承诺句…'),
1814
1943
  minLen: 10, noPaste: true, okLabel: '郑重解锁',
1815
1944
  }).then(function (v) {
1816
1945
  if (!v) return;
@@ -2010,7 +2139,8 @@
2010
2139
  }
2011
2140
  var wasSealed = sealedNow;
2012
2141
  sealedNow = p.sealed === true;
2013
- sealPhrase = p.sealPhrase || '';
2142
+ sealPhrase = p.sealPhrase || ''; // 本机直连才有值;远端为空(见下一行的标注)
2143
+ sealHidden = p.phraseHidden === true; // 服务端明说"这句只在本机显示"(经代理的读面)
2014
2144
  if (!keepSession || (sealedNow && !wasSealed)) { unlocked = false; unlockPhrase = ''; }
2015
2145
  blCount();
2016
2146
  applyLockUI();
@@ -2304,9 +2434,39 @@
2304
2434
  (r.personaApplied ? ' · 人格已覆盖' : ''));
2305
2435
  } else if (r && r.reason === 'sealed') {
2306
2436
  toast('导入失败:人格已定型 — 请先到「人格中心」做手术(输入承诺句)后再覆盖导入;仅导入记忆不受影响');
2307
- } else toast('导入失败:' + JSON.stringify(r || {}).slice(0, 120));
2437
+ } else if (r && r.reason === 'too-large') {
2438
+ // B(2026-09-29,主人原话「文案改成人话,说明清楚发生啥了就行」):旧代码走的是最后那条
2439
+ // 兜底 —— `JSON.stringify(r).slice(0,120)` 把回执原文(含 `"reason":"too-large"` 这种
2440
+ // 机器话)直接糊到 toast 上,看得见字段、看不出"发生了什么、上限是多少、我该干什么"。
2441
+ // 这条文案必须说清三件事,且三件事**各有出处、都不许猜**:
2442
+ // ① 整包太大、**不是包坏了** —— 判据是服务端回的 `reason === 'too-large'`:
2443
+ // 它是**体量**闸门(readBody 的 4 MiB 上限),不是 JSON 解析失败/包结构坏;
2444
+ // ② 上限是多少 —— 判据是回执的 `limit`,用 fmtLimitMB 换算;**绝不写死**(见该函数注释);
2445
+ // ③ 怎么办 —— 换成**不含原文**的包重导:界面「⬇ 导出记忆包」走的 /export 默认
2446
+ // `includeRaw` 不为 1(`exportMemory()` 不带该参数)⇒ 那条路**本来就不含原文**、
2447
+ // 体积小得多,是主人自己能走通的一条路;并如实说明"整包一次发不完"是**当前通道的
2448
+ // 结构限制**(记忆包导入是整包一 POST,没有分批 —— 分批是 1.6 的活,不在这里顺手做)。
2449
+ // ⚠️ 体量报的是**这份文件多大**(f.size,缩进过的 JSON),与 limit(压缩后的请求体口径)不同尺,
2450
+ // 所以只说"这份包 ≈X / 整包超了上限"这个方向性事实,不假装两个数能直接相减。
2451
+ var sizeHint = (f && f.size > 0) ? '(≈' + fmtBytes(f.size) + ')' : '';
2452
+ toast('导入失败:这份包' + sizeHint + '整包超过了服务端单次上限 ' + fmtLimitMB(r.limit) + ' —— 不是包坏了。\n'
2453
+ + '· 「导入记忆包」是把整份文件**一次**发出去,带对话原文的包很容易超过它(当前通道的结构限制:整包一次发不完)。\n'
2454
+ + '· 怎么办:改用「⬇ 导出记忆包」重新导出一份**不含原文**的包(那个按钮默认就不含原文,体积小得多),再导入一次即可 —— 记忆/建议/队列都在,只是不含对话原文。',
2455
+ 12000, true);
2456
+ } else {
2457
+ // 其余失败:优先用服务端给的 `message`(它是一句中文),只有连 message 都没有时
2458
+ // 才退回 JSON 摘要 —— 兜底可以有,但不许当主路(旧代码就是无条件 JSON)。
2459
+ var whyImp = r && (r.message || r.error || r.reason);
2460
+ toast('导入失败:' + (whyImp ? String(whyImp) : JSON.stringify(r || {}).slice(0, 120)));
2461
+ }
2308
2462
  if (st.tab === 'overview') renderOverview();
2309
- }).catch(function () { toast('导入失败(网关不可达)'); });
2463
+ }).catch(function () {
2464
+ // 拿不到回执 ⇒ **不猜**成确定结论(网关真不可达 / 体量大到连接被直接断开,浏览器这一侧
2465
+ // 长得一模一样)⇒ 只说"无从判断" + 一条已知可能 + 一条主人能自己走的路。
2466
+ toast('导入失败(网关不可达):没拿到回执,具体原因无从判断。\n'
2467
+ + '已知的一种可能:这份包太大 —— 数据量大到超出服务端的排空余量时,连接会被直接断开,连说明情况的回执都送不出来。\n'
2468
+ + '可以先改用「⬇ 导出记忆包」重导一份不含原文的小包,再试一次。', 12000, true);
2469
+ });
2310
2470
  } catch (e) {
2311
2471
  toast('文件不是有效的记忆包 JSON');
2312
2472
  }
@@ -3782,7 +3942,7 @@
3782
3942
  var doc = splitDoc(String(reader.result));
3783
3943
  if (doc.note) resB.textContent = doc.note + '\n';
3784
3944
  pendingItems = doc.items;
3785
- resB.textContent = (resB.textContent || '') + '解析到会话 ' + doc.items.length + ' 条 —— 点击下方「确认导入」分批写入(200/批)。';
3945
+ resB.textContent = (resB.textContent || '') + '解析到会话 ' + doc.items.length + ' 条 —— 点击下方「确认导入」分批写入(按体量切批,单批 ≤' + (BATCH_MAX_BYTES / 1024 / 1024) + ' MB)。';
3786
3946
  bGo.textContent = '确认导入(' + doc.items.length + ' 条)';
3787
3947
  bGo.disabled = false;
3788
3948
  } catch (e) {
@@ -3799,9 +3959,13 @@
3799
3959
  var old = bGo.textContent;
3800
3960
  var items = pendingItems;
3801
3961
  pendingItems = null;
3802
- var totals = { newRows: 0, refreshed: 0, upgraded: 0, folded: 0, removedImport: 0, accepted: 0, rejected: 0, degraded: 0, degradedReasons: {}, errors: 0 };
3962
+ // tooBig = 单条自身超限被跳过的条数(C-1):**已知且可解释**,与 errors(意外)分开记,
3963
+ // 但必须在 finish() 汇总里如实显示 —— 不显示就是静默丢数据。
3964
+ // foldSkippedRaw(A,2026-09-30)= 折叠时"因为目标会话**已有原文**而**没有替换**的轮数":
3965
+ // 同一档"已知且可解释的跳过",同样必须说出来(finish() 里有一行专门交代它)。
3966
+ var totals = { newRows: 0, refreshed: 0, upgraded: 0, folded: 0, removedImport: 0, foldSkippedRaw: 0, accepted: 0, rejected: 0, degraded: 0, degradedReasons: {}, errors: 0, tooBig: 0 };
3803
3967
  var rejPreview = [];
3804
- var batch = 200;
3968
+ var sentBytes = 0; // 已送字节(累计;C2 双口径进度用)
3805
3969
  // 一次导入 = 一条账:runId 贯穿全部批,at = 导入开始时刻(服务端固定后不再变动)
3806
3970
  var runId = 'imp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
3807
3971
  var startedAt = Date.now();
@@ -3812,14 +3976,33 @@
3812
3976
  body: { runId: runId, at: startedAt, file: currentFileName, errors: 1 },
3813
3977
  }).catch(function () {});
3814
3978
  }
3979
+ // #10(2026-09-29):被跳过的"单条过大"要进**持久账本**(「接入历史 → 导入记录」)。
3980
+ // ⚠️ 语义:它记的是"**跳过**"、不是"失败" —— 所以这一笔**不带 `errors`**
3981
+ // (端点里 `errors` 的旧语义是 `max(1, ...)`:带上它就等于把"已知跳过"混成"失败批";
3982
+ // 而当初正是因为那条通路只认 errors,C-1 只能**故意不报** ⇒ 账本上完全看不出有东西被跳过)。
3983
+ // 名字与界面点名**同源**(同一个三级退路 title → name →「未命名」),最多 5 条(端点还会再截一次)。
3984
+ // 收尾时发**一次**(不是每条一发);失败不重试、不影响界面 —— 与 reportError 同款"尽力而为"。
3985
+ var skippedNames = [];
3986
+ function reportTooBig() {
3987
+ if (!totals.tooBig) return;
3988
+ api('/import/log', {
3989
+ method: 'POST',
3990
+ body: {
3991
+ runId: runId, at: startedAt, file: currentFileName,
3992
+ tooBig: totals.tooBig, tooBigNames: skippedNames.slice(0, 5),
3993
+ },
3994
+ }).catch(function () {});
3995
+ }
3815
3996
  function next() {
3816
- var chunk = items.slice(idx, idx + batch);
3817
- if (!chunk.length) return finish();
3818
- idx += batch;
3819
- resB.textContent = '导入中…' + Math.min(idx, items.length) + '/' + items.length;
3997
+ var chunk = takeChunk(items, idx); // C1:条数 × 字节双限(字节口径见 takeChunk 注释)
3998
+ if (!chunk.list.length) return finish();
3999
+ var chunkStart = idx; // C4:本批起始下标(0-based)⇒ 拒绝序号 = chunkStart + rj.i + 1
4000
+ idx = chunk.next;
4001
+ sentBytes += chunk.bytes;
4002
+ resB.textContent = '导入中…' + Math.min(idx, items.length) + '/' + items.length + ' 条 · 已送 ' + fmtBytes(sentBytes);
3820
4003
  api('/import/file/batch', {
3821
4004
  method: 'POST',
3822
- body: { items: chunk, file: currentFileName, runId: runId, at: startedAt },
4005
+ body: { items: chunk.list, file: currentFileName, runId: runId, at: startedAt },
3823
4006
  }).then(function (r) {
3824
4007
  if (r && r.ok) {
3825
4008
  totals.accepted += r.accepted || 0;
@@ -3828,6 +4011,8 @@
3828
4011
  totals.upgraded += r.upgraded || 0;
3829
4012
  totals.folded += r.folded || 0;
3830
4013
  totals.removedImport += r.removedImport || 0;
4014
+ // A(2026-09-30):折叠时"目标已有原文 ⇒ 这一轮没替换"的计数,照 upgraded/folded 同一写法累加
4015
+ totals.foldSkippedRaw += r.foldSkippedRaw || 0;
3831
4016
  totals.degraded += r.degraded || 0;
3832
4017
  if (r.degradedReasons) {
3833
4018
  Object.keys(r.degradedReasons).forEach(function (k) {
@@ -3837,14 +4022,46 @@
3837
4022
  if (r.rejected && r.rejected.length) {
3838
4023
  totals.rejected += r.rejected.length;
3839
4024
  r.rejected.slice(0, 3).forEach(function (rj) {
3840
- var at = idx - batch + rj.i + 1;
4025
+ var at = chunkStart + rj.i + 1; // C4:以【本批起始下标】为准(batch 已不存在)
3841
4026
  if (rejPreview.length < 5) rejPreview.push('第 ' + at + ' 条:' + rj.reason);
3842
4027
  });
3843
4028
  }
3844
4029
  } else {
3845
- totals.errors += 1;
3846
- reportError();
3847
- if (rejPreview.length < 5) rejPreview.push('批失败:' + ((r && (r.message || r.error || r.reason)) || '未知'));
4030
+ // C5:超限是可自愈的确定性失败,给可执行的人话(而不是让它烂成「(网关)」)。
4031
+ // 上限数字**取自服务端回执**(413 体里带 limit),不在这边再写死一个 ——
4032
+ // 两个地方各写一个"4 MB",将来改上限时必漂移;`4` 只作回执缺 limit 时的兜底。
4033
+ var limMb = Number(r && r.limit) > 0 ? Math.round(Number(r.limit) / 1024 / 1024) : 4;
4034
+ // C-1(2026-09-29):同一个 413 其实有两种,必须分开说 ——
4035
+ // ① 本批**只有 1 条** ⇒ 是【这一条自己】太大:takeChunk 已保证超限条单独成批,
4036
+ // 所以"再切细一点"物理上不存在(单条不可再分)。它是**已知且可解释**的,
4037
+ // 不是意外 ⇒ 记 totals.tooBig,不混进 errors;并且要点名报到哪一条、多大,
4038
+ // 再说清"这条跳过、其余照常导入"(后面照常 next(),绝不因一条太大中止整次导入)。
4039
+ // ② 本批**多于 1 条** ⇒ 是切批没生效(真异常)⇒ 沿用旧文案,计入 errors。
4040
+ // 判据只用**服务端回执**(reason==='too-large' + limit)与本批条数;
4041
+ // 客户端**不镜像**服务端的字节上限(将来放宽上限不用改这里)。
4042
+ if (r && r.reason === 'too-large' && chunk.list.length === 1) {
4043
+ var one = chunk.list[0] || {};
4044
+ var oneTitle = String(one.title || one.name || '').trim() || '未命名';
4045
+ var oneBytes = chunk.bytes; // 退路:本批自报字节(含封套近似)
4046
+ try { oneBytes = utf8Len(JSON.stringify(one)); } catch (e) { /* 循环引用等:用退路 */ }
4047
+ totals.tooBig += 1;
4048
+ if (skippedNames.length < 5) skippedNames.push(oneTitle); // #10:账本点名用(最多 5 条)
4049
+ if (rejPreview.length < 5) {
4050
+ rejPreview.push('单条过大跳过:「' + oneTitle + '」这一条 ' + fmtBytes(oneBytes) + '(' + oneBytes + ' B)'
4051
+ + ' —— 它自己就在单次上限 ' + limMb + ' MB 之上,导入通道装不下(不是文件坏了,是这条太长,拆文件也拆不开它);'
4052
+ + '这条已跳过,其余会话照常导入');
4053
+ }
4054
+ } else {
4055
+ totals.errors += 1;
4056
+ reportError();
4057
+ if (rejPreview.length < 5) {
4058
+ if (r && r.reason === 'too-large') {
4059
+ rejPreview.push('批失败:这一批太大(服务端单次上限 ' + limMb + ' MB),请把导出文件拆小后再导');
4060
+ } else {
4061
+ rejPreview.push('批失败:' + ((r && (r.message || r.error || r.reason)) || '未知'));
4062
+ }
4063
+ }
4064
+ }
3848
4065
  }
3849
4066
  next();
3850
4067
  }).catch(function () {
@@ -3859,7 +4076,12 @@
3859
4076
  bGo.textContent = old;
3860
4077
  // S5(2026-09-17 导入诚实化):「有壳无肉」时绝不说"✓ 导入完成"。
3861
4078
  // 旧文案把"对话解析不出来"说成"降级为轻量",用户以为导入成功了,其实正文一条没进。
4079
+ // C-1(2026-09-29)按同一口径补一档:**有会话因单条过长被跳过**时,同样绝不说"✓ 导入完成"。
3862
4080
  var shell = totals.degraded || 0;
4081
+ var skipped = totals.tooBig || 0;
4082
+ // A(2026-09-30):折叠时"因为目标会话已有原文而**没有替换**的轮数"。
4083
+ // 它不是失败、也没有丢东西(保住的是对方原有的真原文),但**必须说出来** —— 静默不可接受。
4084
+ var notReplaced = totals.foldSkippedRaw || 0;
3863
4085
  var why = Object.keys(totals.degradedReasons).map(function (k) {
3864
4086
  return k + ' ×' + totals.degradedReasons[k];
3865
4087
  }).join(';');
@@ -3869,19 +4091,37 @@
3869
4091
  shell + ' 条只有标题、没有对话正文');
3870
4092
  lines.push(' 原因:' + (why || '文件里的对话格式没能认出来') +
3871
4093
  ' —— 这些记忆暂时只有壳,深摘也补不出内容。');
4094
+ } else if (skipped) {
4095
+ lines.push('⚠ 导入不完全:写入新记忆 ' + totals.newRows + ' 条 · 刷新已有 ' + totals.refreshed + ' 条,另有 ' +
4096
+ skipped + ' 条会话单条太长、没能通过导入通道(已跳过,见下)');
4097
+ lines.push(' 这不是文件坏了:是那几条会话**自己**就超过了单次上限 —— 拆小导出文件也拆不开单条。');
3872
4098
  } else {
3873
4099
  lines.push('✓ 导入完成:写入新记忆 ' + totals.newRows + ' 条 · 刷新已有 ' + totals.refreshed + ' 条' +
3874
4100
  (totals.upgraded ? ' · 补全对话原文 ' + totals.upgraded + ' 条' : '') +
3875
4101
  (totals.folded ? ' · 并入网页端历史 ' + totals.folded + ' 条(含对话原文)' : '') +
3876
4102
  (totals.removedImport ? ' · 清理重复副本 ' + totals.removedImport + ' 条' : ''));
3877
4103
  }
3878
- lines.push(' 校验通过 ' + totals.accepted + ' · 拒绝 ' + totals.rejected + (totals.errors ? ' · 批错误 ' + totals.errors : ''));
4104
+ lines.push(' 校验通过 ' + totals.accepted + ' · 拒绝 ' + totals.rejected + (totals.errors ? ' · 批错误 ' + totals.errors : '') + (skipped ? ' · 单条过大跳过 ' + skipped : ''));
4105
+ // A(2026-09-30):折叠时"没替换"的那几轮必须有一行人话(静默不可接受)。
4106
+ // 措辞要点:① 说清**为什么**(该会话在网页端旧库里已经有原文);② 说清**不是失败、也没丢东西**
4107
+ // (保住的是原有的真原文,是**更安全**的那一边);③ 与 `⚠` 那两档分开 —— 那两档是真的不完全,这档不是。
4108
+ if (notReplaced) {
4109
+ lines.push(' ⓘ 另有 ' + notReplaced + ' 轮原文没有替换:这些会话在网页端旧库里**已经有原文**了 —— '
4110
+ + '保留原有的那份、不覆盖它(不是失败,也没有丢东西)。');
4111
+ }
3879
4112
  rejPreview.forEach(function (x) { lines.push(' ⚠ ' + x); });
3880
- if (!totals.errors && totals.accepted && !shell) {
3881
- lines.push(she('她开始读这些日子了 —— 深摘要会在后台按热度慢慢补齐。'));
4113
+ // 收尾句只在"全好"时打(S5/C-1 的诚实化口径)。
4114
+ // A(2026-09-30):有"没替换"的轮次同样不算全好 —— 那几轮的正文确实没进这一侧的库,
4115
+ // 不能一边少进一边说"她开始读这些日子了"。⚠️ 这一层**写成内层判断**而不是往上面那行
4116
+ // 守卫里再加一个 `&&`:那一行的字面形态被 `tests/client-batch.test.mjs:219` 的 T7 源码护栏
4117
+ // 逐字盯着(`!totals.errors && !skipped && totals.accepted && !shell`),往里塞条件会让那条
4118
+ // 既有断言变红 —— 而它守的正是同一个口径(有跳过就不打"全都好"的收尾句)。
4119
+ if (!totals.errors && !skipped && totals.accepted && !shell) {
4120
+ if (!notReplaced) lines.push(she('她开始读这些日子了 —— 深摘要会在后台按热度慢慢补齐。'));
3882
4121
  }
3883
4122
  resB.textContent = lines.join('\n');
3884
4123
  renderImportLog();
4124
+ reportTooBig(); // #10:有跳过就记一笔账(不带 errors;见 reportTooBig 上方的注释)
3885
4125
  }
3886
4126
  next();
3887
4127
  };
@@ -4463,7 +4703,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4463
4703
  var LOG_FETCH_LIMIT = 100;
4464
4704
 
4465
4705
  /** 「最近改动」溢出笔数(纯函数 —— 抽出来是为了能在假 DOM 上真跑,
4466
- * 见 `_probe\check-panel-M1-runtime.cjs`)。
4706
+ * 已由一次性运行时探针在假 DOM 上实跑核对过)。
4467
4707
  *
4468
4708
  * 主路:用**服务端真计数**。`/branch-log/pending` 的 `pending` 就是
4469
4709
  * `COUNT(id > 水位 AND action <> 'snapshot')`(host/memory.js:1257-1263),再把列表里
@@ -4474,7 +4714,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4474
4714
  * (`ORDER BY id DESC`),而 branch_log 是**全量留痕** —— 2026-09-27 只读实测已有 **404 行**
4475
4715
  * ⇒ 页面恒满,`data.log.length - 6` 恒等于 `limit - 6`(limit=100 ⇒ 恒 94),与主人看到的
4476
4716
  * 改动笔数无关。那正是 1.5.1 在注入面修掉的"两个写死上限的差值"
4477
- * (`plans\BACKLOG-1.5.2.md` §A;host/memory.js:1016-1029 明写"不需要把 limit 调大去凑数")。
4717
+ * (作者的开发台账 §A,该台账不随包公开;host/memory.js:1016-1029 明写"不需要把 limit 调大去凑数")。
4478
4718
  *
4479
4719
  * 退路(真计数问不到时 `pend` 为 null):退回页面口径;页面满(`log.length >= limit`)
4480
4720
  * 说明可能还有更多行没取到 ⇒ 只说「N 笔**以上**」,不冒充确切数。
@@ -4621,7 +4861,8 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4621
4861
  api('/conflicts').catch(function () { return null; }),
4622
4862
  api('/branch-log?limit=' + LOG_FETCH_LIMIT).catch(function () { return null; }),
4623
4863
  api('/tree/snapshots?limit=30').catch(function () { return null; }),
4624
- api('/branch-log/pending').catch(function () { return null; }),
4864
+ // 1.5.3(主会话校正:本批未发布,不叫 1.5.4):该端点进了服务端 `POST_ONLY`(它会一次性补种水位 = 真写),GET 现在回 405。
4865
+ api('/branch-log/pending', { method: 'POST', body: {} }).catch(function () { return null; }),
4625
4866
  api('/vein/suggestions?status=new&limit=100').catch(function () { return null; }),
4626
4867
  // E10:主脉记忆清单(只读,limit 上限就是 500,见 memory.js:1951)
4627
4868
  api('/memories?source=vein&limit=500').catch(function () { return null; }),
@@ -4654,7 +4895,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4654
4895
  // 侧栏还写着"有 N 笔没说"。加轻量轮询让它跟上(8s 一次小请求,值不变时 React 不重渲染)。
4655
4896
  React.useEffect(function () {
4656
4897
  var t = setInterval(function () {
4657
- api('/branch-log/pending').then(function (r) {
4898
+ api('/branch-log/pending', { method: 'POST', body: {} }).then(function (r) {
4658
4899
  if (!r || !r.ok) return;
4659
4900
  var p = Number(r.pending || 0);
4660
4901
  var w = Number(r.watermark || 0);
@@ -5531,7 +5772,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5531
5772
  // 2026-09-23 修:显示值可能滞后(器灵开口才推进水位,而"发言"不触发本面板重载),
5532
5773
  // 所以退出前**实时查一次**服务端 —— 避免"明明已经同步了却拦着不让退"。
5533
5774
  if (!next) {
5534
- api('/branch-log/pending').then(function (r) {
5775
+ api('/branch-log/pending', { method: 'POST', body: {} }).then(function (r) {
5535
5776
  var p = (r && r.ok) ? Number(r.pending || 0) : pendingChanges;
5536
5777
  setPendingChanges(p);
5537
5778
  if (p > 0) {
@@ -5908,6 +6149,15 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5908
6149
  console.warn('[dsh-ling] client apply failed', e);
5909
6150
  }
5910
6151
  };
6152
+ // ---- 测试钩子(仅供 tests/client-batch.test.mjs 直调真实实现)----
6153
+ // 只挂纯函数与常量,无任何副作用;生产路径不读这里。
6154
+ exports.__test__ = {
6155
+ takeChunk: takeChunk,
6156
+ utf8Len: utf8Len,
6157
+ fmtBytes: fmtBytes,
6158
+ BATCH_MAX_BYTES: BATCH_MAX_BYTES,
6159
+ BATCH_MAX_ITEMS: BATCH_MAX_ITEMS,
6160
+ };
5911
6161
  return module.exports;
5912
6162
  },
5913
6163
  });