@nightpainters/dsh-ling 1.6.2 → 1.7.1

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
@@ -168,15 +168,27 @@
168
168
  // 取不到(经反代打开的页面 / 旧宿主)= 空串 ⇒ 手术请求照旧发,由宿主那边给出明确拒绝。
169
169
  var surgeryTicket = '';
170
170
  var surgeryTicketTried = false;
171
+ var surgeryTicketFailCount = 0; // D2 修复:失败计数,每页面生命周期最多 2 次(第 3 次不再自动试)
171
172
  function fetchSurgeryTicket() {
172
- surgeryTicketTried = true; // 无论成败都只试一次(失败不重试,免得把拒绝变成刷屏)
173
+ surgeryTicketTried = true; // 标记"正在尝试",阻止 api() 重入路径并发触发第二发
173
174
  return fetch(API + '/surgery/ticket', { method: 'POST', headers: { 'content-type': 'application/json' } })
174
175
  .then(function (r) { return r.json().catch(function () { return null; }); })
175
176
  .then(function (j) {
176
- if (j && j.ok && typeof j.ticket === 'string' && j.ticket) { surgeryTicket = j.ticket; return surgeryTicket; }
177
- return ''; // 远端 / 未定型:没有票,照常走后面的拒绝路径
177
+ if (j && j.ok && typeof j.ticket === 'string' && j.ticket) {
178
+ surgeryTicket = j.ticket; // 成功:票落袋,保持 tried=true(不再重取)
179
+ return surgeryTicket;
180
+ }
181
+ // 失败(远端拒绝 / 旧宿主):解锁以便再试,但计次;到上限后不再自动试
182
+ surgeryTicketTried = false;
183
+ surgeryTicketFailCount++;
184
+ return '';
178
185
  })
179
- .catch(function () { return ''; });
186
+ .catch(function () {
187
+ // 网络错误 / 超时:同上
188
+ surgeryTicketTried = false;
189
+ surgeryTicketFailCount++;
190
+ return '';
191
+ });
180
192
  }
181
193
 
182
194
  function api(path, opts) {
@@ -192,7 +204,7 @@
192
204
  }).then(function (j) {
193
205
  // 宿主说"票据不对/没票"⇒ 现场取一次,原样重发一次(只重发一次,不做循环)。
194
206
  // 票据每启动轮换 ⇒ 页面放久了会遇到这一支,那时用户不必手动刷新。
195
- if (j && j.ok === false && j.surgery === 'surgery-ticket' && !surgeryTicketTried) {
207
+ if (j && j.ok === false && j.surgery === 'surgery-ticket' && !surgeryTicketTried && surgeryTicketFailCount < 2) {
196
208
  return fetchSurgeryTicket().then(function (t) {
197
209
  if (!t) return j;
198
210
  var h2 = { 'content-type': 'application/json', 'x-dsh-ling-surgery': t };
@@ -340,6 +352,31 @@
340
352
  '.ling-tab:hover{opacity:1;}' +
341
353
  '.ling-tab.on{opacity:1;background:var(--lg-hover-bg);border-color:var(--lg-accent);color:var(--lg-hover-text);font-weight:700;}' +
342
354
  '.ling-mc .mc-body{flex:1;overflow:auto;min-height:0;font-size:14px;padding-right:2px;}' +
355
+ // 图书馆(独立库)的样式:全部走既有 --lg-* 变量 ⇒ 明/暗主题自动跟随,不新造配色体系。
356
+ // 语义色只用三档:ok=accent / warn=gold / bad=红 —— 与面板既有告警语言一致(审计色 #d9a441 同族)。
357
+ '.lib-status{margin:0 0 10px;padding:10px 12px;border:1px solid var(--lg-border);border-radius:10px;' +
358
+ 'background:var(--lg-rowbg);line-height:1.7;}' +
359
+ '.lib-status.ok{border-color:var(--lg-accent);}' +
360
+ '.lib-status.warn{border-color:var(--lg-gold);}' +
361
+ '.lib-status.bad{border-color:#d9534f;}' +
362
+ '.lib-card{margin:8px 0;padding:10px 12px;border:1px solid var(--lg-border);border-radius:10px;background:var(--lg-rowbg);}' +
363
+ '.lib-chip{display:inline-block;margin:0 6px 4px 0;padding:1px 9px;border:1px solid var(--lg-border);' +
364
+ 'border-radius:999px;font-size:12px;opacity:.92;}' +
365
+ '.lib-mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all;line-height:1.6;}' +
366
+ '.lib-pre{white-space:pre-wrap;word-break:break-word;max-height:260px;overflow:auto;padding:8px 10px;' +
367
+ 'border:1px dashed var(--lg-border);border-radius:8px;line-height:1.6;}' +
368
+ '.lib-form label{display:block;margin:9px 0 3px;font-size:12.5px;opacity:.82;}' +
369
+ '.lib-form input[type=text],.lib-form input[type=search],.lib-form textarea{width:100%;box-sizing:border-box;' +
370
+ 'padding:6px 10px;border:1px solid var(--lg-input);border-radius:9px;background:transparent;color:var(--lg-text);' +
371
+ 'font-size:14px;font-family:inherit;}' +
372
+ '.lib-form textarea{min-height:74px;line-height:1.6;}' +
373
+ '.lib-err{margin:8px 0;color:#d9534f;line-height:1.7;word-break:break-word;}' +
374
+ '.lib-warn{margin:10px 0;padding:9px 12px;border:1px solid #d9a441;border-left:4px solid #d9a441;' +
375
+ 'border-radius:8px;background:rgba(217,164,65,.10);line-height:1.7;word-break:break-word;}' +
376
+ '.lib-warn .t{font-weight:600;}' +
377
+ '.lib-note{margin:8px 0;color:#d9a441;line-height:1.7;word-break:break-word;}' +
378
+ '.lib-grid{display:grid;grid-template-columns:minmax(120px,max-content) 1fr;gap:4px 12px;align-items:start;}' +
379
+ '.lib-grid .k{opacity:.62;font-size:12.5px;}' +
343
380
  '.m-tools{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:12px;}' +
344
381
  '.m-tools input[type=search],.m-tools select{min-height:38px;padding:6px 12px;border:1px solid var(--lg-input);' +
345
382
  'border-radius:9px;background:transparent;color:var(--lg-text);font-size:14px;}' +
@@ -430,7 +467,7 @@
430
467
 
431
468
  function dark() { return document.documentElement.getAttribute('data-ds-theme') === 'dark' ? '1' : '0'; }
432
469
 
433
- /** 悬停即时小气泡(2026-09-27,尝生反馈"太罗嗦 + 窗口叠加"):
470
+ /** 悬停即时小气泡(2026-09-27,用户反馈"太罗嗦 + 窗口叠加"):
434
471
  * 原生 `title` 由**浏览器**绘制 —— 既不受样式控制,也**不会自动消失**;而悬停 1.5s 会开菜单,
435
472
  * 于是浏览器 tooltip 和菜单叠成两层。故完整提醒改走自绘:悬停瞬间出现、`TIP_MS` 后自动移除,
436
473
  * 且菜单一开就先收掉它 ⇒ 两者永不同屏。与 `toast` 同规格(都是 dsh-ling-menu 容器 + 定时移除),
@@ -1024,7 +1061,7 @@
1024
1061
  return r || {};
1025
1062
  }).catch(function () { toast('切换失败(网关不可达)'); return {}; });
1026
1063
  }
1027
- // ⚠️ 这里**故意不问一句** —— 尝生 2026-09-27 裁决(原话):
1064
+ // ⚠️ 这里**故意不问一句** —— 用户 2026-09-27 裁决(原话):
1028
1065
  // 「创建新会话时只能通过调全局默认来调新会话的模式,所以它的使用比想象的频繁一些」
1029
1066
  // ⇒ 全局默认不是危险开关,而是**开一个不同模式的新会话**的必经入口,属高频正常动作;
1030
1067
  // 事前弹确认 = 给日常添堵,且那个按钮按下本身就是低成本可撤销动作。
@@ -1081,7 +1118,7 @@
1081
1118
  wrap.appendChild(h);
1082
1119
  var kv = el('div', 'kv', '加载中…');
1083
1120
  wrap.appendChild(kv);
1084
- // E3(2026-09-27 尝生拍板):「那一行」只读块。**必须另起一个节点** —— 上面 `kv` 整块是纯
1121
+ // E3(2026-09-27 用户拍板):「那一行」只读块。**必须另起一个节点** —— 上面 `kv` 整块是纯
1085
1122
  // textContent(一段文字),而「看全文」要一个真能展开/收起的子节点(快照原文放 <pre>,默认收起)。
1086
1123
  // 这一块对模型上下文**零占用**:读的全是 `/state` 面板档(`l0Preview=1`)字段,纯读,不进 prompt。
1087
1124
  var e3box = el('div', 'ling-e3');
@@ -1223,7 +1260,7 @@
1223
1260
  }
1224
1261
  // 全局视图的 L2 三行**必然**缺席:后端 lib/host/api.js 在 global 分支写死 snapshot: null,
1225
1262
  // 而且整个返回里不带 idleRefresh 字段 ⇒ 上面两段一个字符都进不来。
1226
- // 不写这一行,"按设计没有"与"坏了"在界面上长得一模一样(尝生 2026-09-27 反馈)。
1263
+ // 不写这一行,"按设计没有"与"坏了"在界面上长得一模一样(用户 2026-09-27 反馈)。
1227
1264
  // 放在 L2 本该出现的位置(待应用更新 → 这里 → 人格),两个视图的行序才对得齐。
1228
1265
  if (global && !r.snapshot && !r.idleRefresh) {
1229
1266
  lines.push('(全局视图无会话快照 —— 快照定稿/空闲刷新是按会话统计的,进入某个会话才显示)' + SNAP_TAG);
@@ -1344,7 +1381,7 @@
1344
1381
  if (nextText) {
1345
1382
  // 号码 `S.ordinal` 与正文 `nextText` **同源**(都是"下一条")⇒ 这个组合是自洽的
1346
1383
  row(' 活行正文' + ((S && S.ordinal != null) ? '(#' + S.ordinal + ')' : '') + ' —— 下一条会长这样: ' + nextText);
1347
- row(' (' + nextText.length + ' 字符 · 这一条是**预测**:后端知道轮次时才给;只有内容真变了才追加,故它可能连着好几轮不动 —— 那不代表机制失效)', 'e3-meta');
1384
+ row(' (' + nextText.length + ' 字符 · 这一条是预测:后端知道轮次时才给;只有内容真变了才追加,故它可能连着好几轮不动 —— 那不代表机制失效)', 'e3-meta');
1348
1385
  } else {
1349
1386
  row(' 活行正文 —— 下一条会长这样: (后端没给 —— 面板这条路径取不到轮次,故不预测正文,也绝不用"上次那条"顶替)', 'e3-why');
1350
1387
  }
@@ -1394,21 +1431,21 @@
1394
1431
  row('为什么没变(逐条判据):', 'e3-h');
1395
1432
  var running = (cur && cur.running !== undefined) ? !!cur.running : !!r.running;
1396
1433
  row(' 快照为什么没变:' + (running
1397
- ? '会话**运行中** ⇒ 快照不重建(人格/记忆更新要等空闲那一刻落盘;这一条**正在**挡着)'
1398
- : '会话空闲 ⇒ 这一条**不构成**"没变"的原因(没有在挡它)'));
1434
+ ? '会话运行中 ⇒ 快照不重建(人格/记忆更新要等空闲那一刻落盘;这一条正在挡着)'
1435
+ : '会话空闲 ⇒ 这一条不构成"没变"的原因(没有在挡它)'));
1399
1436
  row(' 深摘:' + (r.deepDone === true
1400
- ? '该会话**已深摘冻结**(kv `deep:<会话id>` = done:…)⇒ 本会话那一行(只有标题那条)不再更新'
1437
+ ? '该会话已深摘冻结(kv `deep:<会话id>` = done:…)⇒ 本会话那一行(只有标题那条)不再更新'
1401
1438
  : (r.deepDone === false
1402
- ? '未深摘 ⇒ 这一条**不构成**"没变"的原因'
1439
+ ? '未深摘 ⇒ 这一条不构成"没变"的原因'
1403
1440
  : '后端这一版还没带 deepDone ⇒ 不做猜测')));
1404
1441
  row(' 本会话标题行为什么只有标题:' + ((selfRow && selfRow.line)
1405
- ? '这一行**只有标题**(公式里就没有摘要),进展由②活行承担 —— 两条不同源,**不是印了两遍,也不是 bug**'
1442
+ ? '这一行只有标题(公式里就没有摘要),进展由②活行承担 —— 两条不同源,不是印了两遍,也不是 bug'
1406
1443
  : '本会话那一行不在 L1 里 ⇒ L2 本会话标题行此刻为空,②活行照旧'));
1407
1444
  row(' 档位:' + (known
1408
- ? ('本拍被判定为「' + (E3_APPEND[rsn] ? '会追加' : (E3_HOLD[rsn] ? '本拍不判定' : '不追加')) + '」—— ' + known + ';另外:15 分钟 / 10 轮那道**档位门已撤除**(追加只看"内容真变了没")')
1445
+ ? ('本拍被判定为「' + (E3_APPEND[rsn] ? '会追加' : (E3_HOLD[rsn] ? '本拍不判定' : '不追加')) + '」—— ' + known + ';另外:15 分钟 / 10 轮那道档位门已撤除(追加只看"内容真变了没")')
1409
1446
  : ((rsn && !known)
1410
- ? ('后端给了个前端未收录的原因("' + rsn + '")⇒ 不在这里翻译;另外:15 分钟 / 10 轮那道**档位门已撤除**')
1411
- : '**没有档位门**了(15 分钟 / 10 轮双门已撤除:追加只看"内容真变了没")—— 所以这里没有"距下次还差多久"可算,不编一个')) + ' ' + basisTag, 'e3-why');
1447
+ ? ('后端给了个前端未收录的原因("' + rsn + '")⇒ 不在这里翻译;另外:15 分钟 / 10 轮那道档位门已撤除')
1448
+ : '没有档位门了(15 分钟 / 10 轮双门已撤除:追加只看"内容真变了没")—— 所以这里没有"距下次还差多久"可算,不编一个')) + ' ' + basisTag, 'e3-why');
1412
1449
  // ---- 看全文:模型那一轮真正读到的**冻结快照原文**(面板档 snapshot.text;纯读,对模型上下文零占用) ----
1413
1450
  var stext = (snap2 && typeof snap2.text === 'string') ? snap2.text : null;
1414
1451
  if (stext === null) {
@@ -2171,7 +2208,7 @@
2171
2208
  var st = store.get();
2172
2209
  var mode = st.mode || 'life';
2173
2210
  var items = [
2174
- ['记忆中心(浏览/置顶/导入导出)', openMemoryCenter],
2211
+ ['记忆与数据中心(浏览/置顶/导入导出)', openMemoryCenter],
2175
2212
  ['人格中心(自称/语气/规矩·习惯·底线·定型)', openPersonaEditor],
2176
2213
  ['建议中心(语料提炼+点踩成长)', openFeedbackPanel],
2177
2214
  ['🌱 一键进入复盘', function () { startReviewFromMenu(); }, '与器灵互相复盘总结纠正:复制复盘开场白 + 解锁编辑 + 打开右侧复盘栏'],
@@ -2239,7 +2276,43 @@
2239
2276
  // 一键生成记忆树(autotree):autoOpen=面板开关;autoData=preview 原始返回;
2240
2277
  // autoNames=bucketId→名字(可手改);autoBusy=防重入(命名/应用期间禁用按钮)
2241
2278
  autoOpen: false, autoData: null, autoNames: {}, autoBusy: false, autoExpand: {},
2279
+ // 「图书馆」标签的自己一格状态。**与上面任何记忆字段都不共用**:
2280
+ // 图书馆是另一套库(library.db),混用字段正是"两套东西看起来像一套"的起点。
2281
+ // 竞态三令牌(libSeq/libDetailSeq/libActionSeq)在函数作用域,不在这里(见下方注释)。
2282
+ // · 例外:结论层标签的代次令牌 `seq` 就放在下面 st.deep 里 —— 它随面板重建
2283
+ // (openMemoryCenter 每次都新建 st)⇒ 面板一重开,上一轮在飞的请求自动全部作废。
2284
+ // 结论层(B 档,2026-10-15):**只读**面的状态。它不放在 lib 里 —— 深层库与图书馆
2285
+ // 是两套东西(前者是"我记住的结论",后者是"有源可对账的源资料"),混进一个状态对象
2286
+ // 迟早会让某处 `st.lib.x` 读到一个属于结论层的值。
2287
+ deep: { data: null, busy: false, err: '', showRetired: false, limit: 500, seq: 0 },
2288
+ lib: {
2289
+ view: 'list', q: '', offset: 0, limit: 20, items: null,
2290
+ nextOffset: null, truncated: false, searchedOnce: false,
2291
+ status: null, statusLoaded: false, statusLoading: false, statusErr: '',
2292
+ itemId: '', item: null, detailErr: '', hist: null, checks: null,
2293
+ form: null, formMode: 'create', formItem: null,
2294
+ busy: false, checking: false, err: '', notice: '',
2295
+ checkResult: null, checkErr: '', withdrawReason: '',
2296
+ // 导入面的自己一格:源 → 候选 → 勾选 → 入库。
2297
+ // ⚠️ 它**必须**与维护面那几个字段分开:`imp.receipt` 是"候选清单",不是库里的条目 ——
2298
+ // 混用会让"分析过但没入库"的东西看起来像已经在库里了(§8 回执纪律要防的正是这个)。
2299
+ imp: null,
2300
+ // 浏览面(第七面)的自己一格:分组计数表 + 每个已展开组的一页条目 + 选中条的详情/谱系/核对史。
2301
+ // 与维护面字段分开的理由同导入面 —— 这一面**只读**,它的缓存不许被写动作改动。
2302
+ br: null,
2303
+ },
2242
2304
  };
2305
+ // ---- 迟到回画守卫(2026-10-06,加了「图书馆」标签之后补) ----
2306
+ // 四个记忆标签各自都有"请求在飞 → 回来再清 body 重画"的异步尾巴(概览的 Promise.all、
2307
+ // 记忆浏览的 sources、记忆树的 loadTreeAll/paintTree、autotree 的三段尾巴)。若请求在飞时
2308
+ // 主人切到了别的标签(尤其第五「图书馆」),旧尾巴回来照样**清空 body 并画旧标签的内容** ——
2309
+ // 顶部高亮停在图书馆、正文却变成记忆浏览,正是本文件反复出现的 C#8「内容与标签不符」。
2310
+ // 统一用一枚**绘制代次**:每次切标签 +1、每次派发渲染 +1;异步尾巴回来时代次不一致就
2311
+ // **连清都不清**,整条丢弃(不是"先画错再撤回"——那会闪一下,而且丢的是当前视图)。
2312
+ // 与 C#8 那处 `st.tab` 守卫是**互补**的,两者都留:
2313
+ // · 代次挡住"同一个标签内,旧请求盖掉新渲染"(切走又切回时 st.tab 恰好相等,单靠它拦不住);
2314
+ // · st.tab 挡住"代次恰好相等却已不在本标签"(双保险,不依赖调用顺序)。
2315
+ var drawGen = 0;
2243
2316
  // 注册同步钩子供模块级"一键进入复盘"使用(见 lingCenterSync 注释)
2244
2317
  lingCenterSync = function (td, conf) {
2245
2318
  st.treeData = td;
@@ -2279,8 +2352,17 @@
2279
2352
  wrap.tabIndex = 0;
2280
2353
  wrap.style.outline = 'none';
2281
2354
  wrap.dataset.dark = dark();
2282
- var head = el('h3', null, '记忆中心');
2283
- head.appendChild(el('div', 'hint', '历史会话与 DSH 会话的记忆库:浏览、置顶(提升 L1 权重)、删除、导入导出'));
2355
+ // 中心标题:「记忆与数据中心」—— 六个面同住一个中心,但**分成三段**:
2356
+ // ① 记忆相关的四面(2026-10-16 用户要求:把记忆的部分挨在一起)
2357
+ // ② 「接入历史」= 会话接入与导入的流水
2358
+ // ③ 「图书馆」= 另一套独立库:零注入、不与记忆条目混排
2359
+ var head = el('h3', null, '记忆与数据中心');
2360
+ head.appendChild(el('div', 'hint',
2361
+ '左边四面是记忆本身,挨在一起:概览(数据与后台开关)、浏览(置顶/删除/导入导出)、'
2362
+ + '记忆树(分枝与血缘)、结论层(深层库条目 —— 与开场注入直接相关,只读,'
2363
+ + '落条与作废走工具、面板不提供写入口);'
2364
+ + '「接入历史」是会话接入与导入的流水;'
2365
+ + '最后的「图书馆」是另一套独立库,只放有源可对账的资料,不参与开场注入、不与记忆条目混排。'));
2284
2366
  wrap.appendChild(head);
2285
2367
 
2286
2368
  var tabs = el('div', 'ling-tabs');
@@ -2288,10 +2370,21 @@
2288
2370
  var tabBrowse = el('div', 'ling-tab', '记忆浏览');
2289
2371
  var tabAccess = el('div', 'ling-tab', '接入历史');
2290
2372
  var tabTree = el('div', 'ling-tab', '记忆树');
2373
+ // 图书馆(另一套库 —— 只读状态 + 检索 + 维护 + 核对留痕;零记忆依赖):2026-10-16 起排在最后
2374
+ var tabLib = el('div', 'ling-tab', '图书馆');
2375
+ // 结论层(深层库条目,**只读**)—— 用户 2026-10-15 拍板加这一面;2026-10-16 起紧挨「记忆树」;
2376
+ // 取名就三个字:与前五个标签同量级(4~5 字)才不会把标签条挤成一团,
2377
+ // 面内第一行再写全「深层库 · 结论层」。
2378
+ var tabDeep = el('div', 'ling-tab', '结论层');
2379
+ // 顺序(2026-10-16 用户要求):概览 · 浏览 · 记忆树 · 结论层 · 接入历史 · 图书馆
2380
+ // ⇒ 记忆相关的四面相邻,接入历史与图书馆靠后。**只改这里的追加顺序** ——
2381
+ // 切换按名字(`switchTab('tree')`)不按下标,故换序不影响任何逻辑。
2291
2382
  tabs.appendChild(tabOverview);
2292
2383
  tabs.appendChild(tabBrowse);
2293
- tabs.appendChild(tabAccess);
2294
2384
  tabs.appendChild(tabTree);
2385
+ tabs.appendChild(tabDeep);
2386
+ tabs.appendChild(tabAccess);
2387
+ tabs.appendChild(tabLib);
2295
2388
  wrap.appendChild(tabs);
2296
2389
 
2297
2390
  var body = el('div', 'mc-body');
@@ -2307,19 +2400,29 @@
2307
2400
  close.onclick = closeOverlays;
2308
2401
 
2309
2402
  function switchTab(name) {
2403
+ // 切标签即作废上一次绘制:在飞的旧请求回来时代次对不上 ⇒ 不再清 body、不再画。
2404
+ drawGen += 1;
2310
2405
  st.tab = name;
2311
2406
  tabOverview.className = 'ling-tab' + (name === 'overview' ? ' on' : '');
2312
2407
  tabBrowse.className = 'ling-tab' + (name === 'browse' ? ' on' : '');
2313
2408
  tabAccess.className = 'ling-tab' + (name === 'access' ? ' on' : '');
2314
2409
  tabTree.className = 'ling-tab' + (name === 'tree' ? ' on' : '');
2410
+ // ⚠️ 高亮必须与内容同源:这里漏一行,切到图书馆后顶部高亮还停在「记忆树」上
2411
+ // —— 本文件注释里反复出现的 C#8 那类"内容与标签不符"就是从这个缝里长出来的。
2412
+ tabLib.className = 'ling-tab' + (name === 'library' ? ' on' : '');
2413
+ tabDeep.className = 'ling-tab' + (name === 'deep' ? ' on' : '');
2315
2414
  render();
2316
2415
  }
2317
2416
  tabOverview.onclick = function () { switchTab('overview'); };
2318
2417
  tabBrowse.onclick = function () { switchTab('browse'); };
2319
2418
  tabAccess.onclick = function () { switchTab('access'); };
2320
2419
  tabTree.onclick = function () { switchTab('tree'); };
2420
+ tabLib.onclick = function () { switchTab('library'); };
2421
+ tabDeep.onclick = function () { switchTab('deep'); };
2321
2422
 
2322
2423
  function renderOverview() {
2424
+ // 本次绘制的代次(见文件内「迟到回画守卫」注):Promise.all 的尾巴只认这一枚。
2425
+ var gen = ++drawGen;
2323
2426
  body.textContent = '';
2324
2427
  body.appendChild(el('div', 'kv', '加载器灵概览…'));
2325
2428
  var g = el('div', null);
@@ -2356,7 +2459,13 @@
2356
2459
  api('/suggestions').then(function (r) { meta.sugg = r; }).catch(ov('语料建议')),
2357
2460
  api('/feedback').then(function (r) { meta.fb = r; }).catch(ov('点踩反馈')),
2358
2461
  api('/deep/status').then(function (r) { meta.deep = r; }).catch(ov('深摘状态')),
2462
+ // 后台行为的生效值 + 来源(2026-10-16)。并进这一批而不是另起一次异步回画:
2463
+ // 下面的 `gen !== drawGen` 守卫已经把"请求在飞时切走标签"这条判据写在一处,
2464
+ // 另开一路就得再抄一遍 —— 抄两份的守卫迟早漏一份。
2465
+ api('/auto').then(function (r) { meta.auto = r; }).catch(ov('后台行为')),
2359
2466
  ]).then(function () {
2467
+ // ★迟到回画守卫:期间切走标签(或本标签又重画过一次)⇒ 整条丢弃,**连 body 都不清**。
2468
+ if (gen !== drawGen || st.tab !== 'overview') return;
2360
2469
  body.textContent = '';
2361
2470
  var st8 = meta.state || {};
2362
2471
  var per = st8.persona || {};
@@ -2450,8 +2559,8 @@
2450
2559
  // 所以只说"这份包 ≈X / 整包超了上限"这个方向性事实,不假装两个数能直接相减。
2451
2560
  var sizeHint = (f && f.size > 0) ? '(≈' + fmtBytes(f.size) + ')' : '';
2452
2561
  toast('导入失败:这份包' + sizeHint + '整包超过了服务端单次上限 ' + fmtLimitMB(r.limit) + ' —— 不是包坏了。\n'
2453
- + '· 「导入记忆包」是把整份文件**一次**发出去,带对话原文的包很容易超过它(当前通道的结构限制:整包一次发不完)。\n'
2454
- + '· 怎么办:改用「⬇ 导出记忆包」重新导出一份**不含原文**的包(那个按钮默认就不含原文,体积小得多),再导入一次即可 —— 记忆/建议/队列都在,只是不含对话原文。',
2562
+ + '· 「导入记忆包」是把整份文件一次发出去,带对话原文的包很容易超过它(当前通道的结构限制:整包一次发不完)。\n'
2563
+ + '· 怎么办:改用「⬇ 导出记忆包」重新导出一份不含原文的包(那个按钮默认就不含原文,体积小得多),再导入一次即可 —— 记忆/建议/队列都在,只是不含对话原文。',
2455
2564
  12000, true);
2456
2565
  } else {
2457
2566
  // 其余失败:优先用服务端给的 `message`(它是一句中文),只有连 message 都没有时
@@ -2640,6 +2749,179 @@
2640
2749
  sumZone.appendChild(sumRes);
2641
2750
  g.appendChild(sumZone);
2642
2751
  sumStatus();
2752
+
2753
+ // ---- 后台行为(2026-10-16):自动深摘 / 启动探活 / 反馈回路的总闸 ----
2754
+ // 为什么单开这张卡片:这三件事**默认全开着**,而此前既没文档也没开关 ——
2755
+ // 自动深摘只认插件配置里的旧键(deepSummaryAuto / deepIntervalMin / deepMaxPerRun),
2756
+ // 探活延时干脆是硬编码的 12000ms;主人想看或想关,只能去手改 settings.json。
2757
+ // 两条纪律写进这张卡片:
2758
+ // · 每个值都标"是谁定的"(后端 resolveAuto 的 source:你设的 / 插件配置 / 默认)——
2759
+ // 看不出处,就没法判断"我改了到底有没有用";
2760
+ // · 生效时机**如实说**:探活延时与反馈回路是启动期读的(要重启宿主),间隔与每轮上限
2761
+ // 对已挂上的定时器不回溯 —— 写"立即生效"是谎话,比不写更坏。
2762
+ function autoZone(r) {
2763
+ var zone = el('div', 'scan-zone');
2764
+ zone.style.cssText = 'margin-top:10px;padding:10px 12px;border:1px dashed var(--lg-border);border-radius:11px;';
2765
+ var head = el('div', 'm-meta', '⚙ 后台行为(下面几项默认都开着)');
2766
+ head.style.cssText = 'font-weight:700;opacity:.9;margin-bottom:4px;';
2767
+ zone.appendChild(head);
2768
+ var eff = (r && r.effective) ? r.effective : null;
2769
+ var res = el('div', 'm-meta', '');
2770
+ res.style.cssText = 'margin-top:8px;line-height:1.7;white-space:pre-wrap;';
2771
+ if (!eff) {
2772
+ // 读不到就说读不到:这里**不显示**任何"看起来像默认值"的数字 —— 把猜的当生效值
2773
+ // 比空着更坏(主人会以为屏幕上那个 30 分钟就是后台真在跑的间隔)。
2774
+ var miss = el('div', 'hint', '读不到当前的生效值(宿主可能是旧版本,或本次没连上网关)—— '
2775
+ + '重启宿主后回到本页重看。');
2776
+ miss.style.cssText = 'margin:6px 0 0;line-height:1.7;color:#d9a441;';
2777
+ zone.appendChild(miss);
2778
+ return zone;
2779
+ }
2780
+ var SRC = { settings: '你设的', config: '插件配置', default: '默认' };
2781
+ var srcMap = eff.source || {};
2782
+ function srcLabel(k) { return SRC[srcMap[k]] || '默认'; }
2783
+ /** 一行控件 + 一行 hint。返回节点引用:保存成功后要就地改来源标签。 */
2784
+ function row(label, ctrl, hintText, key) {
2785
+ var box = el('div', null);
2786
+ box.style.cssText = 'margin-top:9px;';
2787
+ var line = el('div', 'btnrow');
2788
+ line.style.cssText = 'justify-content:flex-start;gap:8px;align-items:center;margin-top:0;';
2789
+ var lab = el('span', 'm-meta', label);
2790
+ lab.style.cssText = 'min-width:126px;font-weight:600;';
2791
+ line.appendChild(lab);
2792
+ line.appendChild(ctrl);
2793
+ var src = el('span', 'm-meta', '(' + srcLabel(key) + ')');
2794
+ src.style.cssText = 'opacity:.7;font-size:12px;';
2795
+ line.appendChild(src);
2796
+ var hint = el('div', 'hint', hintText);
2797
+ hint.style.cssText = 'margin:3px 0 0 0;line-height:1.65;opacity:.8;';
2798
+ box.appendChild(line);
2799
+ box.appendChild(hint);
2800
+ return { box: box, src: src };
2801
+ }
2802
+ /** 数字输入 + 保存按钮同排(不引新类,走既有 .btnrow 的左对齐写法)。 */
2803
+ function pair(input, btn) {
2804
+ var d = el('div', null);
2805
+ d.style.cssText = 'display:flex;gap:8px;align-items:center;';
2806
+ d.appendChild(input);
2807
+ d.appendChild(btn);
2808
+ return d;
2809
+ }
2810
+ function numInput(val, min, max) {
2811
+ var n = document.createElement('input');
2812
+ n.type = 'number';
2813
+ n.min = String(min); n.max = String(max); n.step = '1';
2814
+ n.value = String(val);
2815
+ n.style.cssText = 'width:112px;padding:5px 9px;border:1px solid var(--lg-input);border-radius:9px;background:transparent;color:var(--lg-text);font-size:13px;';
2816
+ return n;
2817
+ }
2818
+ var cbDeep = document.createElement('input');
2819
+ cbDeep.type = 'checkbox';
2820
+ cbDeep.checked = eff.deepSummary === true;
2821
+ var cbFb = document.createElement('input');
2822
+ cbFb.type = 'checkbox';
2823
+ cbFb.checked = eff.feedbackAuto === true;
2824
+ var inInt = numInput(eff.deepIntervalMin, 10, 1440);
2825
+ var inMax = numInput(eff.deepMaxPerRun, 1, 20);
2826
+ var inProbe = numInput(eff.bootProbeDelayMs, 0, 600000);
2827
+ var bInt = el('button', null, '保存'); bInt.type = 'button';
2828
+ var bMax = el('button', null, '保存'); bMax.type = 'button';
2829
+ var bProbe = el('button', null, '保存'); bProbe.type = 'button';
2830
+ var rows = {
2831
+ deepSummary: row('自动深摘', cbDeep,
2832
+ '默认开着:宿主启动探活通过后,按下面「间隔」的节奏自动挑达标的会话深摘(每次真花模型额度)。'
2833
+ + '关掉后只有你点「深摘 / 深度摘要」时才调用模型;已经深摘过的会话不受影响,只是不再自动跑新的。',
2834
+ 'deepSummary'),
2835
+ deepIntervalMin: row('间隔(分钟)', pair(inInt, bInt), '默认 30 分钟;可填 ≥10(上限 1440 = 一天)。'
2836
+ + '改动对已经挂上的定时器不回溯 —— 重启宿主后才按新间隔走。', 'deepIntervalMin'),
2837
+ deepMaxPerRun: row('每轮上限', pair(inMax, bMax), '默认 2:一轮最多深摘几个会话。'
2838
+ + '调大 = 一轮多摘几个(每个都要真花模型额度);调小 = 更省、追平更慢。同样重启后按新值走。', 'deepMaxPerRun'),
2839
+ bootProbeDelayMs: row('启动探活(ms)', pair(inProbe, bProbe),
2840
+ '默认 12000ms(12 秒):宿主启动后先等这么久,再做一次 LLM 探活,探通了才挂自动深摘。'
2841
+ + '0 = 立即(刚启动时模型可能还没就绪,探活更容易失败)。这一项是启动时读的 ⇒ 下次重启才生效。',
2842
+ 'bootProbeDelayMs'),
2843
+ feedbackAuto: row('反馈回路', cbFb,
2844
+ '默认开着:宿主收到反馈事件就当场处理,不去轮询文件。关掉后反馈本身照样被平台记下来,'
2845
+ + '只是器灵不再自动把它们变成建议(你仍可在「建议中心」手动处理)。这一项也是启动时读的 ⇒ 下次重启才生效。',
2846
+ 'feedbackAuto'),
2847
+ };
2848
+ /** 把一份 effective 铺回控件与来源标签(保存成功后用;保存失败时用它回滚视觉状态)。 */
2849
+ function paint(e) {
2850
+ cbDeep.checked = e.deepSummary === true;
2851
+ cbFb.checked = e.feedbackAuto === true;
2852
+ inInt.value = String(e.deepIntervalMin);
2853
+ inMax.value = String(e.deepMaxPerRun);
2854
+ inProbe.value = String(e.bootProbeDelayMs);
2855
+ srcMap = e.source || srcMap;
2856
+ for (var k in rows) rows[k].src.textContent = '(' + srcLabel(k) + ')';
2857
+ }
2858
+ function save(patch, what) {
2859
+ res.textContent = '保存中…';
2860
+ api('/auto', { method: 'POST', body: { patch: patch } }).then(function (rr) {
2861
+ // ★迟到回画守卫:保存期间切走标签(或本标签又重画过一次)⇒ 这枚节点已经不是屏幕上的
2862
+ // 那张卡片,一个字都不许再写(写进去主人也看不见,还会把新卡片的状态搅乱)。
2863
+ if (gen !== drawGen || st.tab !== 'overview') return;
2864
+ if (!rr || !rr.ok) {
2865
+ paint(eff); // 没能落盘 ⇒ 视觉状态退回"存着的那个值",不假装已改
2866
+ res.textContent = '✗ ' + ((rr && (rr.message || rr.error)) || '保存失败');
2867
+ return;
2868
+ }
2869
+ var e2 = rr.effective || {};
2870
+ paint(e2);
2871
+ var parts = ['自动深摘 ' + (e2.deepSummary ? '开' : '关'),
2872
+ '间隔 ' + e2.deepIntervalMin + ' 分钟', '每轮 ' + e2.deepMaxPerRun + ' 个',
2873
+ '探活延时 ' + e2.bootProbeDelayMs + 'ms', '反馈回路 ' + (e2.feedbackAuto ? '开' : '关')];
2874
+ var extra = [];
2875
+ if ((rr.clamped || []).length) extra.push('填的值被夹到允许范围内:' + rr.clamped.join(' / '));
2876
+ if ((rr.dropped || []).length) extra.push('忽略了不认识的字段:' + rr.dropped.join(' / '));
2877
+ res.textContent = '✓ 已保存(' + what + ')· 现在:' + parts.join(' · ')
2878
+ + (extra.length ? '\n(' + extra.join(';') + ')' : '');
2879
+ toast('后台行为已保存(' + what + ')。\n'
2880
+ + '· 现在:' + parts.join(' · ') + '\n'
2881
+ + '· 探活延时与反馈回路是宿主启动时读的 ⇒ 下次重启后生效;\n'
2882
+ + '· 间隔与每轮上限对已经挂上的定时器不回溯 ⇒ 同样是重启后按新值走(已存下的记忆不受影响)。',
2883
+ 9000, true);
2884
+ }).catch(function () {
2885
+ if (gen !== drawGen || st.tab !== 'overview') return;
2886
+ paint(eff);
2887
+ res.textContent = '✗ 保存失败(网关不可达)';
2888
+ });
2889
+ }
2890
+ cbDeep.onchange = function () {
2891
+ save({ deepSummary: cbDeep.checked }, cbDeep.checked ? '打开自动深摘' : '关掉自动深摘');
2892
+ };
2893
+ cbFb.onchange = function () {
2894
+ save({ feedbackAuto: cbFb.checked }, cbFb.checked ? '打开反馈回路' : '关掉反馈回路');
2895
+ };
2896
+ // 三个数字行各自的保存按钮:非数字在前端就拦下(后端对类型不符是**丢弃**并回报 ——
2897
+ // 让主人当场看见"填的不是数字",比让它静默消失更清楚)。
2898
+ function numSave(input, key, what, hint) {
2899
+ return function () {
2900
+ var v = Number(input.value);
2901
+ if (String(input.value).trim() === '' || !isFinite(v)) {
2902
+ res.textContent = '✗ ' + hint;
2903
+ return;
2904
+ }
2905
+ var p = {}; p[key] = v;
2906
+ save(p, what);
2907
+ };
2908
+ }
2909
+ bInt.onclick = numSave(inInt, 'deepIntervalMin', '间隔', '「间隔」请填一个数字(分钟,≥10)');
2910
+ bMax.onclick = numSave(inMax, 'deepMaxPerRun', '每轮上限', '「每轮上限」请填一个数字(≥1)');
2911
+ bProbe.onclick = numSave(inProbe, 'bootProbeDelayMs', '启动探活延时', '「启动探活」请填一个毫秒数(≥0)');
2912
+ zone.appendChild(rows.deepSummary.box);
2913
+ zone.appendChild(rows.deepIntervalMin.box);
2914
+ zone.appendChild(rows.deepMaxPerRun.box);
2915
+ zone.appendChild(rows.bootProbeDelayMs.box);
2916
+ // 只读一行:兜底模型(读不到你当前模型选择时才会用到,插件不改你的模型选择)。
2917
+ var note = el('div', 'hint', '兜底模型 deepseek-official/deepseek-v4-flash(只在读不到你当前模型选择时使用)。');
2918
+ note.style.cssText = 'margin:9px 0 0;line-height:1.65;opacity:.8;';
2919
+ zone.appendChild(note);
2920
+ zone.appendChild(rows.feedbackAuto.box);
2921
+ zone.appendChild(res);
2922
+ return zone;
2923
+ }
2924
+ g.appendChild(autoZone(meta.auto));
2643
2925
  body.appendChild(g);
2644
2926
  });
2645
2927
  }
@@ -2647,12 +2929,18 @@
2647
2929
  // D8(2026-09-17):先取各来源条数,筛选栏直接显示「历史网页端 (1523)」——
2648
2930
  // 否则 95% 的库(dsweb 1523/1599)在默认视图里沉底,主人无从知道库里到底有什么。
2649
2931
  function renderBrowse() {
2932
+ // 本次绘制的代次(见文件内「迟到回画守卫」注)。
2933
+ var gen = ++drawGen;
2650
2934
  body.textContent = '';
2651
2935
  body.appendChild(el('div', 'm-empty', '加载中…'));
2652
2936
  api('/memories/sources').then(function (sc) {
2937
+ // ★迟到回画守卫:两路(.then/.catch)**都要** —— 失败路径同样会清 body 重画,
2938
+ // 只守成功那一路的话,"请求在飞时切走、回来时失败"照样把第五面板顶掉。
2939
+ if (gen !== drawGen || st.tab !== 'browse') return;
2653
2940
  body.textContent = '';
2654
2941
  renderBrowseInner(sc && sc.ok ? sc : null);
2655
2942
  }).catch(function () {
2943
+ if (gen !== drawGen || st.tab !== 'browse') return;
2656
2944
  body.textContent = '';
2657
2945
  renderBrowseInner(null);
2658
2946
  });
@@ -2910,7 +3198,7 @@
2910
3198
 
2911
3199
  // ---------------- 记忆树 · 复盘(D9-b,2026-09-19) ----------------
2912
3200
  // 树形结构 + 复盘驱动的操作:并脉(归类) / 连边 / 调权重 / 矛盾裁定。
2913
- // 「一键复盘」按尝生定的设计约束(§10.1)把开场材料一次备齐 —— 复盘的门槛必须接近 0,
3201
+ // 「一键复盘」按用户定的设计约束(§10.1)把开场材料一次备齐 —— 复盘的门槛必须接近 0,
2914
3202
  // 否则它会像"整理房间"一样被无限推迟。树的任何操作都**不改写记忆内容**。
2915
3203
  var TREE_KIND = { trunk: '主干', branch: '枝', vein: '主脉' };
2916
3204
  var TREE_COLOR = { trunk: 'var(--lg-primary)', branch: 'var(--lg-cyan)', vein: '#c58b2f' };
@@ -2943,12 +3231,20 @@
2943
3231
  }
2944
3232
 
2945
3233
  function loadTreeAll() {
3234
+ // ★迟到回画守卫第一层:本函数**先清 body 再拉数据**,而它的调用点里有异步尾巴
3235
+ // (autotree 应用完成后的 loadTreeAll、侧栏 lingCenterReload)。已不在记忆树标签就
3236
+ // 直接不进 —— 否则会把主人正在看的标签(含第五「图书馆」)清成"正在读取记忆树…"。
3237
+ if (st.tab !== 'tree') return;
3238
+ var gen = ++drawGen;
2946
3239
  body.textContent = '';
2947
3240
  body.appendChild(el('div', 'kv', '正在读取记忆树…'));
2948
3241
  Promise.all([
2949
3242
  api('/tree').catch(function () { return null; }),
2950
3243
  api('/conflicts').catch(function () { return null; }),
2951
3244
  ]).then(function (rs) {
3245
+ // ★迟到回画守卫第二层:代次对不上(切走过,或又发起过一次 loadTreeAll)整条丢弃 ——
3246
+ // 连 st.treeData 都不写:写进去会让下一次 paintTree 拿旧数据画,比不画更坏(看着像最新)。
3247
+ if (gen !== drawGen || st.tab !== 'tree') return;
2952
3248
  st.treeData = (rs[0] && rs[0].ok) ? rs[0] : { roots: [], links: [], total: 0, failed: true };
2953
3249
  st.conflicts = (rs[1] && rs[1].ok) ? (rs[1].conflicts || []) : [];
2954
3250
  if (st.treeSel && !branchById(st.treeSel)) st.treeSel = '';
@@ -2973,7 +3269,7 @@
2973
3269
  };
2974
3270
  }
2975
3271
  row.appendChild(tri);
2976
- // 显示层(尝生 2026-09-21 定):前端不写运算层级名;主干用一句话表示"还在生长"。
3272
+ // 显示层(用户 2026-09-21 定):前端不写运算层级名;主干用一句话表示"还在生长"。
2977
3273
  var isTrunk = n.id === ((st.treeData && st.treeData.trunk) || 'trunk');
2978
3274
  var badge = el('span', null, isTrunk ? '🌱 你们的故事正在生长' : (TREE_KIND[n.kind] || n.kind));
2979
3275
  badge.style.cssText = 'font-size:11px;padding:1px 6px;border-radius:6px;flex:none;'
@@ -2987,7 +3283,7 @@
2987
3283
  + (n.weightScale && Number(n.weightScale) !== 1 ? ' · ×' + n.weightScale : ''));
2988
3284
  meta.style.cssText = 'flex:none;opacity:.72;';
2989
3285
  row.appendChild(meta);
2990
- // ---- 连边小标记(尝生 2026-09-25) ----
3286
+ // ---- 连边小标记(用户 2026-09-25) ----
2991
3287
  // 与侧栏记忆树同款:行内只留一个 ⇄N,不在这里铺开"连到哪里" ——
2992
3288
  // 选中本行后,下方"横向连边"区块才展开它的详情。
2993
3289
  var myLinks = (st.linkIndex && st.linkIndex[n.id]) || [];
@@ -3073,7 +3369,7 @@
3073
3369
 
3074
3370
  // ---------------- 一键生成记忆树(autotree,2026-09-20) ----------------
3075
3371
  // 三阶段:① 机械分段(零模型调用) ② 器灵批量命名 ③ 主人改名字后应用。
3076
- // 尝生的设计约束:模型只做"命名"这一件语义活;分段是机械活,不烧 token,也不该由模型做。
3372
+ // 用户的设计约束:模型只做"命名"这一件语义活;分段是机械活,不烧 token,也不该由模型做。
3077
3373
  function loadAutoPreview() {
3078
3374
  if (st.autoBusy) return;
3079
3375
  st.autoBusy = true;
@@ -3161,6 +3457,9 @@
3161
3457
  toast('已建 ' + nCreated + ' 条枝'
3162
3458
  + (r.skipped ? '(跳过 ' + r.skipped + ' 条)' : '')
3163
3459
  + (r.note ? ' —— ' + r.note : ''));
3460
+ // 先作废树缓存再重载:loadTreeAll 在"已不在记忆树标签"时会直接不进(迟到回画守卫),
3461
+ // 若不作废,切回来时 renderTree 会拿**建枝之前**的旧缓存重画(看着像最新的)。
3462
+ st.treeData = null;
3164
3463
  loadTreeAll();
3165
3464
  }).catch(function () { st.autoBusy = false; paintTree(); toast('✗ 应用失败(网关)'); });
3166
3465
  }
@@ -3206,7 +3505,7 @@
3206
3505
  inp.oninput = function () { st.autoNames[b.id] = inp.value; };
3207
3506
  row.appendChild(inp);
3208
3507
  // 「看内容」:展开这一簇实际包含哪些标题 —— 只给一个名字(如"零散记录")时
3209
- // 主人无从判断该不该采纳(尝生实测反馈)。内容来自 /tree/autobuild/preview 的 sample。
3508
+ // 主人无从判断该不该采纳(用户实测反馈)。内容来自 /tree/autobuild/preview 的 sample。
3210
3509
  var bPeek = el('button', null, st.autoExpand[b.id] ? '▾ 收起' : '▸ 看内容');
3211
3510
  bPeek.type = 'button';
3212
3511
  bPeek.style.cssText = 'flex:none;font-size:11px;padding:1px 7px;';
@@ -3233,13 +3532,17 @@
3233
3532
  }
3234
3533
 
3235
3534
  function paintTree() {
3535
+ // ★迟到回画守卫第三层(一处覆盖全部 14 个调用点):autotree 的三段流程里有多处
3536
+ // `.then(... paintTree())` 与 setTimeout 重试尾巴,它们可能在主人切走之后才回来。
3537
+ // 不在记忆树标签 ⇒ 一个字节都不画。数据仍留在 st 里,切回来时 renderTree 会重画。
3538
+ if (st.tab !== 'tree') return;
3236
3539
  body.textContent = '';
3237
3540
  var td = st.treeData || { roots: [], links: [], total: 0 };
3238
3541
  var all = treeAll();
3239
3542
  var links = td.links || [];
3240
3543
  var conf = st.conflicts || [];
3241
3544
  var pend = conf.filter(function (c) { return c.status === 'pending'; });
3242
- // ---- 连边索引(尝生 2026-09-25):节点 id → 挂在它身上的连边 ----
3545
+ // ---- 连边索引(用户 2026-09-25):节点 id → 挂在它身上的连边 ----
3243
3546
  // 用途两个:① nodeRow 行内画小标记(⇄N),让"这条枝有横向连边"平时可见;
3244
3547
  // ② 下方"横向连边"区块改为**只显示选中项**那几条 —— 原先是无条件铺开全部,
3245
3548
  // 连边一多这块就越来越臃肿,把真正该看的矛盾区挤下去。
@@ -3252,11 +3555,11 @@
3252
3555
  });
3253
3556
 
3254
3557
  var tools = el('div', 'btnrow');
3255
- // 左对齐(尝生 2026-09-25):`.btnrow` 的全局 CSS 是 justify-content:flex-end(对"保存/取消"
3558
+ // 左对齐(用户 2026-09-25):`.btnrow` 的全局 CSS 是 justify-content:flex-end(对"保存/取消"
3256
3559
  // 这类按钮行合适),但树工具条挂在面板左上,右对齐会让它飘到面板另一头,与下方树列表脱节。
3257
3560
  // 就地覆盖,不动全局 class(它被另外 15 处按钮行复用)。
3258
3561
  tools.style.cssText = 'margin:0 0 4px;flex-wrap:wrap;justify-content:flex-start;';
3259
- // 记忆中心已改为**只读视图**(2026-09-22 尝生裁决):扫重复 / 模型判定 / 归并零散枝 /
3562
+ // 记忆中心已改为**只读视图**(2026-09-22 用户裁决):扫重复 / 模型判定 / 归并零散枝 /
3260
3563
  // 解锁编辑等**复盘操作**全部移到右侧「器灵 · 复盘」栏 —— 这里只留「看」与一个总入口。
3261
3564
  var bRefresh = el('button', null, '刷新');
3262
3565
  var bReview = el('button', null, '🌱 一键复盘');
@@ -3277,7 +3580,7 @@
3277
3580
  treeHead.style.cssText = 'margin:2px 0 4px;font-weight:700;opacity:.9;';
3278
3581
  body.appendChild(treeHead);
3279
3582
  var treeBox = el('div', null);
3280
- // 选中/重绘时保持滚动位置(否则每次重建 DOM 都会跳回顶部 —— 尝生 2026-09-22 反馈)
3583
+ // 选中/重绘时保持滚动位置(否则每次重建 DOM 都会跳回顶部 —— 用户 2026-09-22 反馈)
3281
3584
  treeBox.onscroll = function () { st.treeScroll = treeBox.scrollTop; };
3282
3585
  treeBox.style.cssText = 'border:1px solid var(--lg-border);border-radius:11px;padding:6px 8px;'
3283
3586
  + 'max-height:34vh;overflow:auto;';
@@ -3302,7 +3605,7 @@
3302
3605
  : '🔒 树平时只读 —— 进入复盘模式后可修改。(先点一条枝 / 主脉选中它)'));
3303
3606
  body.appendChild(ops);
3304
3607
 
3305
- // ---- 横向连边(尝生 2026-09-25 改):只显示**选中项**的连边 ----
3608
+ // ---- 横向连边(用户 2026-09-25 改):只显示**选中项**的连边 ----
3306
3609
  // 原先是无条件铺开全部连边 ⇒ 连边一多这块就越来越臃肿,把真正该看的矛盾区挤到下面。
3307
3610
  // 现在:行内只留 ⇄N 小标记(nodeRow 里画),选中哪条才展开它的详情;
3308
3611
  // 未选中时只给一行"共 N 条"的提示,可发现性不丢。
@@ -3356,7 +3659,7 @@
3356
3659
  var cHead = el('div', 'm-meta', '矛盾标记(待复盘 ' + pend.length + ' / 全部 ' + conf.length + ')');
3357
3660
  cHead.style.cssText = 'margin:12px 0 4px;font-weight:700;opacity:.9;';
3358
3661
  body.appendChild(cHead);
3359
- var cHint = el('div', 'm-meta', '检出但未复盘时 —— 检索以**较新**的那条为准(旧的一条降权,不删除、不改写内容)。');
3662
+ var cHint = el('div', 'm-meta', '检出但未复盘时 —— 检索以较新的那条为准(旧的一条降权,不删除、不改写内容)。');
3360
3663
  cHint.style.cssText = 'margin-bottom:6px;opacity:.8;line-height:1.6;';
3361
3664
  body.appendChild(cHint);
3362
3665
  if (!conf.length) {
@@ -3384,7 +3687,7 @@
3384
3687
  + (c.winner ? ' · 以 ' + (c.winner === 'a' ? 'A' : 'B') + ' 为准' : ''));
3385
3688
  badge.style.cssText = 'opacity:.75;margin-bottom:4px;';
3386
3689
  card.appendChild(badge);
3387
- // 2026-09-21:每侧标题可点开看**摘要全文** —— 只给标题时人判不出"到底哪矛盾"(尝生实测反馈)。
3690
+ // 2026-09-21:每侧标题可点开看**摘要全文** —— 只给标题时人判不出"到底哪矛盾"(用户实测反馈)。
3388
3691
  function sideLine(tag, title, summary, convId) {
3389
3692
  var wrap = el('div', null);
3390
3693
  var head = el('div', null, tag + ':' + (title || convId || '?'));
@@ -3621,8 +3924,11 @@
3621
3924
  g.appendChild(cardD);
3622
3925
  body.appendChild(g);
3623
3926
 
3927
+ var importLogSeq = 0; // F-06 修复:导入日志世代守卫(照抄 libSeq 模式,见 :4445)
3624
3928
  function renderImportLog() {
3929
+ var token = ++importLogSeq;
3625
3930
  api('/import/history').then(function (r) {
3931
+ if (token !== importLogSeq) return; // 迟到的旧应答:丢弃(不把上一次的结果画进这一次)
3626
3932
  logOut.textContent = '';
3627
3933
  if (!r || !r.ok || !r.items || !r.items.length) {
3628
3934
  logOut.appendChild(el('div', 'm-empty', '还没有导入记录 —— 从上面把一段历史请进来,这里就会留账。'));
@@ -3632,7 +3938,10 @@
3632
3938
  logOut.appendChild(el('div', null, it.line || ''));
3633
3939
  });
3634
3940
  if (r.items.length > 20) logOut.appendChild(el('div', 'm-meta', '(仅显示最近 20 条)'));
3635
- }).catch(function () { logOut.textContent = '记录加载失败(网关不可达)'; });
3941
+ }).catch(function () {
3942
+ if (token !== importLogSeq) return; // 同理:迟到的失败也不许凭空写新视图
3943
+ logOut.textContent = '记录加载失败(网关不可达)';
3944
+ });
3636
3945
  }
3637
3946
  bLogRefresh.onclick = renderImportLog;
3638
3947
  renderImportLog();
@@ -4094,7 +4403,7 @@
4094
4403
  } else if (skipped) {
4095
4404
  lines.push('⚠ 导入不完全:写入新记忆 ' + totals.newRows + ' 条 · 刷新已有 ' + totals.refreshed + ' 条,另有 ' +
4096
4405
  skipped + ' 条会话单条太长、没能通过导入通道(已跳过,见下)');
4097
- lines.push(' 这不是文件坏了:是那几条会话**自己**就超过了单次上限 —— 拆小导出文件也拆不开单条。');
4406
+ lines.push(' 这不是文件坏了:是那几条会话自己就超过了单次上限 —— 拆小导出文件也拆不开单条。');
4098
4407
  } else {
4099
4408
  lines.push('✓ 导入完成:写入新记忆 ' + totals.newRows + ' 条 · 刷新已有 ' + totals.refreshed + ' 条' +
4100
4409
  (totals.upgraded ? ' · 补全对话原文 ' + totals.upgraded + ' 条' : '') +
@@ -4106,7 +4415,7 @@
4106
4415
  // 措辞要点:① 说清**为什么**(该会话在网页端旧库里已经有原文);② 说清**不是失败、也没丢东西**
4107
4416
  // (保住的是原有的真原文,是**更安全**的那一边);③ 与 `⚠` 那两档分开 —— 那两档是真的不完全,这档不是。
4108
4417
  if (notReplaced) {
4109
- lines.push(' ⓘ 另有 ' + notReplaced + ' 轮原文没有替换:这些会话在网页端旧库里**已经有原文**了 —— '
4418
+ lines.push(' ⓘ 另有 ' + notReplaced + ' 轮原文没有替换:这些会话在网页端旧库里已经有原文了 —— '
4110
4419
  + '保留原有的那份、不覆盖它(不是失败,也没有丢东西)。');
4111
4420
  }
4112
4421
  rejPreview.forEach(function (x) { lines.push(' ⚠ ' + x); });
@@ -4128,12 +4437,2183 @@
4128
4437
  fileInput.onchange = preview;
4129
4438
  }
4130
4439
 
4440
+ // ---------------- 图书馆(独立库,不与记忆混排) ----------------
4441
+ // 为什么另立一格状态(见 st.lib)而不是塞进既有字段:图书馆是**另一套存储**
4442
+ // (library.db / LibraryStore)——判据是"有源可对账":条目 = 值 + 出处 + 取数时间 + 核对规格。
4443
+ // 它零注入(不参与开场装配、不进 L1、不与记忆条目混排),所以本标签**不读任何记忆端点**,
4444
+ // 也不做迁移/种子填充(契约 §Permanent Boundary:存进记忆库的数据永不迁进图书馆)。
4445
+ //
4446
+ // 三条从契约直接落下来的界面口径(写在这里,免得后来的人"顺手"改掉):
4447
+ // ① 只读字段(content_hash / state / checked_state / checked_at / 修订链)只**展示**;
4448
+ // 提交体里绝不带它们 —— 带了会被 store 以 readonly_field 拒(拒绝而非静默剥离,
4449
+ // 所以"顺手清掉再提交"这条路不存在)。
4450
+ // ② 检索回执**没有 total**(只有 count/nextOffset/truncated,library-store.js:1298-1309)
4451
+ // ⇒ 界面只说"本页几条 + 还有没有更多",绝不把本页条数说成"共 N 条"。
4452
+ // ③ state=ok 只代表"**声明的那一处比较**与源一致"(契约 §Checker Surface 末句),
4453
+ // 不代表正文/解释/整条资料为真 —— 这句原文照搬到界面上。
4454
+ // 安全:全部资料文本与错误都经 el() 的 textContent 出口(本文件一处 innerHTML 都没有);
4455
+ // 资料里写 <img onerror=…> 只会是**字符**,不会变成节点。
4456
+ var LIB_CAT = { 'standard-value': '标准值', boundary: '边界', 'project-spec': '项目规格', 'external-api': '外部接口' };
4457
+ var LIB_SRC = { standard: '标准', 'official-doc': '官方文档', manual: '人工', 'our-code': '自有代码', web: '网页' };
4458
+ var LIB_ST = { active: '有效', superseded: '已被新版取代', withdrawn: '已撤下' };
4459
+ var LIB_CK = { ok: '一致', wrong: '不一致', unknown: '未能判定' };
4460
+ var LIB_CMP = { exact: 'exact(逐字节)', 'text-lf': 'text-lf(只归一 CRLF/CR)' };
4461
+ // 三个竞态令牌(单调递增整数)。为什么必须有:一次点击 = 一个异步往返,而面板是**同一个
4462
+ // body 反复重画**的 —— 没有令牌时,先发的慢应答会后到,把后来那份内容顶掉(串条目/串列表)。
4463
+ var libSeq = 0; // 检索
4464
+ var libDetailSeq = 0; // 条目详情 / 修订链 / 核对留痕
4465
+ var libActionSeq = 0; // 写操作(新建/修订/撤下/核对)
4466
+
4467
+ function libMap(map, key, fallback) {
4468
+ if (key === null || key === undefined || key === '') return fallback || '—';
4469
+ return Object.prototype.hasOwnProperty.call(map, key) ? map[key] : String(key);
4470
+ }
4471
+ /** 失败回执的人话:`message` 优先(后端写得更准),`reason` 是机器码,**两个都留着**。 */
4472
+ function libWhy(r, fallback) {
4473
+ if (!r) return fallback;
4474
+ var m = r.message ? String(r.message) : '';
4475
+ var c = r.reason ? String(r.reason) : '';
4476
+ if (m && c) return m + '(' + c + ')';
4477
+ return m || c || fallback;
4478
+ }
4479
+ function libHash(h) {
4480
+ var s = String(h || '');
4481
+ return s === '' ? '—' : (s.length > 26 ? (s.slice(0, 18) + '…' + s.slice(-6)) : s);
4482
+ }
4483
+ function libShow(v) {
4484
+ return String(v === null || v === undefined || v === '' ? '—' : v);
4485
+ }
4486
+ function libKV(grid, label, value, mono) {
4487
+ grid.appendChild(el('div', 'k', label));
4488
+ grid.appendChild(el('div', mono ? 'lib-mono' : null, libShow(value)));
4489
+ return grid;
4490
+ }
4491
+ function libStat() {
4492
+ var L = st.lib;
4493
+ var s = L.status && L.status.store ? L.status.store : null;
4494
+ var t = s && s.items ? Number(s.items.total) : null;
4495
+ return Number.isFinite(t) ? t : null;
4496
+ }
4497
+ function libSpecText(spec) {
4498
+ if (spec === null || spec === undefined) return '(null —— 这条不可核对:没有声明任何可机械比较的东西)';
4499
+ try { return JSON.stringify(spec, null, 2); } catch (e) { return '(规格无法序列化)'; }
4500
+ }
4501
+
4502
+ // ---- 状态栏:不可用/未启用必须**显式**,且与"零命中"分开说 ----
4503
+ function libStatusBox() {
4504
+ var L = st.lib;
4505
+ if (!L.statusLoaded) return el('div', 'lib-status', '正在读取图书馆状态…');
4506
+ var r = L.status, tone = 'ok', text = '';
4507
+ if (L.statusErr || !r) {
4508
+ tone = 'bad';
4509
+ text = '图书馆状态读不到(' + (L.statusErr || '网关没有回执') + ')。若下面的检索也失败,说明网关/运行时整体不可达,而不是"图书馆是空的"。';
4510
+ } else if (r.ok === false) {
4511
+ tone = 'bad';
4512
+ text = '图书馆不可用:' + libWhy(r, '运行时未给出原因');
4513
+ } else if (r.enabled === false) {
4514
+ tone = 'warn';
4515
+ text = '图书馆未启用(配置 library.enabled = false)—— 记忆与其余四个标签不受影响。';
4516
+ } else if (r.available === false) {
4517
+ tone = 'bad';
4518
+ text = '图书馆不可用:' + libWhy(r, '存储或裁判员未就绪');
4519
+ } else {
4520
+ var s = (r.store && r.store.ok !== false) ? r.store : null;
4521
+ var c = r.checker || null;
4522
+ var bits = [];
4523
+ if (s && s.items) {
4524
+ bits.push('条目 ' + Number(s.items.total || 0)
4525
+ + '(有效 ' + Number(s.items.active || 0) + ' / 已取代 ' + Number(s.items.superseded || 0)
4526
+ + ' / 已撤下 ' + Number(s.items.withdrawn || 0) + ')');
4527
+ }
4528
+ if (s && s.checks) bits.push('核对留痕 ' + Number(s.checks.total || 0) + ' 条(已采纳 ' + Number(s.checks.adopted || 0) + ')');
4529
+ if (c) {
4530
+ bits.push('裁判员' + (c.closed ? '已关闭' : '就绪')
4531
+ + (Number(c.activeCount) > 0 ? '(在飞 ' + Number(c.activeCount) + ')' : '')
4532
+ + ' · 传输 ' + (c.transport || '?')
4533
+ + ' · 源根 ' + Number(c.codeRootCount || 0) + ' 个 / 官方 URL ' + Number(c.officialUrlCount || 0) + ' 条');
4534
+ }
4535
+ text = '图书馆可用' + (bits.length ? ' · ' + bits.join(' · ') : '');
4536
+ }
4537
+ var box = el('div', 'lib-status ' + tone);
4538
+ box.appendChild(el('div', null, text));
4539
+ box.appendChild(el('div', 'm-meta',
4540
+ '图书馆是独立于记忆库的另一套存储:不参与开场注入、不与记忆条目混排。'
4541
+ + '这里只放"有源可对账"的资料(值 + 出处 + 取数时间 + 核对规格),'
4542
+ + '核对只认证声明的那一处比较,不认证正文或解释。'));
4543
+ return box;
4544
+ }
4545
+ function libLoadStatus(force) {
4546
+ var L = st.lib;
4547
+ // 读在飞时又来了写动作 ⇒ 记一笔"读完再补一次":直接 return 会把这次失效请求**吞掉**,
4548
+ // 屏幕上就留下"写之后统计不动"(2026-10-07 真机缺陷的同一个缝)。
4549
+ if (L.statusLoading) { if (force) L.statusAgain = true; return; }
4550
+ if (L.statusLoaded && !force) return;
4551
+ L.statusLoading = true;
4552
+ var done = function (r, err) {
4553
+ L.statusLoading = false;
4554
+ L.statusLoaded = true;
4555
+ L.status = r || null;
4556
+ L.statusErr = err || (r ? '' : '网关没有回执');
4557
+ if (st.tab === 'library') renderLibrary();
4558
+ if (L.statusAgain) { L.statusAgain = false; libLoadStatus(true); }
4559
+ };
4560
+ api('/library/status').then(function (r) { done(r, ''); })
4561
+ .catch(function () { done(null, '网关不可达'); });
4562
+ }
4563
+
4564
+ // ---- 表单:字段值一律存在 st.lib.form 里 ⇒ 出错重画**不抹掉**主人填的东西 ----
4565
+ function libBlankForm() {
4566
+ return {
4567
+ title: '', body: '', category: 'standard-value', source_kind: 'manual',
4568
+ source_ref: '', as_of: '', version: '', conditions: '',
4569
+ specMode: 'builder', specKind: 'none', start: '1', end: '1', pointer: '',
4570
+ expectedText: '', expectedJson: '', comparison: 'exact', specJson: '',
4571
+ };
4572
+ }
4573
+ function libFormFromItem(it) {
4574
+ var f = libBlankForm();
4575
+ f.title = it.title || '';
4576
+ f.body = it.body || '';
4577
+ f.category = it.category || 'standard-value';
4578
+ f.source_kind = it.source_kind || 'manual';
4579
+ f.source_ref = it.source_ref || '';
4580
+ f.as_of = it.as_of || '';
4581
+ f.version = it.version === null || it.version === undefined ? '' : String(it.version);
4582
+ f.conditions = it.conditions === null || it.conditions === undefined
4583
+ ? '' : (typeof it.conditions === 'string' ? it.conditions : JSON.stringify(it.conditions, null, 2));
4584
+ var sp = it.check_spec;
4585
+ if (sp && sp.locator) {
4586
+ f.comparison = sp.comparison === 'text-lf' ? 'text-lf' : 'exact';
4587
+ if (sp.locator.kind === 'lines') {
4588
+ f.specKind = 'lines';
4589
+ f.start = String(sp.locator.start);
4590
+ f.end = String(sp.locator.end);
4591
+ f.expectedText = typeof sp.expected === 'string' ? sp.expected : JSON.stringify(sp.expected);
4592
+ } else if (sp.locator.kind === 'json-pointer') {
4593
+ f.specKind = 'json-pointer';
4594
+ f.pointer = String(sp.locator.pointer === undefined ? '' : sp.locator.pointer);
4595
+ f.expectedJson = JSON.stringify(sp.expected === undefined ? null : sp.expected, null, 2);
4596
+ }
4597
+ }
4598
+ f.specJson = (sp === null || sp === undefined) ? 'null' : JSON.stringify(sp, null, 2);
4599
+ return f;
4600
+ }
4601
+ /** 必填 + as_of 形状(客户端先拦一道,免得把"我以为填了"发出去;服务端仍会再判一次)。 */
4602
+ function libFormProblems(f) {
4603
+ var out = [];
4604
+ if (!String(f.title || '').trim()) out.push('title 必填');
4605
+ if (String(f.body || '') === '') out.push('body 必填(正文不能为空)');
4606
+ if (!String(f.source_ref || '').trim()) out.push('source_ref 必填(没有出处就不许进图书馆)');
4607
+ var asOf = String(f.as_of || '').trim();
4608
+ if (asOf === '') out.push('as_of 必填(显式 ISO 日期或带 Z 的 UTC 时刻,不由创建时间推断)');
4609
+ else if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}:\d{2}(\.\d{1,9})?Z)?$/.test(asOf)) {
4610
+ out.push('as_of 形状不对:要 YYYY-MM-DD,或带 Z 的 UTC 时刻(如 2026-10-06T00:00:00Z)');
4611
+ }
4612
+ return out;
4613
+ }
4614
+ /** conditions:空 = 不写;以 { 或 [ 开头按 JSON 解析,否则按纯文本(store 两种都收)。 */
4615
+ function libConditionsOf(f) {
4616
+ var raw = String(f.conditions || '').trim();
4617
+ if (raw === '') return { ok: true, value: undefined };
4618
+ if (raw.charAt(0) === '{' || raw.charAt(0) === '[') {
4619
+ try { return { ok: true, value: JSON.parse(raw) }; }
4620
+ catch (e) { return { ok: false, message: 'conditions JSON 解析失败:' + ((e && e.message) || '语法错误') }; }
4621
+ }
4622
+ return { ok: true, value: raw };
4623
+ }
4624
+ /** 核对规格:{ok:true,value}(null = 不可核)或 {ok:false,message}。**提交前**就要能报错。 */
4625
+ function libSpecProblems(f) {
4626
+ if (f.specMode === 'json') {
4627
+ var raw = String(f.specJson || '').trim();
4628
+ if (raw === '') return { ok: true, value: null };
4629
+ var v;
4630
+ try { v = JSON.parse(raw); }
4631
+ catch (e) { return { ok: false, message: '核对规格 JSON 解析失败:' + ((e && e.message) || '语法错误') }; }
4632
+ if (v === null) return { ok: true, value: null };
4633
+ if (typeof v !== 'object' || Array.isArray(v)) {
4634
+ return { ok: false, message: '核对规格只能是 JSON 对象或 null;有限格式:{locator:{kind,start,end|pointer},expected,comparison}' };
4635
+ }
4636
+ return { ok: true, value: v };
4637
+ }
4638
+ if (f.specKind === 'none') return { ok: true, value: null };
4639
+ if (f.specKind === 'lines') {
4640
+ var s = String(f.start === undefined ? '' : f.start).trim();
4641
+ var e2 = String(f.end === undefined ? '' : f.end).trim();
4642
+ if (!/^\d+$/.test(s) || !/^\d+$/.test(e2) || Number(s) < 1 || Number(e2) < Number(s)) {
4643
+ return { ok: false, message: 'lines 定位要 1 ≤ start ≤ end 的整数行号(现在是 start=' + libShow(s) + ' end=' + libShow(e2) + ')' };
4644
+ }
4645
+ if (f.comparison === 'text-lf' && typeof f.expectedText !== 'string') {
4646
+ return { ok: false, message: 'text-lf 只用于文本:expected 必须是文本' };
4647
+ }
4648
+ return {
4649
+ ok: true,
4650
+ value: {
4651
+ locator: { kind: 'lines', start: Number(s), end: Number(e2) },
4652
+ expected: String(f.expectedText === undefined ? '' : f.expectedText),
4653
+ comparison: f.comparison,
4654
+ },
4655
+ };
4656
+ }
4657
+ var p = String(f.pointer === undefined ? '' : f.pointer).trim();
4658
+ if (p !== '' && p.charAt(0) !== '/') return { ok: false, message: 'RFC 6901 指针必须以 / 开头(空串 = 整篇)' };
4659
+ if (/~(?![01])/.test(p)) return { ok: false, message: 'RFC 6901 指针里 ~ 只能出现在 ~0 / ~1 中' };
4660
+ var rawJ = String(f.expectedJson === undefined ? '' : f.expectedJson).trim();
4661
+ if (rawJ === '') return { ok: false, message: 'json-pointer 规格必须显式给 expected(JSON 值;null 也算显式)' };
4662
+ var ev;
4663
+ try { ev = JSON.parse(rawJ); }
4664
+ catch (e3) { return { ok: false, message: 'expected JSON 解析失败:' + ((e3 && e3.message) || '语法错误') }; }
4665
+ if (f.comparison === 'text-lf' && typeof ev !== 'string') {
4666
+ return { ok: false, message: 'text-lf 只用于文本:json-pointer 的 expected 得是 JSON 字符串(要逐字节比就用 exact)' };
4667
+ }
4668
+ return {
4669
+ ok: true,
4670
+ value: { locator: { kind: 'json-pointer', pointer: p }, expected: ev, comparison: f.comparison },
4671
+ };
4672
+ }
4673
+
4674
+ // ---- 检索 ----
4675
+ function libSearch(offset) {
4676
+ var L = st.lib;
4677
+ var q = String(L.q || '').trim();
4678
+ L.err = '';
4679
+ L.notice = '';
4680
+ if (q === '') {
4681
+ // 服务端口径:空查询是**参数错误**(library-store.js:1268),不是"返回全部"。
4682
+ // 客户端先拦:不发这个注定失败、且容易被误读成"没有数据"的请求。
4683
+ L.err = '检索需要一个关键词:图书馆的检索不接受空查询(空查询会返回全部条目,那不是检索)。';
4684
+ if (st.tab === 'library') renderLibrary();
4685
+ return;
4686
+ }
4687
+ var token = ++libSeq;
4688
+ var off = Number(offset);
4689
+ L.offset = Number.isFinite(off) && off >= 0 ? off : 0;
4690
+ L.busy = true;
4691
+ if (st.tab === 'library') renderLibrary();
4692
+ api('/library/search?q=' + encodeURIComponent(q) + '&offset=' + L.offset + '&limit=' + L.limit)
4693
+ .then(function (r) {
4694
+ if (token !== libSeq) return; // 迟到的旧应答:丢弃(不把上一次查询的结果画进这一次)
4695
+ L.busy = false;
4696
+ L.searchedOnce = true;
4697
+ if (!r || !r.ok) {
4698
+ L.items = [];
4699
+ L.nextOffset = null;
4700
+ L.truncated = false;
4701
+ L.err = '检索没有成功:' + libWhy(r, '网关没有回执') + '(这不等于"图书馆是空的")';
4702
+ if (st.tab === 'library') renderLibrary();
4703
+ return;
4704
+ }
4705
+ L.items = Array.isArray(r.items) ? r.items : [];
4706
+ L.nextOffset = r.nextOffset === null || r.nextOffset === undefined ? null : Number(r.nextOffset);
4707
+ L.truncated = !!r.truncated;
4708
+ L.offset = Number(r.offset === undefined ? L.offset : r.offset);
4709
+ if (st.tab === 'library') renderLibrary();
4710
+ })
4711
+ .catch(function () {
4712
+ if (token !== libSeq) return;
4713
+ L.busy = false;
4714
+ L.searchedOnce = true;
4715
+ L.items = [];
4716
+ L.nextOffset = null;
4717
+ L.truncated = false;
4718
+ L.err = '检索失败(网关不可达)—— 这不等于"图书馆是空的"。';
4719
+ if (st.tab === 'library') renderLibrary();
4720
+ });
4721
+ }
4722
+
4723
+ // ---- 条目详情 + 修订链 + 核对留痕 ----
4724
+ function libOpenItem(id, keepNotice) {
4725
+ var L = st.lib;
4726
+ var token = ++libDetailSeq;
4727
+ L.view = 'item';
4728
+ L.itemId = id;
4729
+ L.item = null;
4730
+ L.detailErr = '';
4731
+ L.hist = null;
4732
+ L.checks = null;
4733
+ L.checkResult = null;
4734
+ L.checkErr = '';
4735
+ L.err = '';
4736
+ if (!keepNotice) L.notice = '';
4737
+ if (st.tab === 'library') renderLibrary();
4738
+ api('/library/item?id=' + encodeURIComponent(id)).then(function (r) {
4739
+ if (token !== libDetailSeq) return;
4740
+ if (!r || !r.ok) L.detailErr = '读取条目失败:' + libWhy(r, '网关没有回执');
4741
+ else if (!r.found) L.detailErr = '这条已不在库里(found=false)—— 可能已被物理处置或 id 不对。';
4742
+ else L.item = r.item || null;
4743
+ if (st.tab === 'library') renderLibrary();
4744
+ }).catch(function () {
4745
+ if (token !== libDetailSeq) return;
4746
+ L.detailErr = '读取条目失败(网关不可达)。';
4747
+ if (st.tab === 'library') renderLibrary();
4748
+ });
4749
+ libLoadHistory(id, 0, token);
4750
+ libLoadChecks(id, 0, token);
4751
+ }
4752
+ function libLoadHistory(id, offset, token) {
4753
+ var L = st.lib;
4754
+ var t = token === undefined ? ++libDetailSeq : token;
4755
+ api('/library/history?id=' + encodeURIComponent(id) + '&offset=' + offset + '&limit=' + L.limit)
4756
+ .then(function (r) {
4757
+ if (t !== libDetailSeq || L.itemId !== id) return; // 已经切到别的条目:丢弃
4758
+ L.hist = (r && r.ok) ? r : null;
4759
+ if (r && !r.ok) L.detailErr = L.detailErr || ('读取修订链失败:' + libWhy(r, '网关没有回执'));
4760
+ if (st.tab === 'library') renderLibrary();
4761
+ })
4762
+ .catch(function () {
4763
+ if (t !== libDetailSeq || L.itemId !== id) return;
4764
+ L.hist = null;
4765
+ if (st.tab === 'library') renderLibrary();
4766
+ });
4767
+ }
4768
+ function libLoadChecks(id, offset, token) {
4769
+ var L = st.lib;
4770
+ var t = token === undefined ? ++libDetailSeq : token;
4771
+ api('/library/checks?id=' + encodeURIComponent(id) + '&offset=' + offset + '&limit=' + L.limit)
4772
+ .then(function (r) {
4773
+ if (t !== libDetailSeq || L.itemId !== id) return;
4774
+ L.checks = (r && r.ok) ? r : null;
4775
+ if (r && !r.ok) L.detailErr = L.detailErr || ('读取核对留痕失败:' + libWhy(r, '网关没有回执'));
4776
+ if (st.tab === 'library') renderLibrary();
4777
+ })
4778
+ .catch(function () {
4779
+ if (t !== libDetailSeq || L.itemId !== id) return;
4780
+ L.checks = null;
4781
+ if (st.tab === 'library') renderLibrary();
4782
+ });
4783
+ }
4784
+ /** status 是**一次性缓存**(见 libLoadStatus 的 `if (L.statusLoaded && !force) return;`)。
4785
+ * 所以**任何写动作之后都必须让它失效** —— 否则顶部「条目 / 留痕」统计会一直停在写之前的值,
4786
+ * 同页出现"结果盒是新的、统计还是旧的"两套读数(2026-10-07 真机实测的界面缺陷)。 */
4787
+ function libInvalidateStatus() {
4788
+ st.lib.statusLoaded = false;
4789
+ libLoadStatus(true);
4790
+ }
4791
+ /** 写动作之后把**同一条**受影响的三份读数(条目 / 修订链 / 留痕)重读一遍。
4792
+ * ⚠️ 代次(token)必须**共享**:libLoadHistory / libLoadChecks 在缺 token 时各自 `++libDetailSeq`,
4793
+ * 连着调两次会让先发的那次当场作废(`t !== libDetailSeq` 直接 return)——
4794
+ * 实测会表现为"重读了但屏幕上没变",比不重读更难查。
4795
+ * ⚠️ 本函数**不动** L.checkResult:那是本次操作的结果盒,清掉等于把刚做完的事从屏幕上撤了。
4796
+ * 条目重读失败**不改写**既有视图(下一次点开即真值),不在这里编一个折中状态。 */
4797
+ function libReloadDetail(id) {
4798
+ var L = st.lib;
4799
+ var token = ++libDetailSeq;
4800
+ api('/library/item?id=' + encodeURIComponent(id)).then(function (r) {
4801
+ if (token !== libDetailSeq || L.itemId !== id) return; // 已经切走:丢弃
4802
+ if (r && r.ok && r.found) L.item = r.item || null;
4803
+ if (st.tab === 'library') renderLibrary();
4804
+ }).catch(function () { /* 读不到就保持现有视图,不猜 */ });
4805
+ libLoadHistory(id, 0, token);
4806
+ libLoadChecks(id, 0, token);
4807
+ }
4808
+ /** 列表那张「认证:未核对」标签是从 `L.items` 画的(renderLibList → libRow),
4809
+ * 而 `L.items` **只在 libSearch 里更新** ⇒ 重读详情/统计**不足以**让列表跟上:
4810
+ * · 在**列表行**上点「核对」⇒ 行上的标签仍旧;
4811
+ * · 从详情「← 返回列表」⇒ 返回的是一个**写之前的旧页**(撤下的还挂在列表里)。
4812
+ * 所以两处都要重跑当前检索。详情视图里不重跑:那会把主人正在看的条目连同滚动位置换掉。 */
4813
+ function libReloadList() {
4814
+ var L = st.lib;
4815
+ if (L.view === 'item') return;
4816
+ if (String(L.q || '').trim() === '') return; // 检索不接受空查询:不发注定失败的请求
4817
+ libSearch(L.offset);
4818
+ }
4819
+
4820
+ // ---- 核对(按钮;取消绝不伪成功)----
4821
+ function libCheck(it) {
4822
+ var L = st.lib;
4823
+ if (!it || !it.id || L.checking) return;
4824
+ var token = ++libActionSeq;
4825
+ L.checking = true;
4826
+ L.checkResult = null;
4827
+ L.checkErr = '';
4828
+ L.err = '';
4829
+ L.notice = '';
4830
+ if (st.tab === 'library') renderLibrary();
4831
+ api('/library/check', { method: 'POST', body: { id: it.id } }).then(function (r) {
4832
+ if (token !== libActionSeq) return;
4833
+ L.checking = false;
4834
+ if (!r) {
4835
+ L.checkErr = '核对没有回执(网关不可达)—— 不知道有没有写入,请刷新「核对留痕」再判断,不当作已核对。';
4836
+ } else if (r.cancelled) {
4837
+ L.checkErr = '已取消 —— 契约保证取消不写认证;界面不把它当成功(没有伪造一个 state)。';
4838
+ } else if (r.state === undefined || r.ok === false) {
4839
+ // 被拒(closed / in_flight / concurrency_limit / 存储面失败):**不算一次核对**。
4840
+ // 即使运行时给了 state(拒绝时它也是 'unknown'),也不把它当成"核对结果"渲染 ——
4841
+ // 那会把"没查"读成"查了但没判定"。recorded=false 一并写出来。
4842
+ L.checkErr = '核对未执行(这次没有写入任何认证):' + libWhy(r, '运行时未给出原因')
4843
+ + ' · recorded=' + (r.recorded ? 'true' : 'false');
4844
+ } else {
4845
+ L.checkResult = r;
4846
+ // 写动作之后把受影响的读数**全部**重读(2026-10-07 真机缺陷:只重读「留痕」会让同页
4847
+ // 出现两套读数 —— 结果盒/留痕是新的,条目标签、明细 checked_state、修订链计数、顶部统计还是旧的)。
4848
+ libReloadDetail(it.id);
4849
+ libInvalidateStatus();
4850
+ libReloadList(); // 在列表行上核对时:那一行的标签也要跟上(详情视图里自动跳过)
4851
+ }
4852
+ if (st.tab === 'library') renderLibrary();
4853
+ }).catch(function () {
4854
+ if (token !== libActionSeq) return;
4855
+ L.checking = false;
4856
+ L.checkErr = '核对失败(网关不可达)—— 不知道有没有写入,请刷新「核对留痕」再判断。';
4857
+ if (st.tab === 'library') renderLibrary();
4858
+ });
4859
+ }
4860
+ function libResultBox(r) {
4861
+ var box = el('div', 'lib-card');
4862
+ box.appendChild(el('div', 'm-title', '核对结果:' + libMap(LIB_CK, r.state, '未给 state')));
4863
+ var meta = [];
4864
+ meta.push('state=' + libShow(r.state));
4865
+ meta.push('recorded=' + (r.recorded ? 'true' : 'false'));
4866
+ if (r.adopted !== undefined) meta.push('adopted=' + (r.adopted ? 'true' : 'false'));
4867
+ if (r.adoption_reason) meta.push('adoption_reason=' + r.adoption_reason);
4868
+ if (r.reason) meta.push('reason=' + r.reason);
4869
+ box.appendChild(el('div', 'm-meta', meta.join(' · ')));
4870
+ box.appendChild(el('div', 'm-meta',
4871
+ '口径:state=ok 只说明"声明的那一处比较与源一致"—— 不说明这条资料的正文、解释或结论为真。'
4872
+ + '顶层 reason 是「这次比较」的结论(不一致时是 declared_comparison_mismatch);'
4873
+ + '"为什么没被采纳"看 record.reason(通常是 stale_or_inactive),采纳与否另由 adopted 表示。'));
4874
+ if (r.record && r.record.reason) {
4875
+ box.appendChild(el('div', 'm-meta', 'record.reason=' + r.record.reason
4876
+ + (r.record.check_sequence !== undefined ? ' · check_sequence=' + r.record.check_sequence : '')));
4877
+ }
4878
+ box.appendChild(el('div', 'm-meta', '核对凭据(evidence)—— 全部字段为文本,不解释执行'));
4879
+ box.appendChild(el('div', 'lib-pre',
4880
+ r.evidence ? JSON.stringify(r.evidence, null, 2) : '(这次没有凭据)'));
4881
+ return box;
4882
+ }
4883
+
4884
+ // ---- 新建 / 修订 ----
4885
+ function libSaveForm() {
4886
+ var L = st.lib;
4887
+ var f = L.form || libBlankForm();
4888
+ if (L.busy) return;
4889
+ L.err = '';
4890
+ L.notice = '';
4891
+ var problems = libFormProblems(f);
4892
+ var cond = libConditionsOf(f);
4893
+ var spec = libSpecProblems(f);
4894
+ if (!cond.ok) problems.push(cond.message);
4895
+ if (!spec.ok) problems.push(spec.message);
4896
+ if (problems.length) {
4897
+ L.err = '没有发出任何请求(先在界面上改好):' + problems.join(' · ') + '。你填的内容一个字段都没动。';
4898
+ if (st.tab === 'library') renderLibrary();
4899
+ return;
4900
+ }
4901
+ var item = {
4902
+ title: String(f.title).trim(),
4903
+ body: String(f.body),
4904
+ category: f.category,
4905
+ source_kind: f.source_kind,
4906
+ source_ref: String(f.source_ref).trim(),
4907
+ as_of: String(f.as_of).trim(),
4908
+ check_spec: spec.value,
4909
+ };
4910
+ if (String(f.version === undefined ? '' : f.version).trim() !== '') item.version = String(f.version).trim();
4911
+ if (cond.value !== undefined) item.conditions = cond.value;
4912
+ // ⚠️ 提交体里**只有**可编辑字段:content_hash/state/checked_state/checked_at 一个都不带
4913
+ // (带了会被 store 以 readonly_field 拒;旧 id 也不是"顺手覆盖"的凭据)。
4914
+ var revising = L.formMode === 'revise' && L.formItem && L.formItem.id;
4915
+ var id = revising ? L.formItem.id : '';
4916
+ var expectedHash = revising ? L.formItem.content_hash : '';
4917
+ var token = ++libActionSeq;
4918
+ L.busy = true;
4919
+ if (st.tab === 'library') renderLibrary();
4920
+ var req = revising
4921
+ ? api('/library/revise', { method: 'POST', body: { id: id, item: item, expectedHash: expectedHash } })
4922
+ : api('/library/create', { method: 'POST', body: { item: item } });
4923
+ req.then(function (r) {
4924
+ if (token !== libActionSeq) return;
4925
+ L.busy = false;
4926
+ if (!r) {
4927
+ L.err = '提交没有回执(网关不可达)—— 你的填写还在,可以直接重试。';
4928
+ if (st.tab === 'library') renderLibrary();
4929
+ return;
4930
+ }
4931
+ if (!r.ok) {
4932
+ if (r.reason === 'hash_mismatch') { libStale(id, r); return; }
4933
+ L.err = '提交被拒:' + libWhy(r, '未给出原因') + ' —— 你的填写还在,改完可以直接再提交。';
4934
+ if (st.tab === 'library') renderLibrary();
4935
+ return;
4936
+ }
4937
+ var saved = r.item || null;
4938
+ toast(revising ? '已提交修订(新版本换了新 id,认证状态已重置)' : '已新建资料', 4800);
4939
+ L.form = null;
4940
+ L.formItem = null;
4941
+ // 新建 / 修订都改变库的读数(条数、修订链、留痕)⇒ status 必须失效重读,
4942
+ // 否则顶部「条目 / 留痕」统计会一直显示写之前的值(2026-10-07 真机实测)。
4943
+ libInvalidateStatus();
4944
+ if (saved && saved.id) libOpenItem(saved.id);
4945
+ else { L.view = 'list'; libSearch(L.offset); }
4946
+ }).catch(function () {
4947
+ if (token !== libActionSeq) return;
4948
+ L.busy = false;
4949
+ L.err = '提交失败(网关不可达)—— 你的填写还在,可以直接重试。';
4950
+ if (st.tab === 'library') renderLibrary();
4951
+ });
4952
+ }
4953
+ /** 陈旧冲突:报告实情 + 重新载入当前版本,**绝不覆盖**主人填的内容、也不重发。 */
4954
+ function libStale(id, r) {
4955
+ var L = st.lib;
4956
+ L.busy = false;
4957
+ L.err = '';
4958
+ var cur = r && r.current_hash ? ('库中当前 content_hash = ' + String(r.current_hash) + ';') : '';
4959
+ L.notice = '陈旧冲突(hash_mismatch):提交时用的 content_hash 与库中当前值不一致 —— '
4960
+ + '没有覆盖任何内容,也没有重发。' + cur + '已按下述方式重新载入当前版本,请对照差异后重填或重试。';
4961
+ if (id) {
4962
+ api('/library/item?id=' + encodeURIComponent(id)).then(function (rr) {
4963
+ if (rr && rr.ok && rr.found && rr.item) {
4964
+ L.formItem = rr.item; // 只更新"当前版本是什么",不动表单里主人填的字段
4965
+ L.item = rr.item;
4966
+ }
4967
+ if (st.tab === 'library') renderLibrary();
4968
+ }).catch(function () { if (st.tab === 'library') renderLibrary(); });
4969
+ }
4970
+ if (st.tab === 'library') renderLibrary();
4971
+ }
4972
+ function libWithdraw(it) {
4973
+ var L = st.lib;
4974
+ var reason = String(L.withdrawReason || '').trim();
4975
+ L.err = '';
4976
+ L.notice = '';
4977
+ if (!reason) {
4978
+ L.err = '没有发出请求:撤下必须给出原因(撤下不是静默删除)。';
4979
+ if (st.tab === 'library') renderLibrary();
4980
+ return;
4981
+ }
4982
+ if (it.state !== 'active') {
4983
+ L.err = '这条当前是「' + libMap(LIB_ST, it.state, it.state) + '」—— 只有有效最新版才能撤下。';
4984
+ if (st.tab === 'library') renderLibrary();
4985
+ return;
4986
+ }
4987
+ var ask = '撤下《' + it.title + '》?\n\n· 撤下是终态:之后不能再修订\n'
4988
+ + '· 不物理删除:修订链与核对留痕都保留\n· 原因:' + reason;
4989
+ var agreed = false;
4990
+ try { agreed = (typeof window.confirm === 'function') ? !!window.confirm(ask) : false; }
4991
+ catch (e) { agreed = false; }
4992
+ if (!agreed) {
4993
+ L.notice = '已取消撤下 —— 没有发出任何请求,条目一个字节都没动。'
4994
+ + (typeof window.confirm === 'function' ? '' : '(本环境没有确认框,按取消处理。)');
4995
+ if (st.tab === 'library') renderLibrary();
4996
+ return;
4997
+ }
4998
+ var token = ++libActionSeq;
4999
+ L.busy = true;
5000
+ if (st.tab === 'library') renderLibrary();
5001
+ api('/library/withdraw', { method: 'POST', body: { id: it.id, reason: reason, expectedHash: it.content_hash } })
5002
+ .then(function (r) {
5003
+ if (token !== libActionSeq) return;
5004
+ L.busy = false;
5005
+ if (!r) {
5006
+ L.err = '撤下没有回执(网关不可达)—— 不知道有没有生效,请刷新这条再判断。';
5007
+ if (st.tab === 'library') renderLibrary();
5008
+ return;
5009
+ }
5010
+ if (!r.ok) {
5011
+ if (r.reason === 'hash_mismatch') { libStale(it.id, r); return; }
5012
+ L.err = '撤下被拒:' + libWhy(r, '未给出原因');
5013
+ if (st.tab === 'library') renderLibrary();
5014
+ return;
5015
+ }
5016
+ toast('已撤下《' + it.title + '》(留痕不删)', 4800);
5017
+ L.withdrawReason = '';
5018
+ // 撤下改变「有效 / 已撤下」计数 ⇒ 同 create / revise,status 也要失效重读。
5019
+ libInvalidateStatus();
5020
+ libOpenItem(it.id, false);
5021
+ })
5022
+ .catch(function () {
5023
+ if (token !== libActionSeq) return;
5024
+ L.busy = false;
5025
+ L.err = '撤下失败(网关不可达)—— 请刷新这条再判断是否生效。';
5026
+ if (st.tab === 'library') renderLibrary();
5027
+ });
5028
+ }
5029
+
5030
+ // ---- 三个视图 ----
5031
+ function libPageRow(items, meta, onGo) {
5032
+ var row = el('div', 'm-acts');
5033
+ var prev = el('button', null, '上一页');
5034
+ var next = el('button', null, '下一页');
5035
+ var off = Number(meta.offset) || 0;
5036
+ var lim = Number(meta.limit) || 20;
5037
+ var nextOff = meta.nextOffset === null || meta.nextOffset === undefined ? null : Number(meta.nextOffset);
5038
+ prev.disabled = off <= 0;
5039
+ next.disabled = nextOff === null;
5040
+ prev.onclick = function () { onGo(Math.max(0, off - lim)); };
5041
+ next.onclick = function () { if (nextOff !== null) onGo(nextOff); };
5042
+ row.appendChild(prev);
5043
+ row.appendChild(next);
5044
+ row.appendChild(el('span', 'm-meta', '本页 ' + items.length + ' 条 · 偏移 ' + off + ' · '
5045
+ + (meta.truncated ? ('还有更多(下一批从 ' + libShow(nextOff) + ' 开始)') : '没有更多了')));
5046
+ return row;
5047
+ }
5048
+ function renderLibList() {
5049
+ var L = st.lib;
5050
+ var tools = el('div', 'm-tools');
5051
+ var search = document.createElement('input');
5052
+ search.type = 'search';
5053
+ search.placeholder = '搜索标题 / 正文 / 类目…(服务端不接受空查询)';
5054
+ search.value = L.q;
5055
+ search.oninput = function () { L.q = search.value; };
5056
+ search.onkeydown = function (ev) { if (ev && ev.key === 'Enter') libSearch(0); };
5057
+ var bGo = el('button', 'primary', '查询');
5058
+ bGo.onclick = function () { libSearch(0); };
5059
+ var bNew = el('button', null, '+ 新建资料');
5060
+ bNew.onclick = function () {
5061
+ L.form = libBlankForm();
5062
+ L.formItem = null;
5063
+ L.formMode = 'create';
5064
+ L.view = 'form';
5065
+ L.err = '';
5066
+ L.notice = '';
5067
+ if (st.tab === 'library') renderLibrary();
5068
+ };
5069
+ var bSt = el('button', null, '刷新状态');
5070
+ bSt.onclick = function () { libLoadStatus(true); };
5071
+ // 导入面入口(图书馆):从"源"进来,而不是从"一条条手敲"进来。
5072
+ var bImp = el('button', null, '⬆ 导入(粘贴 / 文件)');
5073
+ bImp.onclick = function () { libImpOpen(); };
5074
+ // 浏览面入口(第七面,2026-10-13):**不搜也能翻** —— 上面那张检索要求关键词,
5075
+ // 这里按「类目 → 来源」自己走进去(服务端的检索不接受空查询,所以这一条是必需的)。
5076
+ var bBr = el('button', null, '🗂 浏览(不搜也能翻)');
5077
+ bBr.onclick = function () { libBrOpen(); };
5078
+ tools.appendChild(search);
5079
+ tools.appendChild(bGo);
5080
+ tools.appendChild(bNew);
5081
+ tools.appendChild(bImp);
5082
+ tools.appendChild(bBr);
5083
+ tools.appendChild(bSt);
5084
+ body.appendChild(tools);
5085
+ if (L.err) body.appendChild(el('div', 'lib-err', L.err));
5086
+ if (L.notice) body.appendChild(el('div', 'lib-note', L.notice));
5087
+ var items = L.items;
5088
+ if (L.busy) {
5089
+ // 在飞时如实说"正在检索":首查时不留"还没有查询"这种误导(那会让人以为请求没发出去),
5090
+ // 续查时说明下面那块是**上一次**的结果(不是本次的)。
5091
+ body.appendChild(el('div', 'm-meta', items ? '正在检索…(下面是上一次的结果)' : '正在检索…'));
5092
+ }
5093
+ if (!items) {
5094
+ if (L.busy) return;
5095
+ var total = libStat();
5096
+ body.appendChild(el('div', 'm-empty', total === 0
5097
+ ? '图书馆里还没有资料。这里不预置种子资料、也不从记忆库迁移 —— 资料靠「+ 新建资料」或官方文档核对一条条进来。'
5098
+ : (total === null
5099
+ ? '还没有查询。填一个关键词后点「查询」。'
5100
+ : '还没有查询。图书馆现有 ' + total + ' 条资料(含已取代/已撤下的历史版本):填关键词后点「查询」——服务端的检索不接受空查询。')));
5101
+ return;
5102
+ }
5103
+ if (!items.length) {
5104
+ body.appendChild(el('div', 'm-empty', L.searchedOnce
5105
+ ? ('没有匹配「' + String(L.q).trim() + '」的资料 —— 这是本轮检索为空,不代表图书馆是空的(换词再查)。')
5106
+ : '正在检索…'));
5107
+ return;
5108
+ }
5109
+ for (var i = 0; i < items.length; i++) body.appendChild(libRow(items[i]));
5110
+ body.appendChild(libPageRow(items, {
5111
+ offset: L.offset, limit: L.limit, nextOffset: L.nextOffset, truncated: L.truncated,
5112
+ }, function (off) { libSearch(off); }));
5113
+ body.appendChild(el('div', 'm-meta',
5114
+ '回执只给本页条数与"还有没有更多",不给总数 —— 所以这里不写"共 N 条"。'
5115
+ + '默认检索只看有效最新版:已取代/已撤下的版本不出现在结果里,但它们的留痕在条目的修订链与核对历史里。'));
5116
+ }
5117
+ function libRow(it) {
5118
+ var L = st.lib;
5119
+ var row = el('div', 'm-row');
5120
+ row.appendChild(el('div', 'm-title', libShow(it.title)));
5121
+ var chips = el('div', null);
5122
+ chips.appendChild(el('span', 'lib-chip', libMap(LIB_CAT, it.category, it.category)));
5123
+ chips.appendChild(el('span', 'lib-chip', libMap(LIB_SRC, it.source_kind, it.source_kind)));
5124
+ chips.appendChild(el('span', 'lib-chip', '认证:' + libMap(LIB_CK, it.checked_state, '未核对')));
5125
+ chips.appendChild(el('span', 'lib-chip', libMap(LIB_ST, it.state, it.state)));
5126
+ row.appendChild(chips);
5127
+ row.appendChild(el('div', 'm-meta', 'r' + libShow(it.revision) + ' · as_of ' + libShow(it.as_of)
5128
+ + ' · 核对时间 ' + libShow(it.checked_at) + ' · 更新 ' + libShow(it.updated_at)));
5129
+ row.appendChild(el('div', 'm-meta lib-mono', 'source_ref ' + libShow(it.source_ref)));
5130
+ row.appendChild(el('div', 'm-meta lib-mono', 'content_hash ' + libShow(it.content_hash)));
5131
+ var acts = el('div', 'm-acts');
5132
+ var bOpen = el('button', null, '详情 / 修订链 / 核对');
5133
+ bOpen.onclick = function () { libOpenItem(it.id, false); };
5134
+ var bCk = el('button', null, L.checking ? '核对中…' : '核对');
5135
+ bCk.disabled = !!L.checking;
5136
+ bCk.onclick = function () { libCheck(it); };
5137
+ acts.appendChild(bOpen);
5138
+ acts.appendChild(bCk);
5139
+ row.appendChild(acts);
5140
+ return row;
5141
+ }
5142
+ function renderLibItem() {
5143
+ var L = st.lib;
5144
+ var top = el('div', 'm-acts');
5145
+ var back = el('button', null, '← 返回列表');
5146
+ back.onclick = function () {
5147
+ L.view = 'list';
5148
+ L.item = null;
5149
+ L.hist = null;
5150
+ L.checks = null;
5151
+ L.err = '';
5152
+ L.notice = '';
5153
+ if (st.tab === 'library') renderLibrary();
5154
+ // 返回列表时重跑当前检索:否则返回的是一页**写之前**的旧数据
5155
+ // (撤下的还挂在列表里、刚核对过的行标签还是「未核对」)。
5156
+ libReloadList();
5157
+ };
5158
+ top.appendChild(back);
5159
+ var bRefresh = el('button', null, '刷新这条');
5160
+ bRefresh.onclick = function () { if (L.itemId) libOpenItem(L.itemId, true); };
5161
+ top.appendChild(bRefresh);
5162
+ if (L.item) {
5163
+ var bRe = el('button', null, '修订这条');
5164
+ bRe.disabled = L.item.state !== 'active';
5165
+ bRe.onclick = function () {
5166
+ L.form = libFormFromItem(L.item);
5167
+ L.formItem = L.item;
5168
+ L.formMode = 'revise';
5169
+ L.view = 'form';
5170
+ L.err = '';
5171
+ L.notice = '';
5172
+ if (st.tab === 'library') renderLibrary();
5173
+ };
5174
+ var bCk = el('button', 'primary', L.checking ? '核对中…' : '核对这条');
5175
+ bCk.disabled = !!L.checking;
5176
+ bCk.onclick = function () { libCheck(L.item); };
5177
+ top.appendChild(bRe);
5178
+ top.appendChild(bCk);
5179
+ }
5180
+ body.appendChild(top);
5181
+ if (L.err) body.appendChild(el('div', 'lib-err', L.err));
5182
+ if (L.notice) body.appendChild(el('div', 'lib-note', L.notice));
5183
+ if (L.detailErr) body.appendChild(el('div', 'lib-err', L.detailErr));
5184
+ if (!L.item) {
5185
+ body.appendChild(el('div', 'm-empty', L.detailErr ? '' : '正在读取条目…'));
5186
+ return;
5187
+ }
5188
+ var it = L.item;
5189
+ var card = el('div', 'lib-card');
5190
+ card.appendChild(el('div', 'm-title', libShow(it.title)));
5191
+ var chips = el('div', null);
5192
+ chips.appendChild(el('span', 'lib-chip', libMap(LIB_CAT, it.category, it.category)));
5193
+ chips.appendChild(el('span', 'lib-chip', libMap(LIB_SRC, it.source_kind, it.source_kind)));
5194
+ chips.appendChild(el('span', 'lib-chip', '状态:' + libMap(LIB_ST, it.state, it.state)));
5195
+ chips.appendChild(el('span', 'lib-chip', '认证:' + libMap(LIB_CK, it.checked_state, '未核对')));
5196
+ card.appendChild(chips);
5197
+ var grid = el('div', 'lib-grid');
5198
+ libKV(grid, '出处 source_ref', it.source_ref, true);
5199
+ libKV(grid, '取数时间 as_of', it.as_of);
5200
+ libKV(grid, 'content_hash', it.content_hash, true);
5201
+ libKV(grid, 'state', libShow(it.state) + (it.superseded_by ? (' → 新版 ' + it.superseded_by) : ''));
5202
+ libKV(grid, '修订 revision', 'r' + libShow(it.revision) + (it.previous_id ? (' · 上一版 ' + it.previous_id) : ' · 初版'));
5203
+ libKV(grid, '认证 checked_state', libShow(it.checked_state) + ' · ' + libShow(it.checked_at));
5204
+ libKV(grid, 'version', it.version);
5205
+ libKV(grid, 'conditions', it.conditions === null || it.conditions === undefined
5206
+ ? '—' : (typeof it.conditions === 'string' ? it.conditions : JSON.stringify(it.conditions)));
5207
+ libKV(grid, 'id', it.id, true);
5208
+ libKV(grid, 'lineage_id', it.lineage_id, true);
5209
+ libKV(grid, 'created_at', it.created_at);
5210
+ libKV(grid, 'updated_at', it.updated_at);
5211
+ if (it.withdrawn_at) libKV(grid, '撤下', it.withdrawn_at + ' · 原因:' + libShow(it.withdrawn_reason));
5212
+ card.appendChild(grid);
5213
+ card.appendChild(el('div', 'm-meta', '正文(body)——纯文本渲染,不解释 HTML'));
5214
+ card.appendChild(el('div', 'lib-pre', libShow(it.body)));
5215
+ card.appendChild(el('div', 'm-meta', '核对规格(check_spec)——声明要机械比较的那一处'));
5216
+ card.appendChild(el('div', 'lib-pre', libSpecText(it.check_spec)));
5217
+ body.appendChild(card);
5218
+
5219
+ // 撤下:原因 + 确认(缺一样都不发请求)
5220
+ var wRow = el('div', 'm-tools');
5221
+ var wIn = document.createElement('input');
5222
+ wIn.type = 'text';
5223
+ wIn.placeholder = '撤下原因(必填:撤下不是静默删除)';
5224
+ wIn.value = L.withdrawReason;
5225
+ wIn.oninput = function () { L.withdrawReason = wIn.value; };
5226
+ var bW = el('button', null, '撤下这条');
5227
+ bW.disabled = it.state !== 'active';
5228
+ bW.onclick = function () { libWithdraw(it); };
5229
+ wRow.appendChild(wIn);
5230
+ wRow.appendChild(bW);
5231
+ body.appendChild(wRow);
5232
+
5233
+ if (L.checkErr) body.appendChild(el('div', 'lib-err', L.checkErr));
5234
+ if (L.checkResult) body.appendChild(libResultBox(L.checkResult));
5235
+ else if (L.checking) body.appendChild(el('div', 'm-empty', '正在核对(取源 / 定位 / 比较)…'));
5236
+
5237
+ // 修订链
5238
+ var hCard = el('div', 'lib-card');
5239
+ var hd = L.hist;
5240
+ hCard.appendChild(el('div', 'm-title', '修订链'));
5241
+ if (!hd) hCard.appendChild(el('div', 'm-meta', '正在读取修订链…'));
5242
+ else {
5243
+ var revs = Array.isArray(hd.revisions) ? hd.revisions : [];
5244
+ hCard.appendChild(el('div', 'm-meta', '本谱系共 ' + libShow(hd.revision_count)
5245
+ + ' 个版本(有效 ' + libShow(hd.counts && hd.counts.active) + ' / 已取代 '
5246
+ + libShow(hd.counts && hd.counts.superseded) + ' / 已撤下 ' + libShow(hd.counts && hd.counts.withdrawn)
5247
+ + ')—— 这是版本数,不是检索结果数。'));
5248
+ for (var hi = 0; hi < revs.length; hi++) {
5249
+ var rv = revs[hi];
5250
+ var line = el('div', 'm-meta lib-mono', 'r' + libShow(rv.revision) + (rv.is_current ? ' (当前)' : '')
5251
+ + ' · ' + libShow(rv.id) + ' · ' + libMap(LIB_ST, rv.state, rv.state)
5252
+ + ' · 认证 ' + libShow(rv.checked_state) + ' · ' + libShow(rv.created_at)
5253
+ + ' · 核对 ' + libShow(rv.check_count) + ' 次');
5254
+ hCard.appendChild(line);
5255
+ var revActs = el('div', 'm-acts');
5256
+ var bOpenRev = el('button', null, '看这一版');
5257
+ bOpenRev.onclick = (function (rid) {
5258
+ return function () { libOpenItem(rid, false); };
5259
+ })(rv.id);
5260
+ revActs.appendChild(bOpenRev);
5261
+ hCard.appendChild(revActs);
5262
+ }
5263
+ if (!revs.length) hCard.appendChild(el('div', 'm-meta', '这一页没有版本。'));
5264
+ hCard.appendChild(libPageRow(revs, {
5265
+ offset: hd.offset, limit: hd.limit, nextOffset: hd.nextOffset, truncated: hd.truncated,
5266
+ }, function (off) { libLoadHistory(L.itemId, off); }));
5267
+ }
5268
+ body.appendChild(hCard);
5269
+
5270
+ // 核对留痕(checks 有精确 total,可以如实写"共 N 次")
5271
+ var cCard = el('div', 'lib-card');
5272
+ var ck = L.checks;
5273
+ cCard.appendChild(el('div', 'm-title', '核对留痕'));
5274
+ if (!ck) cCard.appendChild(el('div', 'm-meta', '正在读取核对留痕…'));
5275
+ else {
5276
+ var rows = Array.isArray(ck.checks) ? ck.checks : [];
5277
+ cCard.appendChild(el('div', 'm-meta', '共 ' + libShow(ck.total) + ' 次核对(这一页 ' + rows.length
5278
+ + ' 条)· 每次完成都留档;只有"仍是有效最新版且哈希未变"才采纳为当前认证。'));
5279
+ for (var ci = 0; ci < rows.length; ci++) {
5280
+ var cr = rows[ci];
5281
+ cCard.appendChild(el('div', 'm-meta lib-mono', '第 ' + libShow(cr.check_sequence) + ' 次 · '
5282
+ + libMap(LIB_CK, cr.state, cr.state) + ' · ' + libShow(cr.checked_at)
5283
+ + ' · adopted=' + (cr.adopted ? 'true' : 'false')
5284
+ + (cr.reason ? (' · reason=' + cr.reason) : '')
5285
+ + ' · item_hash ' + libHash(cr.item_hash)));
5286
+ }
5287
+ if (!rows.length) cCard.appendChild(el('div', 'm-meta', '还没有核对过这条。'));
5288
+ cCard.appendChild(libPageRow(rows, {
5289
+ offset: ck.offset, limit: ck.limit, nextOffset: ck.nextOffset, truncated: ck.truncated,
5290
+ }, function (off) { libLoadChecks(L.itemId, off); }));
5291
+ }
5292
+ body.appendChild(cCard);
5293
+ }
5294
+ function renderLibForm() {
5295
+ var L = st.lib;
5296
+ var f = L.form || (L.form = libBlankForm());
5297
+ var editing = L.formMode === 'revise';
5298
+ var top = el('div', 'm-acts');
5299
+ var back = el('button', null, editing ? '← 返回这条' : '← 返回列表');
5300
+ back.onclick = function () {
5301
+ L.view = editing && L.formItem ? 'item' : 'list';
5302
+ L.err = '';
5303
+ L.notice = '';
5304
+ if (st.tab === 'library') renderLibrary();
5305
+ };
5306
+ top.appendChild(back);
5307
+ var bSave = el('button', 'primary', L.busy ? '提交中…' : (editing ? '提交修订' : '新建'));
5308
+ bSave.disabled = !!L.busy;
5309
+ bSave.onclick = function () { libSaveForm(); };
5310
+ top.appendChild(bSave);
5311
+ body.appendChild(top);
5312
+ body.appendChild(el('div', 'm-title', editing ? ('修订《' + libShow(L.formItem && L.formItem.title) + '》') : '新建资料'));
5313
+ body.appendChild(el('div', 'm-meta', editing
5314
+ ? ('修订拿的是发起时观测到的 content_hash(' + libShow(L.formItem && L.formItem.content_hash)
5315
+ + ');服务端会用它与库中当前值对账 —— 不一致就拒(hash_mismatch),不会覆盖。'
5316
+ + '提交成功后是新 id、新版本,认证状态重置(checked_state/checked_at 不由这里写)。')
5317
+ : '只读字段(id / revision / content_hash / state / checked_state / checked_at)由存储层与裁判员写,'
5318
+ + '表单里不带它们;带了会被服务端以 readonly_field 拒。'));
5319
+
5320
+ var card = el('div', 'lib-card lib-form');
5321
+ if (L.err) card.appendChild(el('div', 'lib-err', L.err));
5322
+ if (L.notice) card.appendChild(el('div', 'lib-note', L.notice));
5323
+ function label(text) { card.appendChild(el('label', null, text)); }
5324
+ var liveCheck = null; // 提交前即时校验的入口(在下面装配完 checkLine 后指向 refreshCheck)
5325
+ function bindInput(node, key) {
5326
+ node.value = f[key] === undefined || f[key] === null ? '' : String(f[key]);
5327
+ // ⚠️ 这里只写 f 并**就地**刷新校验行,**绝不整页重绘** —— 重绘会重建输入框,
5328
+ // 主人正在输入的字符与光标会被吃掉(本文件处处在意这件事,如 sheRefreshNode)。
5329
+ node.oninput = function () { f[key] = node.value; if (liveCheck) liveCheck(); };
5330
+ return node;
5331
+ }
5332
+ label('title(必填)');
5333
+ var tIn = document.createElement('input');
5334
+ tIn.type = 'text';
5335
+ tIn.placeholder = '这条资料叫什么(一句话)';
5336
+ card.appendChild(bindInput(tIn, 'title'));
5337
+ label('body(必填:值本身,不是对值的解释)');
5338
+ var taB = document.createElement('textarea');
5339
+ taB.placeholder = '正文。核对比的是 check_spec 声明的那一处,不是这段话;但正文是给人读的,请写清"这是什么、从哪来"。';
5340
+ card.appendChild(bindInput(taB, 'body'));
5341
+ label('category(受控枚举)');
5342
+ card.appendChild(makeSel([
5343
+ { v: 'standard-value', l: '标准值' }, { v: 'boundary', l: '边界' },
5344
+ { v: 'project-spec', l: '项目规格' }, { v: 'external-api', l: '外部接口' },
5345
+ ], f.category, function (v) { f.category = v; }).node);
5346
+ label('source_kind(受控枚举)');
5347
+ card.appendChild(makeSel([
5348
+ { v: 'standard', l: '标准' }, { v: 'official-doc', l: '官方文档(HTTPS 文档)' },
5349
+ { v: 'manual', l: '人工' }, { v: 'our-code', l: '自有代码(绝对路径)' }, { v: 'web', l: '网页' },
5350
+ ], f.source_kind, function (v) { f.source_kind = v; }).node);
5351
+ label('source_ref(必填:出处。our-code = 绝对路径;official-doc = 管理员登记的精确 HTTPS URL)');
5352
+ var refIn = document.createElement('input');
5353
+ refIn.type = 'text';
5354
+ refIn.placeholder = 'our-code: /abs/path/file.mjs · official-doc: https://…';
5355
+ card.appendChild(bindInput(refIn, 'source_ref'));
5356
+ label('as_of(必填:显式 ISO 日期或带 Z 的 UTC 时刻;不由创建时间推断)');
5357
+ var asIn = document.createElement('input');
5358
+ asIn.type = 'text';
5359
+ asIn.placeholder = '2026-10-06 或 2026-10-06T00:00:00Z';
5360
+ card.appendChild(bindInput(asIn, 'as_of'));
5361
+ label('version(可选:显式适用版本)');
5362
+ var verIn = document.createElement('input');
5363
+ verIn.type = 'text';
5364
+ verIn.placeholder = '留空 = 不声明版本';
5365
+ card.appendChild(bindInput(verIn, 'version'));
5366
+ label('conditions(可选:以 { 或 [ 开头按 JSON 解析,否则按纯文本)');
5367
+ var condTa = document.createElement('textarea');
5368
+ condTa.placeholder = '例:{"platform":"win32"} 或 纯文本条件';
5369
+ card.appendChild(bindInput(condTa, 'conditions'));
5370
+ label('核对规格:输入方式');
5371
+ card.appendChild(makeSel([
5372
+ { v: 'builder', l: '分项填写(lines / json-pointer)' }, { v: 'json', l: '完整 JSON(有限格式)' },
5373
+ ], f.specMode, function (v) { f.specMode = v; if (st.tab === 'library') renderLibrary(); }).node);
5374
+ if (f.specMode === 'builder') {
5375
+ label('定位方式');
5376
+ card.appendChild(makeSel([
5377
+ { v: 'none', l: '不对账(不可核对,check_spec = null)' },
5378
+ { v: 'lines', l: 'lines(UTF-8 文本行段)' },
5379
+ { v: 'json-pointer', l: 'json-pointer(JSON 里的位置,RFC 6901)' },
5380
+ ], f.specKind, function (v) { f.specKind = v; if (st.tab === 'library') renderLibrary(); }).node);
5381
+ if (f.specKind === 'lines') {
5382
+ label('起止行(1 ≤ start ≤ end;行终止符不算在选中内容里)');
5383
+ var sIn = document.createElement('input');
5384
+ sIn.type = 'text';
5385
+ var eIn = document.createElement('input');
5386
+ eIn.type = 'text';
5387
+ card.appendChild(bindInput(sIn, 'start'));
5388
+ card.appendChild(bindInput(eIn, 'end'));
5389
+ label('expected(这段文本要逐字/按行尾归一比较)');
5390
+ var exTa = document.createElement('textarea');
5391
+ exTa.placeholder = '期望在这几行里读到的原文';
5392
+ card.appendChild(bindInput(exTa, 'expectedText'));
5393
+ } else if (f.specKind === 'json-pointer') {
5394
+ label('JSON 指针(空串 = 整篇;/a/b 这样的 RFC 6901 指针)');
5395
+ var pIn = document.createElement('input');
5396
+ pIn.type = 'text';
5397
+ pIn.placeholder = '/tool/version';
5398
+ card.appendChild(bindInput(pIn, 'pointer'));
5399
+ label('expected(JSON 值;null 也算显式给出)');
5400
+ var ejTa = document.createElement('textarea');
5401
+ ejTa.placeholder = '"1.2.3" 或 42 或 {"a":1} —— 这里写的是 JSON,null 必须显式写';
5402
+ card.appendChild(bindInput(ejTa, 'expectedJson'));
5403
+ }
5404
+ label('comparison');
5405
+ card.appendChild(makeSel([
5406
+ { v: 'exact', l: 'exact(逐字节;类型敏感:数字 1 ≠ 字符串 "1")' },
5407
+ { v: 'text-lf', l: 'text-lf(只把 CRLF/CR 归一成 LF,不 trim、不折叠大小写)' },
5408
+ ], f.comparison, function (v) { f.comparison = v; if (liveCheck) liveCheck(); }).node);
5409
+ } else {
5410
+ label('完整 JSON(唯一被接受的形状;只这些键,别的键会被服务端拒,不会静默剥离)');
5411
+ var sjTa = document.createElement('textarea');
5412
+ sjTa.placeholder = '{\n "locator": {"kind": "lines", "start": 1, "end": 2},\n'
5413
+ + ' "expected": "文本…",\n "comparison": "exact"\n}';
5414
+ card.appendChild(bindInput(sjTa, 'specJson'));
5415
+ card.appendChild(el('div', 'm-meta',
5416
+ '有限格式:{"locator":{"kind":"lines","start":N,"end":M} 或 {"kind":"json-pointer","pointer":"/a/b"},'
5417
+ + ' "expected":<文本或 JSON 值>, "comparison":"exact"|"text-lf"}。'
5418
+ + '要"不可核对"就写 null。别的形状(未知字段、缺 expected、别的 comparison)一律被拒 —— 不会被改写后接受。'));
5419
+ }
5420
+ body.appendChild(card);
5421
+
5422
+ // 提交前的即时校验(JSON/必填错了就**在提交前**显示,并且提交时不会再发请求)。
5423
+ // 它是**就地更新的一行**(不是每次按键整页重绘):输入时立刻说清错在哪,同时保住光标。
5424
+ var checkLine = el('div', 'm-meta', '');
5425
+ function refreshCheck() {
5426
+ var problems = libFormProblems(f);
5427
+ var cond2 = libConditionsOf(f);
5428
+ var spec2 = libSpecProblems(f);
5429
+ if (!cond2.ok) problems.push(cond2.message);
5430
+ if (!spec2.ok) problems.push(spec2.message);
5431
+ if (problems.length) {
5432
+ checkLine.className = 'lib-err';
5433
+ checkLine.textContent = '提交前检查(现在提交不会发出任何请求):' + problems.join(' · ');
5434
+ } else {
5435
+ checkLine.className = 'm-meta';
5436
+ checkLine.textContent = '提交前检查通过。提交体只含可编辑字段;核对规格 = '
5437
+ + (spec2.value === null ? 'null(不可核对)' : '按上面填写构造的对象') + '。';
5438
+ }
5439
+ }
5440
+ liveCheck = refreshCheck;
5441
+ refreshCheck();
5442
+ body.appendChild(checkLine);
5443
+ body.appendChild(el('div', 'm-meta',
5444
+ '为什么会"提交前就有错":JSON 与必填在本地先判一次,是为了不把"我以为填了"发出去;'
5445
+ + '服务端仍会独立再判一次(界面通过 ≠ 服务端接受)。出错时你填的字段一个都不会被抹掉。'));
5446
+ }
5447
+ // ---------------- 图书馆 · 导入面(源 → 候选 → 人审 → 入库) ----------------
5448
+ // 三条口径从契约直接落下来(写在这里,免得后来的人"顺手"改掉):
5449
+ // ① **分析不写库**(契约 §9.1):点「分析」只换来一份候选清单;每一条都要**人勾了**才可能进库。
5450
+ // ② 引擎是**显式动作**(与「接入历史」那栏同一条纪律,见 2616 行注释):默认「不用模型」,
5451
+ // 选了哪档才真调哪档;调了就必须在回执里如实说出模型名(§3:不许把"两次结果不一样"藏起来)。
5452
+ // ③ 上限**只许来自回执**:客户端不镜像服务端的字节/条数常量(与 fmtLimitMB 同一条命门)——
5453
+ // 所以这里只说"当前多少字节",绝不说"上限是多少"。
5454
+ function libImpToday() {
5455
+ try { return new Date().toISOString().slice(0, 10); } catch (e) { return ''; }
5456
+ }
5457
+ // 导入面的装载口径(2026-10-13,D12;与浏览面同一套):
5458
+ // 默认一页 = 服务端的 `importBatchMaxItems`(200 个内容单元);人往下翻就继续加载(**追加**);
5459
+ // 但这份源的内容单元到 1000 个 ⇒ 切**分页**(只留当前页),免得一路翻下去把会话缓存撑爆。
5460
+ // ⚠️ 跨页**必须保住已勾的条目**:`sel` 按**全局下标**(在 `all` 里的位置)记,不跟着当前页走 ——
5461
+ // 否则"在第 1 页勾了 3 条、翻到第 2 页再提交",那 3 条会人间蒸发。
5462
+ var IMP_PAGE = 200;
5463
+ var IMP_PAGE_SWITCH = 1000;
5464
+ function libImpBlank() {
5465
+ return {
5466
+ kind: 'csv', name: '', text: '', path: '', table: '', engine: '',
5467
+ category: '', source_kind: '', as_of: libImpToday(),
5468
+ busy: false, receipt: null, commit: null, err: '', notice: '',
5469
+ all: [], sel: {}, src: null, mode: 'append', page: 1, ranges: {}, loading: false,
5470
+ };
5471
+ }
5472
+ /** 换源(改文本 / 重选文件)时把**取数账**清干净 —— 否则旧候选会跟着新源一起被提交。 */
5473
+ function libImpReset(im) {
5474
+ im.receipt = null;
5475
+ im.commit = null;
5476
+ im.all = [];
5477
+ im.sel = {};
5478
+ im.src = null;
5479
+ im.mode = 'append';
5480
+ im.page = 1;
5481
+ im.ranges = {};
5482
+ im.loading = false;
5483
+ }
5484
+ function libImpGet() {
5485
+ var L = st.lib;
5486
+ if (!L.imp) L.imp = libImpBlank();
5487
+ return L.imp;
5488
+ }
5489
+ function libImpOpen() {
5490
+ var L = st.lib;
5491
+ L.imp = libImpBlank();
5492
+ L.view = 'import';
5493
+ L.err = '';
5494
+ L.notice = '';
5495
+ if (st.tab === 'library') renderLibrary();
5496
+ }
5497
+ function libImpPaint() { if (st.tab === 'library') renderLibrary(); }
5498
+ /** 勾选状态:按**全局下标**(在 `im.all` 里的位置)记 —— 跨页、跨次加载都作数。
5499
+ * 默认值**只认服务端的 `selected`**(定位没回查到 / 有阻断问题的不预设勾选)。 */
5500
+ function libImpPickedCount() {
5501
+ var im = libImpGet();
5502
+ var n = 0;
5503
+ var keys = Object.keys(im.sel || {});
5504
+ for (var i = 0; i < keys.length; i++) if (im.sel[keys[i]] === true) n++;
5505
+ return n;
5506
+ }
5507
+ /** 提交要送的是**所有勾过的**条 —— 不只是当前显示的这一页。 */
5508
+ function libImpPickedItems() {
5509
+ var im = libImpGet();
5510
+ var out = [];
5511
+ for (var i = 0; i < im.all.length; i++) if (im.sel[i] === true) out.push(im.all[i]);
5512
+ return out;
5513
+ }
5514
+ /** 当前该显示哪一段:分页模式看当前页,渐进模式就是全部已取的。 */
5515
+ function libImpView() {
5516
+ var im = libImpGet();
5517
+ var all = Array.isArray(im.all) ? im.all : [];
5518
+ if (im.mode === 'page') {
5519
+ var rg = im.ranges[im.page];
5520
+ if (!rg) return { list: [], base: 0 };
5521
+ return { list: all.slice(rg.base, rg.base + rg.count), base: rg.base };
5522
+ }
5523
+ return { list: all, base: 0 };
5524
+ }
5525
+ /** 分析(可带 `offset` = 从第几个内容单元开始 ⇒ "继续加载" / "翻页")。
5526
+ * `offset` 缺省 0 = 从头读一份新源(此时才清旧候选)。
5527
+ * `replaceFrom`(D11 重试用)= 成功后**先把已取候选截到这一条之前**再放本页:续页重试
5528
+ * 走的是同一条追加路径,不截就会追加出重复候选。失败时**一个字节都不截**(重试失败不该丢东西)。 */
5529
+ function libImpAnalyze(offset, replaceFrom) {
5530
+ var im = libImpGet();
5531
+ if (im.busy) return;
5532
+ var off = Math.max(0, Math.floor(Number(offset) || 0));
5533
+ var rf = Number.isFinite(Number(replaceFrom)) ? Math.max(0, Math.floor(Number(replaceFrom))) : null;
5534
+ if (off > 0 && !im.src) {
5535
+ im.err = '还没有分析过这一份源 —— 先点「① 分析」。';
5536
+ libImpPaint();
5537
+ return;
5538
+ }
5539
+ im.err = '';
5540
+ im.notice = '';
5541
+ im.commit = null;
5542
+ var name = String(im.name || '').trim();
5543
+ var text = String(im.text == null ? '' : im.text);
5544
+ var isSqlite = im.kind === 'sqlite';
5545
+ if (name === '') {
5546
+ im.err = '源名必填(它是 source_ref 的前半截)—— 没有向服务端发出任何请求。';
5547
+ libImpPaint();
5548
+ return;
5549
+ }
5550
+ // SQLite 的源不是文本框里的东西 ⇒ 空文本不是错,空路径/空表名才是。
5551
+ if (!isSqlite && text === '') {
5552
+ im.err = '源文本是空的 —— 没有发出任何请求(空源不是"导入 0 条",是没有东西可读)。';
5553
+ libImpPaint();
5554
+ return;
5555
+ }
5556
+ if (isSqlite) {
5557
+ if (String(im.path || '').trim() === '') {
5558
+ im.err = 'SQLite 通道要填库文件路径 —— 没有向服务端发出任何请求。';
5559
+ libImpPaint();
5560
+ return;
5561
+ }
5562
+ if (String(im.table || '').trim() === '') {
5563
+ im.err = 'SQLite 通道要点名一张表(服务端不替你猜哪张)—— 没有向服务端发出任何请求。';
5564
+ libImpPaint();
5565
+ return;
5566
+ }
5567
+ }
5568
+ var payload = { kind: im.kind, name: name, text: text };
5569
+ if (isSqlite) { payload.path = String(im.path || '').trim(); payload.table = String(im.table || '').trim(); }
5570
+ if (String(im.as_of || '').trim() !== '') payload.as_of = String(im.as_of).trim();
5571
+ if (im.category) payload.category = im.category;
5572
+ if (im.source_kind) payload.source_kind = im.source_kind;
5573
+ // ⚠️ 引擎为空 ⇒ **根本不带 model 字段**(服务端把空串判成"没选引擎")。
5574
+ // 不要在这里补一个默认引擎顶上 —— 那等于替主人决定花不花算力/额度。
5575
+ if (im.engine) payload.model = im.engine;
5576
+ if (off > 0) payload.offset = off;
5577
+ im.busy = true;
5578
+ libImpPaint();
5579
+ api('/library/import/analyze', { method: 'POST', body: payload }).then(function (r) {
5580
+ im.busy = false;
5581
+ if (!r) {
5582
+ im.err = '分析没有回执(网关不可达)—— 你填的源还在,可以直接重试。';
5583
+ libImpPaint();
5584
+ return;
5585
+ }
5586
+ if (!r.ok) {
5587
+ // 失败回执里**不该有**候选:有的话说明"没跑成的模型段"和规则段被搅在了一起。
5588
+ // 首读失败 ⇒ 全清;续读失败 ⇒ **保住已取的那几页** —— 否则一次翻页失败会把你在前面
5589
+ // 勾好的东西一起抹掉(那是最气人的一种"安全")。
5590
+ if (off === 0) libImpReset(im);
5591
+ im.err = '分析被拒:' + libWhy(r, '未给出原因') + ' —— 没有写任何东西进库(分析是纯读)。';
5592
+ libImpPaint();
5593
+ return;
5594
+ }
5595
+ var list = Array.isArray(r.candidates) ? r.candidates : [];
5596
+ var st = r.stats || {};
5597
+ // ⚠️ `units_total` 可能是 null(SQLite 大表不跑 COUNT(*)——服务端如实说"没数"):
5598
+ // 当成 0 会让界面显示"共 0 个内容单元"、还会误判走渐进还是翻页。
5599
+ var unitsTotal = (st.units_total === null || st.units_total === undefined)
5600
+ ? null : Number(st.units_total || 0);
5601
+ // 继续加载时源摘要变了(主人改过文本)⇒ 当成**新源**:旧候选全清,这一页就是第 1 页。
5602
+ if (off > 0 && im.src && r.source_hash !== im.src.hash) {
5603
+ libImpReset(im);
5604
+ im.notice = '源内容变了(摘要不同)⇒ 旧候选已清,这一页当成第一页重来。';
5605
+ }
5606
+ // 重试(rf !== null)时**不清**:那一页占的正是原来那段下标,重新填回来即可
5607
+ // (见下面的截断 + 剪枝)。
5608
+ if (off === 0 && rf === null) {
5609
+ im.all = [];
5610
+ im.sel = {};
5611
+ im.ranges = {};
5612
+ im.page = 1;
5613
+ im.mode = (unitsTotal !== null && unitsTotal > IMP_PAGE_SWITCH) ? 'page' : 'append';
5614
+ }
5615
+ // 重试:成功之后才截到本页起点(失败路径不截,已取候选原样保留)。
5616
+ if (rf !== null) im.all = im.all.slice(0, rf);
5617
+ var base = im.all.length;
5618
+ for (var i = 0; i < list.length; i++) {
5619
+ im.all.push(list[i]);
5620
+ if (list[i].selected === true) im.sel[base + i] = true;
5621
+ }
5622
+ // 重试后按新长度剪一次勾选:本页候选数变少时,越界的勾不该留着(它们指向不存在的条)。
5623
+ if (rf !== null) {
5624
+ for (var kk in im.sel) { if (Number(kk) >= im.all.length) delete im.sel[kk]; }
5625
+ }
5626
+ // 页表**两种模式都记**(2026-10-14,D11):重试某一页时必须知道它的起点,否则续页重试
5627
+ // 走同一条追加路径会追加出重复候选。`im.page` 只在翻页模式用于显示,记下来无副作用。
5628
+ im.page = Math.floor(Number(st.offset || 0) / IMP_PAGE) + 1;
5629
+ im.ranges[im.page] = { base: base, count: list.length };
5630
+ im.receipt = r;
5631
+ im.src = {
5632
+ hash: r.source_hash,
5633
+ next: st.next_offset === undefined ? null : st.next_offset,
5634
+ unitsTotal: unitsTotal,
5635
+ loaded: im.all.length,
5636
+ };
5637
+ im.notice = list.length === 0
5638
+ ? (off > 0
5639
+ ? '这一页没有候选(后面还有页)。'
5640
+ : '源里没有可成为条目的东西(空文本 / 只有表头)—— 这是"没有候选",不是"导入成功 0 条"。')
5641
+ : ((off > 0 ? '又取到 ' : '读到 ') + list.length + ' 条候选:累计 ' + im.all.length
5642
+ + ' 条,其中 ' + libImpPickedCount() + ' 条勾着。');
5643
+ libImpPaint();
5644
+ }).catch(function () {
5645
+ im.busy = false;
5646
+ im.err = '分析失败(网关不可达)—— 你填的源还在,可以直接重试。';
5647
+ libImpPaint();
5648
+ });
5649
+ }
5650
+ /** 重试**回执里那一页**(D11)。算出它的起始下标交给 `libImpAnalyze` 的 `replaceFrom`;
5651
+ * 取不到页表时退回"从当前累计长度续"(等价于把最后取到的东西再取一遍)。 */
5652
+ function libImpRetry() {
5653
+ var im = libImpGet();
5654
+ var st = (im.receipt && im.receipt.stats) || {};
5655
+ var off = Math.max(0, Math.floor(Number(st.offset) || 0));
5656
+ if (off === 0) { libImpAnalyze(0, 0); return; }
5657
+ var rg = im.ranges[Math.floor(off / IMP_PAGE) + 1];
5658
+ var base = rg && Number.isFinite(Number(rg.base)) ? Number(rg.base) : im.all.length;
5659
+ libImpAnalyze(off, base);
5660
+ }
5661
+ function libImpCommit() {
5662
+ var im = libImpGet();
5663
+ if (im.busy) return;
5664
+ im.err = '';
5665
+ im.notice = '';
5666
+ var rec = im.receipt;
5667
+ if (!rec || !im.src || !im.all.length) {
5668
+ im.err = '还没有可提交的东西 —— 先点「分析」。';
5669
+ libImpPaint();
5670
+ return;
5671
+ }
5672
+ // 送的是**所有勾过的**条:跨页勾的也算(`sel` 按全局下标记,理由见 `libImpBlank` 上头那段)。
5673
+ var items = libImpPickedItems();
5674
+ if (!items.length) {
5675
+ im.err = '一条都没勾 —— 没有发出任何请求(分析不会自动入库,必须由你逐条点名)。';
5676
+ libImpPaint();
5677
+ return;
5678
+ }
5679
+ var token = ++libActionSeq;
5680
+ im.busy = true;
5681
+ im.commit = null;
5682
+ libImpPaint();
5683
+ api('/library/import/commit', { method: 'POST', body: { source_hash: im.src.hash, items: items } })
5684
+ .then(function (r) {
5685
+ if (token !== libActionSeq) return;
5686
+ im.busy = false;
5687
+ if (!r) {
5688
+ im.err = '入库没有回执(网关不可达)—— 勾选还在,可以直接重试。';
5689
+ libImpPaint();
5690
+ return;
5691
+ }
5692
+ if (!r.ok) {
5693
+ im.err = '这批没能处理:' + libWhy(r, '未给出原因') + ' —— 勾选还在,改完可以直接重试。';
5694
+ libImpPaint();
5695
+ return;
5696
+ }
5697
+ im.commit = r;
5698
+ // 入库改变库的读数(条数 / 留痕)⇒ status 必须失效重读,否则顶部统计停在写之前的值。
5699
+ libInvalidateStatus();
5700
+ var written = Number(r.committed || 0);
5701
+ var rejectedN = Array.isArray(r.rejected) ? r.rejected.length : 0;
5702
+ var sk = Number(r.skipped || 0);
5703
+ im.notice = '写入 ' + written + ' 条'
5704
+ + (sk ? ' · 跳过 ' + sk + ' 条(同一份源、同一个定位已经入过库 —— 幂等命中,这是成功不是失败)' : '')
5705
+ + (rejectedN ? ' · 被拒 ' + rejectedN + ' 条(逐条原因见下)' : '')
5706
+ + (written === 0 && !sk && !rejectedN ? '(这一批什么都没写)' : '');
5707
+ toast('导入:' + im.notice, 6000);
5708
+ libImpPaint();
5709
+ }).catch(function () {
5710
+ if (token !== libActionSeq) return;
5711
+ im.busy = false;
5712
+ im.err = '入库失败(网关不可达)—— 勾选还在,可以直接重试。';
5713
+ libImpPaint();
5714
+ });
5715
+ }
5716
+ function libImpCand(it, gi) {
5717
+ var im = libImpGet();
5718
+ var box = el('div', 'lib-card');
5719
+ var head = el('div', null);
5720
+ head.style.cssText = 'display:flex;gap:8px;align-items:flex-start;';
5721
+ var cb = document.createElement('input');
5722
+ cb.type = 'checkbox';
5723
+ cb.checked = im.sel[gi] === true;
5724
+ cb.onchange = function () { im.sel[gi] = !!cb.checked; libImpPaint(); };
5725
+ head.appendChild(cb);
5726
+ var main = el('div', null);
5727
+ main.style.cssText = 'flex:1;min-width:0;';
5728
+ main.appendChild(el('div', 'm-title', libShow(it.title)));
5729
+ var chips = el('div', null);
5730
+ chips.appendChild(el('span', 'lib-chip', '#' + libShow(it.index)));
5731
+ chips.appendChild(el('span', 'lib-chip', '定位 ' + libShow(it.locator)));
5732
+ chips.appendChild(el('span', 'lib-chip', libMap(LIB_CAT, it.category, it.category)));
5733
+ chips.appendChild(el('span', 'lib-chip', libMap(LIB_SRC, it.source_kind, it.source_kind)));
5734
+ chips.appendChild(el('span', 'lib-chip', it.origin === 'model' ? '模型提的' : '规则读的'));
5735
+ chips.appendChild(el('span', 'lib-chip', it.locator_verified === true ? '定位回查通过' : '定位存疑'));
5736
+ main.appendChild(chips);
5737
+ main.appendChild(el('div', 'm-meta', 'as_of ' + libShow(it.as_of) + ' · source_ref ' + libShow(it.source_ref)));
5738
+ if (it.evidence) main.appendChild(el('div', 'lib-pre', String(it.evidence)));
5739
+ var issues = Array.isArray(it.issues) ? it.issues : [];
5740
+ if (issues.length) {
5741
+ main.appendChild(el('div', 'm-meta', '问题:' + issues.map(function (s) {
5742
+ return typeof s === 'string' ? s : String((s && s.reason) || '');
5743
+ }).join(' · ')));
5744
+ }
5745
+ if (it.selected !== true) {
5746
+ main.appendChild(el('div', 'm-meta',
5747
+ '默认不勾:这一条的定位或字段没能过闸 —— 勾它等于由你担保它(闸拦下的是"机器判不了",不是"这条一定错")。'));
5748
+ }
5749
+ head.appendChild(main);
5750
+ box.appendChild(head);
5751
+ return box;
5752
+ }
5753
+ function renderLibImport() {
5754
+ var L = st.lib;
5755
+ var im = libImpGet();
5756
+ var tools = el('div', 'm-tools');
5757
+ var bBack = el('button', null, '← 回到列表');
5758
+ bBack.onclick = function () {
5759
+ L.view = 'list';
5760
+ L.err = '';
5761
+ L.notice = '';
5762
+ libImpPaint();
5763
+ };
5764
+ tools.appendChild(bBack);
5765
+ body.appendChild(tools);
5766
+ body.appendChild(el('div', 'm-meta',
5767
+ '导入 = 源 → 候选 → 你逐条点名 → 入库。第一步「分析」一个字都不写库;'
5768
+ + '第二条路「+ 新建资料」是手敲一条 —— 两者写进的是同一套库、同一套闸。'));
5769
+ if (im.err) body.appendChild(el('div', 'lib-err', im.err));
5770
+ if (im.notice) body.appendChild(el('div', 'lib-note', im.notice));
5771
+
5772
+ // ① 源
5773
+ var box = el('div', 'lib-card');
5774
+ box.appendChild(el('div', 'm-title', '① 源(要导入什么)'));
5775
+ var r1 = el('div', null);
5776
+ r1.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;';
5777
+ r1.appendChild(el('span', 'm-meta', '源类型'));
5778
+ r1.appendChild(makeSel([
5779
+ { v: 'csv', l: 'CSV 表格(第 1 行是表头)' },
5780
+ { v: 'text', l: '粘贴文本(空行分段)' },
5781
+ { v: 'json', l: 'JSON / JSONL(数组一元素一条)' },
5782
+ { v: 'sqlite', l: 'SQLite 库(选一张表;填路径)' },
5783
+ ], im.kind, function (v) { if (v !== im.kind) { im.kind = v; libImpReset(im); } libImpPaint(); }).node);
5784
+ var inpName = document.createElement('input');
5785
+ inpName.type = 'text';
5786
+ inpName.placeholder = '源名(必填,如 GB-T1800.1-2020.csv / 数据集.json / 源库.db)';
5787
+ inpName.value = im.name;
5788
+ inpName.oninput = function () { im.name = inpName.value; };
5789
+ inpName.style.cssText = 'flex:1;min-width:200px;padding:6px 10px;border:1px solid var(--lg-input);border-radius:9px;background:transparent;color:var(--lg-text);font-size:13px;';
5790
+ r1.appendChild(inpName);
5791
+ box.appendChild(r1);
5792
+ var r2 = el('div', null);
5793
+ r2.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px;';
5794
+ // SQLite 通道(路 A,2026-10-15):源在盘上 ⇒ 填路径 + 表名,不走文本框。
5795
+ // ⚠️ 浏览器给不了真实路径(`input.files[0]` 只有 name,没有 path)⇒ 这里只能手敲 / 粘贴。
5796
+ var isSqlite = im.kind === 'sqlite';
5797
+ if (isSqlite) {
5798
+ var rp = el('div', null);
5799
+ rp.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px;';
5800
+ rp.appendChild(el('span', 'm-meta', '库文件路径'));
5801
+ var inpPath = document.createElement('input');
5802
+ inpPath.type = 'text';
5803
+ inpPath.placeholder = '例如 D:\\资料\\源库.db(必须是插件配置里登记过的根目录下的文件)';
5804
+ inpPath.value = im.path || '';
5805
+ inpPath.oninput = function () { im.path = inpPath.value; };
5806
+ inpPath.style.cssText = 'flex:1;min-width:260px;padding:6px 10px;border:1px solid var(--lg-input);border-radius:9px;background:transparent;color:var(--lg-text);font-size:13px;';
5807
+ rp.appendChild(inpPath);
5808
+ rp.appendChild(el('span', 'm-meta', '表'));
5809
+ var inpTable = document.createElement('input');
5810
+ inpTable.type = 'text';
5811
+ inpTable.placeholder = '表名(必填:不许猜)';
5812
+ inpTable.value = im.table || '';
5813
+ inpTable.oninput = function () { im.table = inpTable.value; };
5814
+ inpTable.style.cssText = 'width:180px;padding:6px 10px;border:1px solid var(--lg-input);border-radius:9px;background:transparent;color:var(--lg-text);font-size:13px;';
5815
+ rp.appendChild(inpTable);
5816
+ box.appendChild(rp);
5817
+ box.appendChild(el('div', 'm-meta',
5818
+ '源库只读打开(插件不写它一个字节),而且拒开本插件自己的库;表必须你点名 —— '
5819
+ + '这一路不进文本框:文本量由服务端按页取(SQL 的 LIMIT/OFFSET),大表也不会一次读满。'));
5820
+ }
5821
+ var fileIn = document.createElement('input');
5822
+ fileIn.type = 'file';
5823
+ fileIn.accept = '.csv,.txt,.md,.json,.jsonl,.ndjson,text/*,application/json';
5824
+ fileIn.onchange = function () {
5825
+ var f = fileIn.files && fileIn.files[0];
5826
+ if (!f) return;
5827
+ if (typeof f.text !== 'function') {
5828
+ im.err = '这个浏览器读不了文件内容(没有 File.text())—— 请把内容粘贴到下面的框里。';
5829
+ libImpPaint();
5830
+ return;
5831
+ }
5832
+ f.text().then(function (t) {
5833
+ im.text = String(t == null ? '' : t);
5834
+ if (String(im.name || '').trim() === '' && f.name) im.name = String(f.name);
5835
+ libImpReset(im);
5836
+ im.err = '';
5837
+ im.notice = '已读入 ' + String(f.name || '文件') + '(' + fmtBytes(utf8Len(im.text)) + ')—— 内容还能在下面的框里改。';
5838
+ libImpPaint();
5839
+ }).catch(function () {
5840
+ im.err = '读文件失败 —— 也可以直接把内容粘到下面的框里。';
5841
+ libImpPaint();
5842
+ });
5843
+ };
5844
+ r2.appendChild(fileIn);
5845
+ r2.appendChild(el('span', 'm-meta', '选文件 = 把内容读进下面的框(不发送文件本身);读不到就粘贴'));
5846
+ if (isSqlite) r2.style.display = 'none'; // SQLite 走路径,不走文件
5847
+ box.appendChild(r2);
5848
+ var ta = document.createElement('textarea');
5849
+ ta.rows = 8;
5850
+ ta.placeholder = '把 CSV / 一段资料原文粘在这里…';
5851
+ ta.value = im.text;
5852
+ // 2026-10-13 真机发现修正:原先这里只写 im.text 不刷新字节数 ⇒ 计数恒停在 0.0 KB。
5853
+ // 只就地改那一行文字的 textContent,**不**整块重绘(textarea 重建会丢光标)。
5854
+ ta.oninput = function () { im.text = ta.value; paintBytes(); };
5855
+ ta.style.cssText = 'width:100%;margin-top:6px;padding:8px 10px;border:1px solid var(--lg-input);border-radius:9px;background:transparent;color:var(--lg-text);font-size:13px;box-sizing:border-box;';
5856
+ box.appendChild(ta);
5857
+ var byteNote = el('div', 'm-meta', '当前 ' + fmtBytes(utf8Len(im.text))
5858
+ + ' —— 服务端按字节封顶;真超了会拒,并在回执里写明它的上限(客户端不镜像那个数字)');
5859
+ var paintBytes = function () {
5860
+ byteNote.textContent = '当前 ' + fmtBytes(utf8Len(im.text))
5861
+ + ' —— 服务端按字节封顶;真超了会拒,并在回执里写明它的上限(客户端不镜像那个数字)';
5862
+ };
5863
+ if (isSqlite) { ta.style.display = 'none'; byteNote.style.display = 'none'; }
5864
+ box.appendChild(byteNote);
5865
+
5866
+ // ② 交给谁读
5867
+ var r3 = el('div', null);
5868
+ r3.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px;';
5869
+ r3.appendChild(el('span', 'm-meta', '引擎'));
5870
+ r3.appendChild(makeSel([
5871
+ { v: '', l: '不用模型(只用规则段)' },
5872
+ { v: 'assistant', l: '本机小助手(零成本)' },
5873
+ { v: 'global', l: '当前全局大模型(会花额度)' },
5874
+ ], im.engine, function (v) { im.engine = v; libImpPaint(); }).node);
5875
+ r3.appendChild(el('span', 'm-meta',
5876
+ '粘贴人话 / 乱格式时才需要模型(它只干"把散段读成条目"这一件);表格类源不必用。'
5877
+ + '模型读出来的每一条仍要过定位回查 + 字段闸,而且照样要你勾。'));
5878
+ box.appendChild(r3);
5879
+ var r4 = el('div', null);
5880
+ r4.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px;';
5881
+ r4.appendChild(el('span', 'm-meta', '取数时间'));
5882
+ var inpAsOf = document.createElement('input');
5883
+ inpAsOf.type = 'text';
5884
+ inpAsOf.placeholder = 'YYYY-MM-DD';
5885
+ inpAsOf.value = im.as_of;
5886
+ inpAsOf.oninput = function () { im.as_of = inpAsOf.value; };
5887
+ inpAsOf.style.cssText = 'width:130px;padding:6px 10px;border:1px solid var(--lg-input);border-radius:9px;background:transparent;color:var(--lg-text);font-size:13px;';
5888
+ r4.appendChild(inpAsOf);
5889
+ r4.appendChild(el('span', 'm-meta', '类目'));
5890
+ r4.appendChild(makeSel([
5891
+ { v: '', l: '不指定(用服务端默认)' },
5892
+ { v: 'standard-value', l: '标准值' }, { v: 'boundary', l: '边界' },
5893
+ { v: 'project-spec', l: '项目规格' }, { v: 'external-api', l: '外部接口' },
5894
+ ], im.category, function (v) { im.category = v; }).node);
5895
+ r4.appendChild(el('span', 'm-meta', '来源'));
5896
+ r4.appendChild(makeSel([
5897
+ { v: '', l: '不指定(用服务端默认)' },
5898
+ { v: 'standard', l: '标准' }, { v: 'official-doc', l: '官方文档' },
5899
+ { v: 'manual', l: '人工' }, { v: 'our-code', l: '自有代码' }, { v: 'web', l: '网页' },
5900
+ ], im.source_kind, function (v) { im.source_kind = v; }).node);
5901
+ box.appendChild(r4);
5902
+ box.appendChild(el('div', 'm-meta',
5903
+ 'as_of 是"这份资料的取数时间",绝不由创建时间推断(契约 §4)—— 粘贴一份去年的标准就填去年那份的日期。'));
5904
+ var btnRow = el('div', 'btnrow');
5905
+ var bRun = el('button', 'primary', im.busy ? '分析中…' : '① 分析(不写库)');
5906
+ bRun.disabled = !!im.busy;
5907
+ bRun.onclick = function () { libImpAnalyze(); };
5908
+ btnRow.appendChild(bRun);
5909
+ box.appendChild(btnRow);
5910
+ body.appendChild(box);
5911
+
5912
+ var rec = im.receipt;
5913
+ if (!rec) return;
5914
+
5915
+ // ③ 候选清单的读数(只在有回执时出现)
5916
+ var stat = rec.stats || {};
5917
+ var card = el('div', 'lib-card');
5918
+ card.appendChild(el('div', 'm-title', '② 候选(勾谁才进谁)'));
5919
+ card.appendChild(el('div', 'm-meta', '源摘要 ' + libHash(rec.source_hash)
5920
+ + ' · 数据行 ' + Number(stat.rows || 0) + ' · 候选 ' + Number(stat.candidates || 0) + ' 条'));
5921
+ var mb = [];
5922
+ mb.push(stat.model_used === true
5923
+ ? ('模型段:跑了 ' + libShow(stat.model))
5924
+ : '模型段:没跑(只用规则段)');
5925
+ if (stat.model_used === true) {
5926
+ mb.push('送模型 ' + Number(stat.model_rows || 0) + ' 条'
5927
+ + (stat.model_rows_capped ? '(触到成本闸,超出的行只走规则段)' : ''));
5928
+ }
5929
+ if (stat.dropped_by_batch_limit) {
5930
+ mb.push('超单批上限被丢下 ' + Number(stat.dropped_by_batch_limit) + ' 条(不静默截断,如实记账)');
5931
+ }
5932
+ card.appendChild(el('div', 'm-meta', mb.join(' · ')));
5933
+ if (im.src) {
5934
+ card.appendChild(el('div', 'm-meta', '这份源' + libImpUnitsText(im.src.unitsTotal) + ' · 已取 '
5935
+ + im.all.length + ' 条候选' + (im.mode === 'page'
5936
+ ? '(单元数超过 ' + IMP_PAGE_SWITCH + ' ⇒ 走翻页,一页 ' + IMP_PAGE + ' 个单元)'
5937
+ : '(往下滚到底会自动继续加载)')));
5938
+ }
5939
+ if (Number(stat.rejected || 0) > 0) {
5940
+ card.appendChild(el('div', 'm-meta', '默认未勾 ' + Number(stat.rejected)
5941
+ + ' 条:定位没回查到 / 有阻断性问题 —— 它们不会自己进库。'));
5942
+ }
5943
+ // ── 降级告警(D11,2026-10-14 放行)──────────────────────────────────────────
5944
+ // 判据:`stats.model_error` 在场就**必须有这一块**,而且**不许**是一行灰字。
5945
+ // ① 模型段没跑成 ≠ 分析失败(规则段候选照给),所以不能说"分析被拒";
5946
+ // ② 但 "model_used:true + model_error"(分批里部分成功)**同样必须告警** ——
5947
+ // 只看 model_used 会把"半份模型结果"读成"模型全跑完了";
5948
+ // ③ 「重试」重发的是**这一页**(同一个 offset),不回到第一页、不动别的页的勾。
5949
+ if (stat.model_error) {
5950
+ var me = stat.model_error;
5951
+ var warn = el('div', 'lib-warn');
5952
+ warn.appendChild(el('div', 't', '⚠ 模型段没跑成'
5953
+ + (me.engine ? '(引擎 ' + String(me.engine) + ')' : '')));
5954
+ warn.appendChild(el('div', null, String(me.message || '未给出原因')));
5955
+ var wf = [];
5956
+ if (Number(me.rows_offered || 0) > 0) wf.push('本来要送模型 ' + Number(me.rows_offered) + ' 行');
5957
+ if (Array.isArray(me.batches_failed) && me.batches_failed.length) {
5958
+ wf.push('第 ' + me.batches_failed.join('、') + ' / ' + Number(me.batches_total || 1)
5959
+ + ' 批炸了(已成功的批结果保留)');
5960
+ }
5961
+ warn.appendChild(el('div', 'm-meta', wf.join(' · ') + ' —— 规则段候选照常给你,进不进库由你勾,不由模型定。'));
5962
+ var bRetry = el('button', null, '重试这一页(含模型段)');
5963
+ bRetry.onclick = function () { libImpRetry(); };
5964
+ var wrow = el('div', 'btnrow');
5965
+ wrow.appendChild(bRetry);
5966
+ warn.appendChild(wrow);
5967
+ body.appendChild(warn);
5968
+ }
5969
+ body.appendChild(card);
5970
+
5971
+ // ④ 上一批的入库回执(贴在勾选区上面:提交完不用往下翻)
5972
+ if (im.commit) {
5973
+ var cc = el('div', 'lib-card');
5974
+ cc.appendChild(el('div', 'm-title', '③ 入库回执'));
5975
+ var rj = Array.isArray(im.commit.rejected) ? im.commit.rejected : [];
5976
+ cc.appendChild(el('div', 'm-meta', '写入 ' + Number(im.commit.committed || 0)
5977
+ + ' 条 · 跳过(幂等命中,算成功)' + Number(im.commit.skipped || 0)
5978
+ + ' 条 · 被拒 ' + rj.length + ' 条'));
5979
+ if (im.commit.no_key) {
5980
+ cc.appendChild(el('div', 'm-meta', '其中 ' + Number(im.commit.no_key)
5981
+ + ' 条没有定位 ⇒ 没有幂等键(重复导入它挡不住)—— 这是如实记账,不是失败。'));
5982
+ }
5983
+ rj.forEach(function (x) {
5984
+ cc.appendChild(el('div', 'lib-err', '被拒 #' + libShow(x.index) + ':' + libShow(x.reason)
5985
+ + (x.message ? '(' + String(x.message) + ')' : '')));
5986
+ });
5987
+ var ids = Array.isArray(im.commit.ids) ? im.commit.ids : [];
5988
+ if (ids.length) cc.appendChild(el('div', 'lib-pre lib-mono', ids.join('\n')));
5989
+ body.appendChild(cc);
5990
+ }
5991
+
5992
+ var view = libImpView();
5993
+ var list = view.list;
5994
+ /** 装载控制条 + 滚到底自动续页(与记忆浏览同款)。空段也要出:空的是**这一页**,不是这份源。 */
5995
+ function impTail() {
5996
+ if (im.src) body.appendChild(libImpMoreUI(im, list.length));
5997
+ if (body.__lingImpScroll) body.removeEventListener('scroll', body.__lingImpScroll);
5998
+ body.__lingImpScroll = function () {
5999
+ if (im.busy || im.mode === 'page') return;
6000
+ if (!im.src || im.src.next === null) return;
6001
+ if (!body.scrollHeight) return;
6002
+ if (body.scrollTop + body.clientHeight >= body.scrollHeight - 170) libImpAnalyze(im.src.next);
6003
+ };
6004
+ body.addEventListener('scroll', body.__lingImpScroll);
6005
+ }
6006
+ if (!list.length) {
6007
+ body.appendChild(el('div', 'm-empty',
6008
+ '这一段没有候选 —— 源里没有可成为条目的东西(空文本 / 只有表头),或者这一页本来就是空的。'
6009
+ + '这不是"导入成功 0 条"。'));
6010
+ // ⚠️ 空的时候**不出现提交按钮**(没有可勾的东西就不该递一个"提交(0 条)");翻页/续页控件照给。
6011
+ impTail();
6012
+ return;
6013
+ }
6014
+ var pickRow = el('div', 'btnrow');
6015
+ var bAll = el('button', null, '全选(这一段)');
6016
+ bAll.onclick = function () {
6017
+ for (var i = 0; i < list.length; i++) im.sel[view.base + i] = true;
6018
+ libImpPaint();
6019
+ };
6020
+ var bNone = el('button', null, '全不选(这一段)');
6021
+ bNone.onclick = function () {
6022
+ for (var i = 0; i < list.length; i++) im.sel[view.base + i] = false;
6023
+ libImpPaint();
6024
+ };
6025
+ var bCommit = el('button', 'primary', im.busy ? '提交中…' : ('② 提交入库(' + libImpPickedCount() + ' 条)'));
6026
+ bCommit.disabled = !!im.busy;
6027
+ bCommit.onclick = function () { libImpCommit(); };
6028
+ pickRow.appendChild(bAll);
6029
+ pickRow.appendChild(bNone);
6030
+ pickRow.appendChild(bCommit);
6031
+ body.appendChild(pickRow);
6032
+ for (var i = 0; i < list.length; i++) body.appendChild(libImpCand(list[i], view.base + i));
6033
+ body.appendChild(el('div', 'm-meta',
6034
+ '提交只走既有的 create(与「+ 新建资料」同一条路、同一套闸)。'
6035
+ + '每条都带 import_key(= 源摘要 + 定位)⇒ 同一份源、同一个定位再提交两次,第二次是跳过,不会写第二行。'));
6036
+ impTail();
6037
+ }
6038
+ /** 内容单元数怎么念:`null` = 服务端没数(SQLite 大表不跑 `COUNT(*)`)——
6039
+ * 如实说"没数",不许显示成 0(那会让人以为源是空的)。 */
6040
+ function libImpUnitsText(n) {
6041
+ return (n === null || n === undefined) ? '内容单元数没数(SQLite 大表不跑 COUNT(*))' : ('共 ' + n + ' 个内容单元');
6042
+ }
6043
+ /** 装载控制条:渐进(往下翻续页)/ 分页(源太大,只留当前页)—— 与浏览面同一套口径(2026-10-13,D12)。 */
6044
+ function libImpMoreUI(im, shownCount) {
6045
+ var bar = el('div', 'm-acts lib-imp-more');
6046
+ if (im.mode === 'page') {
6047
+ var pages = Math.max(1, Math.ceil(im.src.unitsTotal / IMP_PAGE));
6048
+ var bPrev = el('button', null, '‹ 上一页');
6049
+ var bNext = el('button', null, '下一页 ›');
6050
+ bPrev.disabled = !!im.busy || im.page <= 1;
6051
+ bNext.disabled = !!im.busy || im.page >= pages;
6052
+ bPrev.onclick = function () { libImpGoPage(im.page - 1); };
6053
+ bNext.onclick = function () { libImpGoPage(im.page + 1); };
6054
+ bar.appendChild(bPrev);
6055
+ bar.appendChild(el('span', 'm-meta', '第 ' + im.page + ' / ' + pages + ' 页 · 这一段 '
6056
+ + shownCount + ' 条 · 这份源 ' + im.src.unitsTotal + ' 个内容单元'));
6057
+ bar.appendChild(bNext);
6058
+ } else if (im.src.next !== null) {
6059
+ var bMore = el('button', null, im.busy ? '读取中…' : '继续加载 ▾');
6060
+ bMore.disabled = !!im.busy;
6061
+ bMore.onclick = function () { libImpAnalyze(im.src.next); };
6062
+ bar.appendChild(bMore);
6063
+ bar.appendChild(el('span', 'm-meta', '已取 ' + im.all.length + ' 条 / 这份源'
6064
+ + libImpUnitsText(im.src.unitsTotal) + '(往下滚到底会自动继续加载)'));
6065
+ } else {
6066
+ bar.appendChild(el('span', 'm-meta', '— 已到最早 —(已取 ' + im.all.length
6067
+ + ' 条 / ' + libImpUnitsText(im.src.unitsTotal) + ')'));
6068
+ }
6069
+ return bar;
6070
+ }
6071
+ /** 翻到第 page 页:取过的页直接切(**不重复请求、不重复入列**),没取过才去取。 */
6072
+ function libImpGoPage(page) {
6073
+ var im = libImpGet();
6074
+ if (im.busy || !im.src) return;
6075
+ var pages = Math.max(1, Math.ceil(im.src.unitsTotal / IMP_PAGE));
6076
+ var p = Math.min(pages, Math.max(1, page));
6077
+ if (im.ranges[p]) { im.page = p; libImpPaint(); return; }
6078
+ im.page = p;
6079
+ libImpAnalyze((p - 1) * IMP_PAGE);
6080
+ }
6081
+
6082
+ // ── 浏览面(第七面,2026-10-13):**只读** —— 不搜也能翻 ────────────────────────────
6083
+ // 形态 A(用户 2026-10-13 拍板):左树「类目 → 来源 → 条目」+ 右侧详情(谱系链 + 核对史)。
6084
+ // 三条自我约束:
6085
+ // ① **这一面一个写按钮都没有**(新建 / 修订 / 撤下 / 核对全在维护面)—— "能翻一翻看"与
6086
+ // "能改"是两种授权,混在同一面里迟早有人误点;
6087
+ // ② 树的骨架来自服务端回的 `groups`(恒定全量),**不是**从"当前这一页条目"反推的 ——
6088
+ // 否则翻一页骨架就跟着变一次;
6089
+ // ③ 只有**展开某个组**时才去取那一组(懒加载):进这一面总共 1 个请求。
6090
+ // 服务端为什么不给总数:两个列表端点都不报 total(契约禁止误导性 total)⇒ 这里只写
6091
+ // "本页几条 / 还有更多",**不写"共 N 条"**。
6092
+ function libBrBlank() {
6093
+ return {
6094
+ groups: null, loading: false, err: '',
6095
+ catOpen: {}, node: {},
6096
+ sel: '', detail: null, hist: null, checks: null, selErr: '', selLoading: false,
6097
+ gen: 0, scroll: 0,
6098
+ };
6099
+ }
6100
+ // 浏览面的装载口径(2026-10-13 主人定,形状沿用「记忆浏览」那一套):
6101
+ // 默认一页 200 条;人往下翻(滚到底)就继续加载;
6102
+ // 但某组总量到 1000 条 ⇒ 切**分页**(只留当前页,替换不累积),免得一路翻下去把会话缓存撑爆。
6103
+ var BR_PAGE = 200;
6104
+ var BR_PAGE_SWITCH = 1000;
6105
+ function libBrGet() {
6106
+ var L = st.lib;
6107
+ if (!L.br) L.br = libBrBlank();
6108
+ return L.br;
6109
+ }
6110
+ function libBrOpen() {
6111
+ var L = st.lib;
6112
+ var br = libBrGet();
6113
+ L.view = 'browse';
6114
+ L.err = '';
6115
+ L.notice = '';
6116
+ br.sel = '';
6117
+ br.detail = null;
6118
+ br.hist = null;
6119
+ br.checks = null;
6120
+ br.selErr = '';
6121
+ libBrPaint();
6122
+ if (!br.groups) libBrLoadGroups();
6123
+ }
6124
+ function libBrPaint() { if (st.tab === 'library') renderLibrary(); }
6125
+ /** 迟到回画守卫(与记忆树 / 导入面同款):标签切走、视图切走、或换了对象 ⇒ 整条丢弃,
6126
+ * **连清都不清** —— 先画错再撤回会闪一下,而且丢的是当前视图。 */
6127
+ function libBrAlive(br) {
6128
+ var L = st.lib;
6129
+ return st.tab === 'library' && L.view === 'browse' && L.br === br;
6130
+ }
6131
+ function libBrWhy(j, fallback) {
6132
+ if (!j) return fallback;
6133
+ return (j.message || j.reason || fallback);
6134
+ }
6135
+ function libBrLoadGroups() {
6136
+ var br = libBrGet();
6137
+ var gen = br.gen;
6138
+ br.loading = true;
6139
+ br.err = '';
6140
+ libBrPaint();
6141
+ api('/library/browse').then(function (j) {
6142
+ if (!libBrAlive(br) || gen !== br.gen) return;
6143
+ br.loading = false;
6144
+ if (!j || j.ok !== true) {
6145
+ br.err = '读取分组失败:' + libBrWhy(j, '网关没有回执');
6146
+ libBrPaint();
6147
+ return;
6148
+ }
6149
+ br.groups = Array.isArray(j.groups) ? j.groups : [];
6150
+ libBrPaint();
6151
+ }).catch(function () {
6152
+ if (!libBrAlive(br) || gen !== br.gen) return;
6153
+ br.loading = false;
6154
+ br.err = '读取分组失败(网关不可达)。';
6155
+ libBrPaint();
6156
+ });
6157
+ }
6158
+ function libBrNodeKey(cat, src) { return cat + '|' + src; }
6159
+ /** 取某一组(类目 + 来源)的一页。`replace` = 换页(替换列表);否则按 offset 追加。
6160
+ * `fields:'row'` 不是可选优化:**列表行不带正文** —— 单条正文上限 64 KiB,而一次响应总共
6161
+ * 只有 256 KiB ⇒ 200 条带正文必然被字节闸截成几条,"翻到底再加载"就名存实亡。
6162
+ * 正文由详情面按 id 单取(`/library/item`)。 */
6163
+ function libBrLoadNode(cat, src, offset, replace) {
6164
+ var br = libBrGet();
6165
+ var key = libBrNodeKey(cat, src);
6166
+ if (!br.node[key]) {
6167
+ br.node[key] = { items: [], offset: 0, nextOffset: null, truncated: false, loading: false, err: '', total: 0, mode: 'append' };
6168
+ }
6169
+ var nd = br.node[key];
6170
+ var gen = br.gen;
6171
+ var off = Math.max(0, offset || 0);
6172
+ nd.loading = true;
6173
+ nd.err = '';
6174
+ libBrPaint();
6175
+ api('/library/browse' + qs({ category: cat, source_kind: src, limit: BR_PAGE, offset: off, fields: 'row' })).then(function (j) {
6176
+ if (!libBrAlive(br) || gen !== br.gen) return;
6177
+ nd.loading = false;
6178
+ if (!j || j.ok !== true) {
6179
+ nd.err = '读取条目失败:' + libBrWhy(j, '网关没有回执');
6180
+ libBrPaint();
6181
+ return;
6182
+ }
6183
+ var got = Array.isArray(j.items) ? j.items : [];
6184
+ nd.items = (replace || off === 0) ? got : nd.items.concat(got);
6185
+ nd.offset = off;
6186
+ nd.nextOffset = (j.nextOffset === undefined ? null : j.nextOffset);
6187
+ nd.truncated = j.truncated === true;
6188
+ libBrPaint();
6189
+ }).catch(function () {
6190
+ if (!libBrAlive(br) || gen !== br.gen) return;
6191
+ nd.loading = false;
6192
+ nd.err = '读取条目失败(网关不可达)。';
6193
+ libBrPaint();
6194
+ });
6195
+ }
6196
+ /** 渐进模式:续下一页(追加)。 */
6197
+ function libBrMore(cat, src) {
6198
+ var br = libBrGet();
6199
+ var nd = br.node[libBrNodeKey(cat, src)];
6200
+ if (!nd || nd.loading || nd.nextOffset === null) return;
6201
+ libBrLoadNode(cat, src, nd.nextOffset, false);
6202
+ }
6203
+ /** 分页模式:跳到第 page 页(替换当前页)。 */
6204
+ function libBrGoPage(cat, src, page) {
6205
+ var br = libBrGet();
6206
+ var nd = br.node[libBrNodeKey(cat, src)];
6207
+ if (!nd || nd.loading) return;
6208
+ var pages = Math.max(1, Math.ceil((nd.total || 0) / BR_PAGE));
6209
+ var p = Math.min(pages, Math.max(1, page));
6210
+ libBrLoadNode(cat, src, (p - 1) * BR_PAGE, true);
6211
+ }
6212
+ /** 滚到底自动续页(与记忆浏览同款:距底 170px 内就续)。
6213
+ * 树里可能同时展开好几组 ⇒ 一次只给"还没取完的第一组"续一页,不一口气砸出一串请求;
6214
+ * 分页模式的组不在此列(它要人点页码 —— 正是为了不累积)。 */
6215
+ function libBrAutoMore(box) {
6216
+ if (!box || !box.scrollHeight) return;
6217
+ if (box.scrollTop + box.clientHeight < box.scrollHeight - 170) return;
6218
+ var br = libBrGet();
6219
+ var keys = Object.keys(br.node);
6220
+ for (var i = 0; i < keys.length; i++) {
6221
+ var nd = br.node[keys[i]];
6222
+ if (nd.mode === 'page' || nd.loading || nd.nextOffset === null) continue;
6223
+ var parts = keys[i].split('|');
6224
+ libBrLoadNode(parts[0], parts[1], nd.nextOffset, false);
6225
+ return;
6226
+ }
6227
+ }
6228
+ /** 选中一条:三个只读面并行取(条目本体 / 谱系链 / 核对史)。三条各自独立落地,
6229
+ * 谁先回来先显示 —— 但不允许"旧选中"的回执盖到新选中上(比 `sel` 令牌)。 */
6230
+ function libBrSelect(id) {
6231
+ var br = libBrGet();
6232
+ var gen = br.gen;
6233
+ br.sel = id;
6234
+ br.detail = null;
6235
+ br.hist = null;
6236
+ br.checks = null;
6237
+ br.selErr = '';
6238
+ br.selLoading = true;
6239
+ libBrPaint();
6240
+ api('/library/item' + qs({ id: id })).then(function (j) {
6241
+ if (!libBrAlive(br) || gen !== br.gen || br.sel !== id) return;
6242
+ br.selLoading = false;
6243
+ if (!j || j.ok !== true || !j.found) {
6244
+ br.selErr = '读取条目失败:' + libBrWhy(j, '网关没有回执');
6245
+ libBrPaint();
6246
+ return;
6247
+ }
6248
+ br.detail = j.item || null;
6249
+ libBrPaint();
6250
+ }).catch(function () {
6251
+ if (!libBrAlive(br) || gen !== br.gen || br.sel !== id) return;
6252
+ br.selLoading = false;
6253
+ br.selErr = '读取条目失败(网关不可达)。';
6254
+ libBrPaint();
6255
+ });
6256
+ api('/library/history' + qs({ id: id })).then(function (j) {
6257
+ if (!libBrAlive(br) || gen !== br.gen || br.sel !== id) return;
6258
+ br.hist = (j && j.ok) ? j : null;
6259
+ libBrPaint();
6260
+ }).catch(function () {});
6261
+ api('/library/checks' + qs({ id: id })).then(function (j) {
6262
+ if (!libBrAlive(br) || gen !== br.gen || br.sel !== id) return;
6263
+ br.checks = (j && j.ok) ? j : null;
6264
+ libBrPaint();
6265
+ }).catch(function () {});
6266
+ }
6267
+ /** 类目层。计数**只**来自 groups(恒定全量);空类目不展开、也不假装能点。 */
6268
+ function libBrCatNode(br, cat) {
6269
+ var box = el('div', null);
6270
+ var gs = (br.groups || []).filter(function (g) { return g.category === cat; });
6271
+ var count = 0;
6272
+ for (var i = 0; i < gs.length; i++) count += Number(gs[i].count || 0);
6273
+ // 默认**展开有内容的类目**:骨架(`groups`)是一次取回的全量表,展开它不花任何请求,
6274
+ // 而"两级都收起"会让第一次进来的人面对四个 ▶ 不知道点哪儿。真正的懒加载在**来源组**
6275
+ // 那一层(那才是会发请求的层)。空类目照样是死的(见下面的 count 分支)。
6276
+ var open = br.catOpen[cat] !== false;
6277
+ var head = el('div', 'lib-br-head', (open ? '▼ ' : '▶ ') + libMap(LIB_CAT, cat, cat) + '(' + count + ' 条)');
6278
+ head.style.cssText = 'padding:3px 4px;font-weight:700;' + (count ? 'cursor:pointer;' : 'opacity:.45;');
6279
+ if (count) {
6280
+ head.onclick = function () { br.catOpen[cat] = !open; libBrPaint(); };
6281
+ } else {
6282
+ head.title = '这个类目下还没有资料。';
6283
+ }
6284
+ box.appendChild(head);
6285
+ if (!open || !count) return box;
6286
+ for (var j = 0; j < gs.length; j++) box.appendChild(libBrSrcNode(br, cat, gs[j]));
6287
+ return box;
6288
+ }
6289
+ /** 来源层(叶子组)。展开 = 取这一组第一页;收起 = 丢掉缓存(下次展开重新取,宁可新不要旧)。 */
6290
+ function libBrSrcNode(br, cat, g) {
6291
+ var box = el('div', null);
6292
+ box.style.cssText = 'margin-left:14px;';
6293
+ var key = libBrNodeKey(cat, g.source_kind);
6294
+ var nd = br.node[key];
6295
+ var head = el('div', 'lib-br-head', (nd ? '▼ ' : '▶ ') + libMap(LIB_SRC, g.source_kind, g.source_kind)
6296
+ + '(' + g.count + ' 条 · 有效 ' + g.active + ')');
6297
+ head.style.cssText = 'padding:2px 4px;cursor:pointer;opacity:.92;';
6298
+ head.onclick = function () {
6299
+ if (nd) { delete br.node[key]; libBrPaint(); return; }
6300
+ var total = Number(g.count || 0);
6301
+ br.node[key] = {
6302
+ items: [], offset: 0, nextOffset: null, truncated: false, loading: false, err: '',
6303
+ total: total,
6304
+ // 装载策略在**展开这一刻**就定下来:总量超过阈值就一路走页码,不再累积。
6305
+ mode: total > BR_PAGE_SWITCH ? 'page' : 'append',
6306
+ };
6307
+ libBrPaint();
6308
+ libBrLoadNode(cat, g.source_kind, 0, true);
6309
+ };
6310
+ box.appendChild(head);
6311
+ if (!nd) return box;
6312
+ if (nd.err) box.appendChild(el('div', 'lib-err', nd.err));
6313
+ if (nd.loading && !nd.items.length) box.appendChild(el('div', 'm-meta', '正在读取这一组…'));
6314
+ for (var i = 0; i < nd.items.length; i++) box.appendChild(libBrLeaf(br, nd.items[i]));
6315
+ if (!nd.items.length && !nd.loading && !nd.err) box.appendChild(el('div', 'm-meta', '这一组这一页没有条目。'));
6316
+ var shown = nd.items.length;
6317
+ if (nd.mode === 'page') {
6318
+ // 分页模式:总量太大 ⇒ 只留当前页(替换,不累积)
6319
+ var pages = Math.max(1, Math.ceil((nd.total || 0) / BR_PAGE));
6320
+ var cur = Math.floor(nd.offset / BR_PAGE) + 1;
6321
+ var bar = el('div', 'm-acts lib-br-page');
6322
+ var bPrev = el('button', null, '‹ 上一页');
6323
+ var bNext = el('button', null, '下一页 ›');
6324
+ bPrev.disabled = !!nd.loading || cur <= 1;
6325
+ bNext.disabled = !!nd.loading || cur >= pages;
6326
+ bPrev.onclick = function () { libBrGoPage(cat, g.source_kind, cur - 1); };
6327
+ bNext.onclick = function () { libBrGoPage(cat, g.source_kind, cur + 1); };
6328
+ bar.appendChild(bPrev);
6329
+ bar.appendChild(el('span', 'm-meta', '第 ' + cur + ' / ' + pages + ' 页 · 本页 ' + shown
6330
+ + ' 条 · 这一组 ' + (nd.total || 0) + ' 条'));
6331
+ bar.appendChild(bNext);
6332
+ box.appendChild(bar);
6333
+ } else if (nd.nextOffset !== null) {
6334
+ // 渐进模式:往下滚到底自动续页;这里给一个明确的按钮当兜底(也让人知道还有多少没看)
6335
+ var acts = el('div', 'm-acts lib-br-sentinel');
6336
+ var bMore = el('button', null, nd.loading ? '读取中…' : '继续加载 ▾');
6337
+ bMore.disabled = !!nd.loading;
6338
+ bMore.onclick = function () { libBrMore(cat, g.source_kind); };
6339
+ acts.appendChild(bMore);
6340
+ acts.appendChild(el('span', 'm-meta', '已显示 ' + shown + ' / ' + (nd.total || 0)
6341
+ + ' 条(往下滚到底会自动继续加载)'));
6342
+ box.appendChild(acts);
6343
+ } else if (shown) {
6344
+ box.appendChild(el('div', 'm-meta lib-br-sentinel', '— 已到最早 —(已显示 ' + shown + ' / ' + (nd.total || 0) + ' 条)'));
6345
+ }
6346
+ return box;
6347
+ }
6348
+ function libBrLeaf(br, it) {
6349
+ var row = el('div', 'lib-br-leaf');
6350
+ row.style.cssText = 'margin-left:16px;padding:3px 5px;border-radius:8px;cursor:pointer;'
6351
+ + (br.sel === it.id ? 'background:rgba(127,127,127,.18);' : '');
6352
+ var t = el('div', null, libShow(it.title));
6353
+ t.style.cssText = 'font-size:13px;';
6354
+ row.appendChild(t);
6355
+ var chips = el('div', null);
6356
+ chips.appendChild(el('span', 'lib-chip', 'r' + libShow(it.revision)));
6357
+ chips.appendChild(el('span', 'lib-chip', libMap(LIB_ST, it.state, it.state)));
6358
+ chips.appendChild(el('span', 'lib-chip', '认证:' + libMap(LIB_CK, it.checked_state, '未核对')));
6359
+ if (it.import_key) chips.appendChild(el('span', 'lib-chip', '导入的'));
6360
+ row.appendChild(chips);
6361
+ row.onclick = function () { libBrSelect(it.id); };
6362
+ return row;
6363
+ }
6364
+ function libBrDetail(box, br) {
6365
+ box.appendChild(el('div', 'm-title', '详情(这一面只读)'));
6366
+ if (br.selErr) { box.appendChild(el('div', 'lib-err', br.selErr)); return; }
6367
+ if (!br.sel) {
6368
+ box.appendChild(el('div', 'm-meta', '点左边任意一条 —— 这里显示它的字段、谱系链(历史版本)与核对史。'));
6369
+ return;
6370
+ }
6371
+ if (br.selLoading && !br.detail) { box.appendChild(el('div', 'm-empty', '正在读取这条…')); return; }
6372
+ var it = br.detail;
6373
+ if (!it) return;
6374
+ var card = el('div', 'lib-card');
6375
+ card.appendChild(el('div', 'm-title', libShow(it.title)));
6376
+ var chips = el('div', null);
6377
+ chips.appendChild(el('span', 'lib-chip', libMap(LIB_CAT, it.category, it.category)));
6378
+ chips.appendChild(el('span', 'lib-chip', libMap(LIB_SRC, it.source_kind, it.source_kind)));
6379
+ chips.appendChild(el('span', 'lib-chip', '状态:' + libMap(LIB_ST, it.state, it.state)));
6380
+ chips.appendChild(el('span', 'lib-chip', '认证:' + libMap(LIB_CK, it.checked_state, '未核对')));
6381
+ card.appendChild(chips);
6382
+ var grid = el('div', 'lib-grid');
6383
+ libKV(grid, '出处 source_ref', it.source_ref, true);
6384
+ libKV(grid, '取数时间 as_of', it.as_of);
6385
+ libKV(grid, '修订 revision', 'r' + libShow(it.revision)
6386
+ + (it.previous_id ? (' · 上一版 ' + it.previous_id) : ' · 初版'));
6387
+ libKV(grid, 'content_hash', it.content_hash, true);
6388
+ libKV(grid, 'import_key', it.import_key ? it.import_key : '(无 —— 不是导入进来的)', true);
6389
+ libKV(grid, '认证时间', libShow(it.checked_at));
6390
+ libKV(grid, 'id', it.id, true);
6391
+ libKV(grid, 'lineage_id', it.lineage_id, true);
6392
+ libKV(grid, 'created_at', it.created_at);
6393
+ libKV(grid, 'updated_at', it.updated_at);
6394
+ if (it.withdrawn_at) libKV(grid, '撤下', it.withdrawn_at + ' · 原因:' + libShow(it.withdrawn_reason));
6395
+ card.appendChild(grid);
6396
+ card.appendChild(el('div', 'm-meta', '正文(body)——纯文本渲染,不解释 HTML'));
6397
+ card.appendChild(el('div', 'lib-pre', libShow(it.body)));
6398
+ box.appendChild(card);
6399
+ var hCard = el('div', 'lib-card');
6400
+ hCard.appendChild(el('div', 'm-title', '谱系链'));
6401
+ var hd = br.hist;
6402
+ if (!hd) hCard.appendChild(el('div', 'm-meta', '正在读取谱系链…'));
6403
+ else {
6404
+ var revs = Array.isArray(hd.revisions) ? hd.revisions : [];
6405
+ hCard.appendChild(el('div', 'm-meta', '本谱系共 ' + libShow(hd.revision_count) + ' 个版本(有效 '
6406
+ + libShow(hd.counts && hd.counts.active) + ' / 已取代 ' + libShow(hd.counts && hd.counts.superseded)
6407
+ + ' / 已撤下 ' + libShow(hd.counts && hd.counts.withdrawn) + ')'));
6408
+ for (var i = 0; i < revs.length; i++) {
6409
+ var rv = revs[i];
6410
+ hCard.appendChild(el('div', 'm-meta lib-mono', 'r' + libShow(rv.revision)
6411
+ + (rv.is_current ? ' (当前)' : '') + ' · ' + libMap(LIB_ST, rv.state, rv.state)
6412
+ + ' · 认证 ' + libShow(rv.checked_state) + ' · 核对 ' + libShow(rv.check_count)
6413
+ + ' 次 · ' + libShow(rv.created_at)));
6414
+ }
6415
+ if (!revs.length) hCard.appendChild(el('div', 'm-meta', '没有版本。'));
6416
+ }
6417
+ box.appendChild(hCard);
6418
+ var cCard = el('div', 'lib-card');
6419
+ cCard.appendChild(el('div', 'm-title', '核对史'));
6420
+ var ck = br.checks;
6421
+ if (!ck) cCard.appendChild(el('div', 'm-meta', '正在读取核对史…'));
6422
+ else {
6423
+ var crows = Array.isArray(ck.checks) ? ck.checks : [];
6424
+ cCard.appendChild(el('div', 'm-meta', '共 ' + libShow(ck.total) + ' 次(这一页 ' + crows.length + ' 条)'));
6425
+ for (var k = 0; k < crows.length; k++) {
6426
+ var cr = crows[k];
6427
+ cCard.appendChild(el('div', 'm-meta lib-mono', '第 ' + libShow(cr.check_sequence) + ' 次 · '
6428
+ + libMap(LIB_CK, cr.state, cr.state) + ' · ' + libShow(cr.checked_at)
6429
+ + ' · adopted=' + (cr.adopted ? 'true' : 'false')
6430
+ + (cr.reason ? (' · reason=' + cr.reason) : '')));
6431
+ }
6432
+ if (!crows.length) cCard.appendChild(el('div', 'm-meta', '还没有核对过这条。'));
6433
+ }
6434
+ box.appendChild(cCard);
6435
+ }
6436
+ function renderLibBrowse() {
6437
+ var L = st.lib;
6438
+ var br = libBrGet();
6439
+ var tools = el('div', 'm-tools');
6440
+ var bBack = el('button', null, '← 回到列表(维护面)');
6441
+ bBack.onclick = function () {
6442
+ L.view = 'list';
6443
+ L.err = '';
6444
+ L.notice = '';
6445
+ libBrPaint();
6446
+ };
6447
+ var bRe = el('button', null, '刷新');
6448
+ bRe.onclick = function () {
6449
+ // 刷新 = 换一代:在飞的旧回执全部作废(否则它们会把新骨架盖回旧的)。
6450
+ br.gen += 1;
6451
+ br.groups = null;
6452
+ br.node = {};
6453
+ br.catOpen = {};
6454
+ br.sel = '';
6455
+ br.detail = null;
6456
+ br.hist = null;
6457
+ br.checks = null;
6458
+ br.selErr = '';
6459
+ libBrPaint();
6460
+ libBrLoadGroups();
6461
+ };
6462
+ tools.appendChild(bBack);
6463
+ tools.appendChild(bRe);
6464
+ body.appendChild(tools);
6465
+ body.appendChild(el('div', 'm-meta',
6466
+ '浏览面 —— 只读:这一面没有任何写按钮(新建 / 修订 / 撤下 / 核对都在上一面的列表里)。'
6467
+ + '左边按「类目 → 来源」分组,展开哪一组才去取哪一组;右边是选中条目的字段、谱系链与核对史。'));
6468
+ if (br.err) body.appendChild(el('div', 'lib-err', br.err));
6469
+ if (!br.groups) {
6470
+ body.appendChild(el('div', 'm-empty', br.loading ? '正在读取分组…' : '还没有读到分组。'));
6471
+ return;
6472
+ }
6473
+ var wrap = el('div', null);
6474
+ wrap.style.cssText = 'display:flex;gap:10px;flex-wrap:wrap;align-items:flex-start;margin-top:6px;';
6475
+ var treeBox = el('div', null);
6476
+ // 重绘保持滚动位置(否则每次重建 DOM 都跳回顶部 —— 与记忆树同一条教训);
6477
+ // 同一条 scroll 上还挂着"滚到底续页"(与记忆浏览同款)。
6478
+ treeBox.onscroll = function () { br.scroll = treeBox.scrollTop; libBrAutoMore(treeBox); };
6479
+ treeBox.style.cssText = 'flex:1 1 320px;min-width:280px;border:1px solid var(--lg-border);'
6480
+ + 'border-radius:11px;padding:6px 8px;max-height:46vh;overflow:auto;';
6481
+ var cats = Object.keys(LIB_CAT);
6482
+ for (var i = 0; i < cats.length; i++) treeBox.appendChild(libBrCatNode(br, cats[i]));
6483
+ wrap.appendChild(treeBox);
6484
+ var detailBox = el('div', null);
6485
+ detailBox.style.cssText = 'flex:1 1 380px;min-width:300px;max-height:46vh;overflow:auto;';
6486
+ libBrDetail(detailBox, br);
6487
+ wrap.appendChild(detailBox);
6488
+ body.appendChild(wrap);
6489
+ body.appendChild(el('div', 'm-meta',
6490
+ '分组计数是服务端一次给全的真实计数;每组怎么取数:总量到 ' + BR_PAGE_SWITCH
6491
+ + ' 条就切成页码(只留当前页),否则一页 ' + BR_PAGE + ' 条、往下滚到底自动继续加载。'
6492
+ + '撤下与被取代的版本也会在树里出现 —— 留痕也是馆藏,靠每行那枚状态标签区分。'));
6493
+ treeBox.scrollTop = br.scroll || 0;
6494
+ }
6495
+
6496
+ function renderLibrary() {
6497
+ var L = st.lib;
6498
+ body.textContent = '';
6499
+ body.appendChild(libStatusBox());
6500
+ libLoadStatus(false);
6501
+ if (L.view === 'item') { renderLibItem(); return; }
6502
+ if (L.view === 'form') { renderLibForm(); return; }
6503
+ if (L.view === 'import') { renderLibImport(); return; }
6504
+ if (L.view === 'browse') { renderLibBrowse(); return; }
6505
+ renderLibList();
6506
+ }
6507
+
4131
6508
  function render() {
4132
6509
  if (st.tab === 'overview') renderOverview();
4133
6510
  else if (st.tab === 'browse') renderBrowse();
4134
6511
  else if (st.tab === 'tree') renderTree();
6512
+ else if (st.tab === 'library') renderLibrary();
6513
+ else if (st.tab === 'deep') renderDeep();
4135
6514
  else renderAccess();
4136
6515
  }
6516
+
6517
+ // ── 结论层页签(深层库条目)──────────────────────────────────────────────
6518
+ // 为什么要有这一面(2026-10-15,B 档,用户拍板):`deep_item` 411 条在面板里
6519
+ // **一条出口都没有** —— 开场注入、recall 检索、加固信号都在用它,而主人看不见
6520
+ // "我正在被注入什么"。他要"加一个只读页签",这一面就是它。
6521
+ // 为什么**只读**:落条与作废的判据是"**经主人授权**"(写在工具 `deep_item` 的描述里),
6522
+ // 面板不提供写入口 —— 少一个入口,就少一条"手滑落错一句结论、而结论直接影响开场"的路。
6523
+ function deepLoad(force) {
6524
+ var D = st.deep;
6525
+ if (D.busy) return;
6526
+ if (D.data && !force) return;
6527
+ // 代次令牌:取号在**发请求之前**,这一号就代表"这一发请求"。
6528
+ // · 应答迟到(切了筛选 / 面板重开之后才回来)⇒ 号对不上,整条丢弃:不写 data、不写 err、不重绘;
6529
+ // · **失败那支也要挡** —— 否则一个已作废的请求会在新筛选的视图上报一个不属于它的错。
6530
+ var token = ++D.seq;
6531
+ D.busy = true;
6532
+ D.err = '';
6533
+ var q = '/deep/items?limit=' + D.limit + (D.showRetired ? '&includeSuperseded=1' : '');
6534
+ api(q).then(function (r) {
6535
+ if (token !== D.seq) return; // 迟到的旧应答:丢弃(连"忙"都不动它 —— 那是新请求的状态)
6536
+ D.busy = false;
6537
+ if (!r || r.ok !== true) {
6538
+ D.err = '网关回了不带 ok 的应答';
6539
+ if (st.tab === 'deep') renderDeep();
6540
+ return;
6541
+ }
6542
+ D.data = r;
6543
+ if (st.tab === 'deep') renderDeep();
6544
+ }).catch(function () {
6545
+ if (token !== D.seq) return; // 同理:作废的请求失败回来,不许凭空报错
6546
+ D.busy = false;
6547
+ D.err = '网关不可达(宿主重启后 GUI cookie 会失效,刷新页面即可)';
6548
+ if (st.tab === 'deep') renderDeep();
6549
+ });
6550
+ }
6551
+ // 「可能过时」标记(2026-10-15,用户问「过时条目怎么弄」之后加):判据与工具 `tools/deep-import-docs.mjs`
6552
+ // 的 STALE_* 同源,**那边是权威**(这里只是把它摆到面板上)。WHY:结论层条目直接影响开场,
6553
+ // 含读数/时点状态的条过时了没人提醒 —— 让它可见,比在库里躺着强。
6554
+ function deepLooksStale(t) { return /(\d+\s*(ms|毫秒|秒|分钟|小时|天|周|个月|年|个会话|条|行|字节|KB|MB|GB|%|倍|次))|(当前|目前|现在|暂时|计划中|待做|待办|尚未|还没|即将|正在|下一步|先按|暂定)|(本次|这次|本轮|今天|昨天|今晚|刚才|明天|本周|这几天)/.test(String(t || '')); }
6555
+ function deepRow(it) {
6556
+ var row = el('div', 'm-row');
6557
+ row.appendChild(el('div', 'm-title', it.text));
6558
+ var chips = el('div', null);
6559
+ chips.appendChild(el('span', 'lib-chip', it.kind || '未分类'));
6560
+ chips.appendChild(el('span', 'lib-chip',
6561
+ it.durability === 'ephemeral' ? '硬度:ephemeral(不进开场)' : ('硬度:' + (it.durability || '—'))));
6562
+ if (it.durability !== 'ephemeral' && deepLooksStale(it.text)) chips.appendChild(el('span', 'lib-chip', '可能过时'));
6563
+ if (Number(it.pinned)) chips.appendChild(el('span', 'lib-chip', '置顶'));
6564
+ chips.appendChild(el('span', 'lib-chip', '加固 ' + Number(it.hit_count || 0)));
6565
+ if (it.superseded_by) {
6566
+ chips.appendChild(el('span', 'lib-chip', it.superseded_by === 'retracted' ? '已作废' : '已被取代'));
6567
+ }
6568
+ row.appendChild(chips);
6569
+ row.appendChild(el('div', 'm-meta lib-mono',
6570
+ it.id + ' · 来源 ' + (it.conv_id || it.src || '—')
6571
+ + (it.at ? (' · ' + it.at) : '')
6572
+ + (it.branch_id ? (' · 气根 ' + it.branch_id) : '')));
6573
+ return row;
6574
+ }
6575
+ function renderDeep() {
6576
+ var D = st.deep;
6577
+ body.textContent = '';
6578
+ var tools = el('div', 'm-tools');
6579
+ var bR = el('button', null, D.busy ? '读取中…' : '刷新');
6580
+ bR.disabled = !!D.busy;
6581
+ bR.onclick = function () { deepLoad(true); };
6582
+ var bAll = el('button', null, D.showRetired ? '只看在役' : '连已退役一起看');
6583
+ // 换筛选 = **作废在飞的那一发**(D.seq++)、放开忙标志,再按新筛选重发。
6584
+ // 只翻 showRetired 是不够的:老请求还在飞时 `if (D.busy) return;` 会把这次"按新筛选重发"
6585
+ // **挡回**,于是老应答回来写的是**旧筛选**的数据,而按钮文字已经是新状态 ⇒ 列表与按钮相反。
6586
+ bAll.onclick = function () {
6587
+ D.showRetired = !D.showRetired; D.data = null;
6588
+ D.seq++; D.busy = false; // 作废在飞的那一发(否则下面这发会被忙标志挡回)
6589
+ deepLoad(true);
6590
+ if (st.tab === 'deep') renderDeep(); // 立刻按新筛选重画:不留"按钮已翻、列表还是旧的"空窗
6591
+ };
6592
+ tools.appendChild(bR);
6593
+ tools.appendChild(bAll);
6594
+ body.appendChild(tools);
6595
+ if (D.err) body.appendChild(el('div', 'lib-err', D.err));
6596
+ deepLoad(false);
6597
+ var d = D.data;
6598
+ if (!d) { body.appendChild(el('div', 'm-empty', '正在读结论层…')); return; }
6599
+ // ⚠️ 两个计数**分开报**:把退役的算进"有多少条结论"会让人以为它们还在参与开场选择。
6600
+ body.appendChild(el('div', 'm-meta',
6601
+ '在役 ' + libShow(d.all) + ' 条 · 已退役 ' + libShow(d.retired)
6602
+ + ' 条(退役 = 被取代或已作废:不再参与开场选择与检索排序,但留痕留在库里)'
6603
+ + ' —— 这一面纯读:落条与作废走工具,面板里不写库。'));
6604
+ var items = d.items || [];
6605
+ if (!items.length) {
6606
+ body.appendChild(el('div', 'm-empty', D.showRetired
6607
+ ? '结论层是空的。'
6608
+ : ('在役条目为空(退役的还有 ' + libShow(d.retired) + ' 条,点上面那枚按钮一起看)。')));
6609
+ return;
6610
+ }
6611
+ for (var i = 0; i < items.length; i++) body.appendChild(deepRow(items[i]));
6612
+ body.appendChild(el('div', 'm-meta',
6613
+ '列出 ' + items.length + ' 条(上限 ' + D.limit + ')。条目是装配单元:'
6614
+ + '开场与新会话按加权从这里挑;加固只在"真被采用"时 +1(被注入不算,否则会自我强化)。'));
6615
+ }
6616
+
4137
6617
  render();
4138
6618
  }
4139
6619
 
@@ -4520,7 +7000,7 @@
4520
7000
  var label = rail
4521
7001
  ? (auditBad ? '⚠' : (mode === 'work' ? '⚙' : '☾'))
4522
7002
  : ((auditBad ? '⚠ ' : '') + (mode === 'work' ? '⚙ 工作' : '☾ 生活'));
4523
- // ⚠️ 原生 `title` 只留**一行**(2026-09-27 尝生反馈:原来四行太罗嗦,且浏览器 tooltip 不会自动
7003
+ // ⚠️ 原生 `title` 只留**一行**(2026-09-27 用户反馈:原来四行太罗嗦,且浏览器 tooltip 不会自动
4524
7004
  // 消失,悬停 1.5s 菜单一开就和它叠成两层)。完整提醒改走自绘气泡 `showTip` ——
4525
7005
  // 悬停即现、3s 自隐、菜单一开就收,见 `tipText`。
4526
7006
  var title = (isGlobal
@@ -4550,7 +7030,7 @@
4550
7030
  if (tipText) showTip(btnRef && btnRef.current, tipText);
4551
7031
  hoverTimer.current = setTimeout(function () {
4552
7032
  hoverTimer.current = null;
4553
- hideTip(); // 菜单与气泡永不同屏(尝生反馈的"窗口叠加"就是这两层)
7033
+ hideTip(); // 菜单与气泡永不同屏(用户反馈的"窗口叠加"就是这两层)
4554
7034
  var node = btnRef && btnRef.current;
4555
7035
  openMenuNear(node ? node.getBoundingClientRect() : null, store);
4556
7036
  }, HOVER_MS);
@@ -4626,7 +7106,7 @@
4626
7106
  return out.join('\n');
4627
7107
  }
4628
7108
 
4629
- /** 复盘解锁:跨面板共享的真值 —— 记忆中心与右侧复盘栏读同一个变量(尝生 2026-09-21)。
7109
+ /** 复盘解锁:跨面板共享的真值 —— 记忆中心与右侧复盘栏读同一个变量(用户 2026-09-21)。
4630
7110
  * 「复盘模式」不是一个 UI 开关,而是"左会话 + 右侧栏"这个状态;树编辑解锁只是它的附属。*/
4631
7111
  var lingUnlocked = false;
4632
7112
  /** 解锁状态订阅者(2026-09-22 审计 C#3):侧栏是 React 组件,useState 只在**首次渲染**取值,
@@ -4660,7 +7140,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4660
7140
  /**
4661
7141
  * 复制文本(clipboard 不可用时回退 execCommand)。
4662
7142
  * silent=true 时不再单独 toast「已复制」—— 调用方自己会报一句完整的结果,
4663
- * 否则会出现两个 toast 叠加(2026-09-22 尝生实测:『已复制复盘开场白 —— 已复制 …』)。
7143
+ * 否则会出现两个 toast 叠加(2026-09-22 用户实测:『已复制复盘开场白 —— 已复制 …』)。
4664
7144
  */
4665
7145
  function reviewCopy(text, silent) {
4666
7146
  // 审计 C#13:改成**回报是否成功**(Promise) —— clipboard 是异步的,只有 `.then` 才知道结果;
@@ -4773,7 +7253,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4773
7253
  if (i >= 0) lingUnlockSubs.splice(i, 1);
4774
7254
  };
4775
7255
  }, []);
4776
- // 选中态统一(2026-09-22 尝生):单选与多选共用 s8 的集合,selId 由它派生。
7256
+ // 选中态统一(2026-09-22 用户):单选与多选共用 s8 的集合,selId 由它派生。
4777
7257
  // 原先 selId(蓝) 与 dragIds(橙) 是两套状态,表现为"一蓝一橙"且拖动只有橙的会动。
4778
7258
  var refName = React.useRef(null);
4779
7259
  var refRep = React.useRef(null);
@@ -4788,7 +7268,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4788
7268
  var setSnapshots = s5[1];
4789
7269
  var refSnap = React.useRef(null);
4790
7270
  // R2 会话级展开:哪些枝展开了、该枝的成员缓存(懒加载)
4791
- // C(2026-09-25 尝生):此前 openId 是**单个字符串** ⇒ 打开一个就把上一个关掉(独占式)。
7271
+ // C(2026-09-25 用户):此前 openId 是**单个字符串** ⇒ 打开一个就把上一个关掉(独占式)。
4792
7272
  // 复盘的场景恰恰是"同时对照几条枝",独占等于逼人来回点。改用集合(每节点独立),
4793
7273
  // 可同时展开多个;Shift+C 一键全折叠(见下方 keydown effect)。
4794
7274
  var s6 = React.useState({});
@@ -4815,7 +7295,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4815
7295
  // 否则落库前的撤回信息里记不到这一项 ⇒ Ctrl+Z 报「已撤回:恢复了 0 项的位置」。
4816
7296
  // 用 ref 而不是 state:拖拽期间不需要重渲染,drop 时读最新值即可。
4817
7297
  var refDragFrom = React.useRef('');
4818
- // 滚动位置自保(2026-09-22 尝生:点主脉级会跳回顶部)
7298
+ // 滚动位置自保(2026-09-22 用户:点主脉级会跳回顶部)
4819
7299
  // 点选节点会触发整块重渲染,内容高度变化时浏览器会把视口弹回顶部;
4820
7300
  // 故实时记录 scrollTop,并在每次渲染后恢复 —— 与记忆中心 st.treeScroll 同思路。
4821
7301
  var boxRef = React.useRef(null);
@@ -4826,7 +7306,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4826
7306
  });
4827
7307
  // 编辑区只在"恰好选中一个"时生效(多选时显示提示)
4828
7308
  var selId = (dragIds.length === 1) ? dragIds[0] : '';
4829
- // 复盘守门(2026-09-22 尝生:「互相复盘不应该被我绕过,动记忆树不能是随意的」):
7309
+ // 复盘守门(2026-09-22 用户:「互相复盘不应该被我绕过,动记忆树不能是随意的」):
4830
7310
  // 记录"自器灵上次开口以来累积了多少笔结构改动还没交代"。未同步时:
4831
7311
  // ① 面板顶部红字提示 ② **拦住"退出复盘模式"** —— 改完树必须让器灵有机会看到。
4832
7312
  var s10 = React.useState(0);
@@ -4845,7 +7325,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4845
7325
  var veinSugs = s11[0];
4846
7326
  var setVeinSugs = s11[1];
4847
7327
  // 审计 E10(1.5.1 红蓝对抗):「这条主脉**已经**有主脉记忆了」是**采纳前**就该知道的事。
4848
- // 后端对同主脉的第二次采纳做的是"先把旧版归档、再覆盖"(尝生 2026-09-25 裁决),可界面
7328
+ // 后端对同主脉的第二次采纳做的是"先把旧版归档、再覆盖"(用户 2026-09-25 裁决),可界面
4849
7329
  // 此前对"将要覆盖"零提示 ⇒ 唯一的发现途径是事后去翻「最近改动」。
4850
7330
  // 判据不靠后端加字段:`conv_overview.source='vein'` 的 `conv_id` **就是主脉 id**
4851
7331
  //(memory.js:1821 写入时 `conv_id: veinId`),所以查一次 `?source=vein` 就知道
@@ -4909,7 +7389,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
4909
7389
  }, 8000);
4910
7390
  return function () { clearInterval(t); };
4911
7391
  }, []);
4912
- // Shift+C:一键折叠所有已展开的枝(2026-09-25 尝生定,暂定键位)。
7392
+ // Shift+C:一键折叠所有已展开的枝(2026-09-25 用户定,暂定键位)。
4913
7393
  // ⚠️ 位置纪律:必须落在**本函数所有 early return 之前**(下方有 `if (!data) return …`)。
4914
7394
  // React 要求每次渲染的 hook 数量与顺序恒定;把 useEffect 放在 early return 之后会出现
4915
7395
  // "首次渲染 data 为空 → 提前 return(少一个 hook) → 数据到达后重渲染多一个 hook"
@@ -5206,7 +7686,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5206
7686
  userSelect: 'none', WebkitUserSelect: 'none',
5207
7687
  // 选中色只有一种(琥珀) —— 单选与 Ctrl 多选视觉一致,避免"一蓝一橙"
5208
7688
  background: on ? 'rgba(197,139,47,.22)' : 'transparent',
5209
- // 拖动悬停目标:青色虚线框,松手前就知道会落到哪(2026-09-22 尝生)
7689
+ // 拖动悬停目标:青色虚线框,松手前就知道会落到哪(2026-09-22 用户)
5210
7690
  outline: (dropTarget === x.n.id) ? '2px dashed #3fb9a6'
5211
7691
  : (on ? '1px solid rgba(197,139,47,.5)' : 'none'),
5212
7692
  outlineOffset: '-2px',
@@ -5221,7 +7701,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5221
7701
  },
5222
7702
  style: { display: 'inline-block', width: '12px', opacity: hasKids ? 0.75 : 0.25 },
5223
7703
  }, hasKids ? (isOpen ? '▾' : '▸') : '·'),
5224
- // A(2026-09-25 尝生):主脉与枝此前在列表里长得一模一样,只能靠"点开会显示 [主脉]"
7704
+ // A(2026-09-25 用户):主脉与枝此前在列表里长得一模一样,只能靠"点开会显示 [主脉]"
5225
7705
  // 分辨 —— 而「零散记录」(枝) 与「零散会话」(主脉) 这类名字只差一个字,必然选错。
5226
7706
  // 现在行首给标记:🌱 主干 / ◆ 主脉 / · 枝。
5227
7707
  (x.n.kind === 'trunk' ? '🌱 ' : (x.n.kind === 'vein' ? '◆ ' : '· ')) + (x.n.name || x.n.id)
@@ -5290,7 +7770,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5290
7770
  paddingLeft: ((x.d + 1) * 12 + 12) + 'px', fontSize: '11px', opacity: 0.85,
5291
7771
  cursor: 'grab', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
5292
7772
  userSelect: 'none', WebkitUserSelect: 'none', borderRadius: '5px',
5293
- // 会话级也要有选中可视化(2026-09-22 尝生:会话能拖但看不出选没选中)
7773
+ // 会话级也要有选中可视化(2026-09-22 用户:会话能拖但看不出选没选中)
5294
7774
  background: con ? 'rgba(197,139,47,.22)' : 'transparent',
5295
7775
  outline: (dropTarget === cid) ? '2px dashed #3fb9a6'
5296
7776
  : (con ? '1px solid rgba(197,139,47,.5)' : 'none'),
@@ -5465,7 +7945,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5465
7945
  .catch(function (e) { toast('✗ 提炼失败:' + ((e && e.message) || '未知')); });
5466
7946
  };
5467
7947
  var resolveSug = function (id, action, text) {
5468
- // 尝生 2026-09-25 裁决(乙):**提炼不锁、采纳要解锁**。
7948
+ // 用户 2026-09-25 裁决(乙):**提炼不锁、采纳要解锁**。
5469
7949
  // 「提炼」只往候选表写一行草稿,不碰记忆 ⇒ 不锁(锁它会抬启动成本);
5470
7950
  // 「采纳」会写 conv_overview(source='vein') + assignConv 改树归属 ⇒ 与改名/并脉同级,故要走同一道门。
5471
7951
  // 「驳回」不写任何东西 ⇒ 不锁。
@@ -5505,7 +7985,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5505
7985
  h('button', { key: 'b', type: 'button', onClick: function () { doDistill(selX.id); } }, '✦ 提炼主脉'),
5506
7986
  ]));
5507
7987
  } else if (selX && selX.kind === 'branch') {
5508
- // B(2026-09-25 尝生):枝上也要有提炼入口 —— 但提炼的素材是"主脉及其子枝",
7988
+ // B(2026-09-25 用户):枝上也要有提炼入口 —— 但提炼的素材是"主脉及其子枝",
5509
7989
  // 对枝本身提炼没有意义。所以这里先沿 parentId 往上找它所属的主脉,
5510
7990
  // 按钮直接对**那条主脉**做提炼,并在文案里说清对象,省掉"自己去列表里找主脉"这一跳。
5511
7991
  var vAnc = null;
@@ -5531,7 +8011,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5531
8011
  '✦ 提炼 —— 这条枝还没挂在主脉下;先并脉(挂到某条主脉),再对它提炼。')]));
5532
8012
  }
5533
8013
  // ---- R7 主脉提炼审核队列(2026-09-23) ----
5534
- // 2026-09-25 尝生:「提炼结果放在『主脉提炼』那一条底下」—— 审核队列紧跟提炼入口,
8014
+ // 2026-09-25 用户:「提炼结果放在『主脉提炼』那一条底下」—— 审核队列紧跟提炼入口,
5535
8015
  // 点完提炼往下看就是候选,不必滚到页面别处去找。
5536
8016
  // 设计稿 §5:提炼结果先入队、带派生来源;采纳后才写进主脉记忆,且可"改后采纳"(文本框里改完再点采纳)。
5537
8017
  if (veinSugs.length) {
@@ -5726,7 +8206,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5726
8206
  });
5727
8207
  }
5728
8208
  // ---- 最近改动 ----
5729
- // 2026-09-25 尝生:「『最近改动』块挪到『树的保存/恢复』底下」—— 它记的是"谁在什么时候
8209
+ // 2026-09-25 用户:「『最近改动』块挪到『树的保存/恢复』底下」—— 它记的是"谁在什么时候
5730
8210
  // 动了树",与上面那排快照是同一件事的两面(一份是存档,一份是流水),放一起才顺。
5731
8211
  if (data.log.length) {
5732
8212
  kids.push(h('div', { key: 'lh', style: { fontWeight: 700, margin: '9px 0 2px' } }, '最近改动'));
@@ -5767,7 +8247,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5767
8247
  style: unlocked ? { borderColor: '#c58b2f', color: '#c58b2f' } : {},
5768
8248
  onClick: function () {
5769
8249
  var next = !unlocked;
5770
- // 复盘守门(2026-09-22 尝生:「互相复盘不应该被我绕过」):退出前必须先把
8250
+ // 复盘守门(2026-09-22 用户:「互相复盘不应该被我绕过」):退出前必须先把
5771
8251
  // 未同步的改动交代给器灵 —— 不能"改完几十处直接走人",那等于假复盘。
5772
8252
  // 2026-09-23 修:显示值可能滞后(器灵开口才推进水位,而"发言"不触发本面板重载),
5773
8253
  // 所以退出前**实时查一次**服务端 —— 避免"明明已经同步了却拦着不让退"。
@@ -5793,7 +8273,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
5793
8273
  }
5794
8274
  setUnlocked(next);
5795
8275
  setLingUnlocked(next, true);
5796
- // 进入复盘模式 = 解锁 + 备好开场白(2026-09-22 尝生:行为与总控板「一键进入复盘」对齐)
8276
+ // 进入复盘模式 = 解锁 + 备好开场白(2026-09-22 用户:行为与总控板「一键进入复盘」对齐)
5797
8277
  // 审计 C#17:原来这里把 reviewCopy 当同步函数 —— 它返回 Promise,所以 try/catch
5798
8278
  // 根本抓不到复制失败,而且**不等结果**就先报「已复制」。改成等结果再说文案。
5799
8279
  if (next) {
@@ -6100,7 +8580,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
6100
8580
  // ⚠️ 只等 sidebarRightTabs —— 不要把 sidebarRight 也写进来:
6101
8581
  // 它是 per-session 绑定的控制器,在该注入作用域里可能永不满足,
6102
8582
  // 回调就永远不执行 ⇒ 面板类型注册不上 ⇒ openTab 抛
6103
- // 'sidebarRight: no tab type is registered as "ling-review"'(尝生实测)。
8583
+ // 'sidebarRight: no tab type is registered as "ling-review"'(用户实测)。
6104
8584
  // 打开面板时用 lingCtx.get('sidebarRight') 动态取即可(那时必然已挂载)。
6105
8585
  // 🔴 根因(2026-09-21 定位):服务必须从**回调参数**上取(scoped),
6106
8586
  // 不能用闭包里的 ctx —— cordis 的属性访问在未 inject 时抛
@@ -6149,14 +8629,17 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
6149
8629
  console.warn('[dsh-ling] client apply failed', e);
6150
8630
  }
6151
8631
  };
6152
- // ---- 测试钩子(仅供 tests/client-batch.test.mjs 直调真实实现)----
6153
- // 只挂纯函数与常量,无任何副作用;生产路径不读这里。
8632
+ // ---- 测试钩子(仅供 tests/client-batch.test.mjs、tests/library-client.test.mjs 直调真实实现)----
8633
+ // 只挂纯函数、常量与"面板入口",无任何副作用;生产路径不读这里。
8634
+ // `openMemoryCenter` 是给「图书馆」标签的**执行级**用例用的:测试侧用假 DOM 打开面板 → 点标签 →
8635
+ // 点按钮,走的是与真人点击**同一条**代码路径(不是源码正则护栏)。
6154
8636
  exports.__test__ = {
6155
8637
  takeChunk: takeChunk,
6156
8638
  utf8Len: utf8Len,
6157
8639
  fmtBytes: fmtBytes,
6158
8640
  BATCH_MAX_BYTES: BATCH_MAX_BYTES,
6159
8641
  BATCH_MAX_ITEMS: BATCH_MAX_ITEMS,
8642
+ openMemoryCenter: openMemoryCenter,
6160
8643
  };
6161
8644
  return module.exports;
6162
8645
  },