@nightpainters/dsh-ling 1.5.1 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ACCESS-DESIGN.md +10 -7
- package/CHANGELOG.md +140 -3
- package/DESIGN.md +162 -18
- package/README.md +66 -32
- package/lib/audit/access-log.js +181 -25
- package/lib/audit/index.js +45 -3
- package/lib/audit/patch-spec.js +1 -1
- package/lib/client.js +1183 -88
- package/lib/host/api.js +716 -83
- package/lib/host/backfill.js +8 -2
- package/lib/host/feedback.js +112 -2
- package/lib/host/freeze.js +52 -1
- package/lib/host/genesis.js +27 -5
- package/lib/host/guard.js +39 -2
- package/lib/host/habit-gen.js +12 -4
- package/lib/host/import-file.js +62 -6
- package/lib/host/import-log.js +80 -2
- package/lib/host/inject.js +49 -12
- package/lib/host/l1.js +11 -4
- package/lib/host/lifecycle.js +13 -2
- package/lib/host/memory.js +425 -33
- package/lib/host/mode-service.js +85 -0
- package/lib/host/mode.js +29 -9
- package/lib/host/persona.js +38 -7
- package/lib/host/retitle.js +6 -0
- package/lib/host/session-line.js +688 -0
- package/lib/host/snapshot.js +48 -7
- package/lib/host/summarizer.js +159 -3
- package/lib/host/surgery.js +600 -0
- package/lib/host/tools.js +12 -1
- package/lib/host/tree-ops.js +1 -1
- package/lib/index.js +22 -29
- package/package.json +51 -4
- package/tools/apply-access-log-patch.mjs +1 -1
package/lib/client.js
CHANGED
|
@@ -27,14 +27,126 @@
|
|
|
27
27
|
|
|
28
28
|
var API = '/api/dsh-ling';
|
|
29
29
|
|
|
30
|
+
// ---------------- 导入切批(纯函数,无副作用) ----------------
|
|
31
|
+
// S9(2026-09-29 导入批失败 4MiB):服务端 readBody() 按【字节】封顶 4MiB
|
|
32
|
+
// (lib/host/api.js:139),而旧客户端按【条数】切批(batch = 200)⇒ 两端判据不同单位:
|
|
33
|
+
// 186 条 / 4.89MB 被打成整批一发 ⇒ 超限 ⇒ 连接被 reset ⇒ 界面错报「批失败(网关)」。
|
|
34
|
+
// 现改为【条数 × 字节】双限:字节上限留 1MiB 余量给数组封套与 JSON 转义。
|
|
35
|
+
// ⚠️ 这两个函数必须留在 factory 作用域(不能塞进 bGo.onclick):V1 用例经
|
|
36
|
+
// tests/vm-light.mjs 的 contextify 加载本文件后,从 exports.__test__ 取名直调。
|
|
37
|
+
var BATCH_MAX_BYTES = 3 * 1024 * 1024; // 服务端 4 MiB;留 1 MiB 余量给封套与转义
|
|
38
|
+
var BATCH_MAX_ITEMS = 200; // 保留旧条数上限(小条目时行为与旧版一致)
|
|
39
|
+
// 精确 UTF-8 字节数。⚠️ 不许用 s.length 冒充(CJK 3 字节、emoji 代理对 4 字节),
|
|
40
|
+
// 也不许依赖 TextEncoder/TextDecoder —— 本文件要能在测试沙箱
|
|
41
|
+
// (tests/vm-light.mjs 的白名单式 sandbox,无 TextEncoder)里跑。
|
|
42
|
+
function utf8Len(s) {
|
|
43
|
+
var t = s === null || s === undefined ? '' : String(s);
|
|
44
|
+
var n = 0;
|
|
45
|
+
for (var i = 0; i < t.length; i++) {
|
|
46
|
+
var c = t.charCodeAt(i);
|
|
47
|
+
if (c < 0x80) n += 1;
|
|
48
|
+
else if (c < 0x800) n += 2;
|
|
49
|
+
else if (c >= 0xD800 && c <= 0xDBFF && i + 1 < t.length) {
|
|
50
|
+
var d = t.charCodeAt(i + 1);
|
|
51
|
+
if (d >= 0xDC00 && d <= 0xDFFF) { n += 4; i += 1; } // 代理对(emoji 等)
|
|
52
|
+
else n += 3; // 落单高代理 ⇒ U+FFFD(3 字节)
|
|
53
|
+
} else n += 3; // BMP 3 字节 / 落单低代理 ⇒ U+FFFD
|
|
54
|
+
}
|
|
55
|
+
return n;
|
|
56
|
+
}
|
|
57
|
+
// 字节 ⇒ 人读口径(C2 进度文案用)
|
|
58
|
+
function fmtBytes(n) {
|
|
59
|
+
var b = Number(n) || 0;
|
|
60
|
+
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
|
|
61
|
+
return (b / 1024 / 1024).toFixed(1) + ' MB';
|
|
62
|
+
}
|
|
63
|
+
/** 服务端**回执里的**单次上限 ⇒ 人话(B / 2026-09-29:413 文案要说清"上限是多少")。
|
|
64
|
+
* ⚠️ 数字**只许来自回执**(`r.limit`):客户端不镜像服务端的那个常量(C5 / C-1 的同一条命门 ——
|
|
65
|
+
* 两处各写一个"4 MB",将来服务端改上限时必漂移);回执没带就如实说"没给",绝不猜一个数顶上。 */
|
|
66
|
+
function fmtLimitMB(limit) {
|
|
67
|
+
var n = Number(limit);
|
|
68
|
+
if (!(n > 0)) return '上限未随回执返回';
|
|
69
|
+
return (Math.round(n / 1024 / 1024 * 10) / 10) + ' MB';
|
|
70
|
+
}
|
|
71
|
+
// 从 from 起切一批 ⇒ { list, next, bytes }。bytes 口径 = JSON.stringify(每条)的
|
|
72
|
+
// UTF-8 字节 + 1(数组逗号),外加 2(方括号)—— 与契约一致(偏保守:末条逗号也计入)。
|
|
73
|
+
// 前进性硬保证:首条即使自己就超 BATCH_MAX_BYTES 也单独成批,绝不返回空 list
|
|
74
|
+
// (空批 ⇒ 调用方 idx 不前进 ⇒ 死循环)。
|
|
75
|
+
function takeChunk(items, from) {
|
|
76
|
+
var list = [];
|
|
77
|
+
var bytes = 2; // 数组封套 []
|
|
78
|
+
var i = from;
|
|
79
|
+
while (i < items.length && list.length < BATCH_MAX_ITEMS) {
|
|
80
|
+
var n = 0;
|
|
81
|
+
try { n = utf8Len(JSON.stringify(items[i])); } catch (e) { n = 0; } // 循环引用等:交给服务端拒
|
|
82
|
+
var cost = n + 1; // 数组逗号
|
|
83
|
+
if (list.length && bytes + cost > BATCH_MAX_BYTES) break;
|
|
84
|
+
list.push(items[i]);
|
|
85
|
+
bytes += cost;
|
|
86
|
+
i += 1;
|
|
87
|
+
}
|
|
88
|
+
return { list: list, next: i, bytes: bytes };
|
|
89
|
+
}
|
|
90
|
+
|
|
30
91
|
// ---------------- 第三人称代词(跟随 persona.pronoun) ----------------
|
|
31
92
|
// 界面文案里写死的「她」全部经 she() 出口 —— 用户改成「他 / TA / 自定义」后文案随之改变;
|
|
32
93
|
// **只用于文案**,绝不用于用户原话或记忆数据(那会把数据改错)。
|
|
33
94
|
var UI_PRONOUN = '她';
|
|
34
|
-
|
|
95
|
+
// she() 每吐出一个含代词的文案,就把「产出值 → 原文模板」记进这张表 —— 它是"这段文字出自
|
|
96
|
+
// she() 出口、属文案而非数据"的**唯一凭据**。换代词时只按这张表逐字重算(见 refreshPronounTexts):
|
|
97
|
+
// 表里没有的文字(主人原话、记忆正文、会话标题、后端来的习惯/自述/改法说明)**一个字节都不动**。
|
|
98
|
+
// ⚠️ 必须是 null 原型:文案里若真出现 `constructor` / `__proto__` 这种键,普通对象会经原型链
|
|
99
|
+
// 拿回 Object.prototype 上的成员(同 ownGet :409 那个坑)⇒ 会把一个函数当成模板去 split。
|
|
100
|
+
var SHE_VALS = Object.create(null);
|
|
101
|
+
function she(s) {
|
|
102
|
+
var src = String(s);
|
|
103
|
+
var out = UI_PRONOUN === '她' ? s : src.split('她').join(UI_PRONOUN);
|
|
104
|
+
if (src.indexOf('她') >= 0) SHE_VALS[out] = src;
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/** 一段**已挂载**的文字 → 当前代词版本。认不出来(不在 SHE_VALS 里 = 不是 she() 写的)就
|
|
108
|
+
* **原样返回** —— 数据面永远走这条出口,所以"顺手把界面上的她全替换掉"这种写法不存在于本文件。 */
|
|
109
|
+
function sheRederive(t) {
|
|
110
|
+
var src = SHE_VALS[t];
|
|
111
|
+
if (src !== undefined) return she(src);
|
|
112
|
+
// 多行读数(join('\n') 拼出来的那一块):逐行认,**认不出的行原样留着**。
|
|
113
|
+
if (String(t).indexOf('\n') < 0) return t;
|
|
114
|
+
var ls = String(t).split('\n');
|
|
115
|
+
for (var i = 0; i < ls.length; i++) { if (SHE_VALS[ls[i]] !== undefined) ls[i] = she(SHE_VALS[ls[i]]); }
|
|
116
|
+
return ls.join('\n');
|
|
117
|
+
}
|
|
118
|
+
/** 递归重画一个已挂载节点里的文案。**只写 Text 节点(nodeType===3)的 nodeValue** ——
|
|
119
|
+
* 输入框/文本域的 .value、自绘下拉的选中态、表单控件一律不碰 ⇒ 主人正在输入的字符、
|
|
120
|
+
* 已选中的文本、光标位置都不可能被这次重画吃掉。 */
|
|
121
|
+
function sheRefreshNode(root) {
|
|
122
|
+
var kids = root && root.childNodes;
|
|
123
|
+
if (!kids) return;
|
|
124
|
+
for (var i = 0; i < kids.length; i++) {
|
|
125
|
+
var n = kids[i];
|
|
126
|
+
if (!n) continue;
|
|
127
|
+
if (n.nodeType === 3) {
|
|
128
|
+
var t = n.nodeValue;
|
|
129
|
+
var nt = sheRederive(t);
|
|
130
|
+
if (nt !== t) n.nodeValue = nt; // 内容真的变了才写(不变就连赋值都不做)
|
|
131
|
+
} else if (n.nodeType === 1) {
|
|
132
|
+
sheRefreshNode(n);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** 代词变了 ⇒ 让**当前已挂载**的、经 she() 出口写进去的界面文案就地重画(不是只给新节点写新值)。
|
|
137
|
+
* 只遍历浮层根节点:本文件全部代词文案都在 .dsh-ling-panel / .dsh-ling-menu 里,页面其余部分
|
|
138
|
+
* (会话正文、侧栏、Dock)本就不该被本插件改写。 */
|
|
139
|
+
function refreshPronounTexts() {
|
|
140
|
+
if (typeof document === 'undefined' || !document.querySelectorAll) return;
|
|
141
|
+
var roots = document.querySelectorAll('.dsh-ling-panel,.dsh-ling-menu');
|
|
142
|
+
for (var i = 0; i < roots.length; i++) sheRefreshNode(roots[i]);
|
|
143
|
+
}
|
|
35
144
|
function setPronounFrom(persona) {
|
|
36
145
|
var pn = persona && persona.pronoun ? String(persona.pronoun).slice(0, 6) : '';
|
|
37
|
-
|
|
146
|
+
// **值没变 ⇒ 一拍都不多做**:既不重画(每拍重画会把面板搞卡),也不惊动主人正在输入的内容。
|
|
147
|
+
if (!pn || pn === UI_PRONOUN) return;
|
|
148
|
+
UI_PRONOUN = pn;
|
|
149
|
+
refreshPronounTexts();
|
|
38
150
|
}
|
|
39
151
|
var NS = 'dsh-ling';
|
|
40
152
|
var STYLE_ID = 'dsh-ling-css';
|
|
@@ -50,13 +162,49 @@
|
|
|
50
162
|
return parts.length ? '?' + parts.join('&') : '';
|
|
51
163
|
}
|
|
52
164
|
|
|
165
|
+
// ── 手术门 · 本机通行票据(2026-09-30 深夜;规格 §1.2 的客户端那一半)──────────────
|
|
166
|
+
// 宿主每启动轮换一枚 32 字节票据;**只有本机直连**拿得到(带代理头的请求会被拒)。
|
|
167
|
+
// 这一半只做"取一次 + 缓存 + 随手术请求带上",**不动任何弹窗与文案结构**(Phase 1 的边界)。
|
|
168
|
+
// 取不到(经反代打开的页面 / 旧宿主)= 空串 ⇒ 手术请求照旧发,由宿主那边给出明确拒绝。
|
|
169
|
+
var surgeryTicket = '';
|
|
170
|
+
var surgeryTicketTried = false;
|
|
171
|
+
function fetchSurgeryTicket() {
|
|
172
|
+
surgeryTicketTried = true; // 无论成败都只试一次(失败不重试,免得把拒绝变成刷屏)
|
|
173
|
+
return fetch(API + '/surgery/ticket', { method: 'POST', headers: { 'content-type': 'application/json' } })
|
|
174
|
+
.then(function (r) { return r.json().catch(function () { return null; }); })
|
|
175
|
+
.then(function (j) {
|
|
176
|
+
if (j && j.ok && typeof j.ticket === 'string' && j.ticket) { surgeryTicket = j.ticket; return surgeryTicket; }
|
|
177
|
+
return ''; // 远端 / 未定型:没有票,照常走后面的拒绝路径
|
|
178
|
+
})
|
|
179
|
+
.catch(function () { return ''; });
|
|
180
|
+
}
|
|
181
|
+
|
|
53
182
|
function api(path, opts) {
|
|
54
183
|
opts = opts || {};
|
|
184
|
+
var headers = { 'content-type': 'application/json' };
|
|
185
|
+
if (surgeryTicket) headers['x-dsh-ling-surgery'] = surgeryTicket;
|
|
55
186
|
return fetch(API + path, {
|
|
56
187
|
method: opts.method || 'GET',
|
|
57
|
-
headers:
|
|
188
|
+
headers: headers,
|
|
58
189
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
59
|
-
}).then(function (res) {
|
|
190
|
+
}).then(function (res) {
|
|
191
|
+
return res.json().catch(function () { return null; });
|
|
192
|
+
}).then(function (j) {
|
|
193
|
+
// 宿主说"票据不对/没票"⇒ 现场取一次,原样重发一次(只重发一次,不做循环)。
|
|
194
|
+
// 票据每启动轮换 ⇒ 页面放久了会遇到这一支,那时用户不必手动刷新。
|
|
195
|
+
if (j && j.ok === false && j.surgery === 'surgery-ticket' && !surgeryTicketTried) {
|
|
196
|
+
return fetchSurgeryTicket().then(function (t) {
|
|
197
|
+
if (!t) return j;
|
|
198
|
+
var h2 = { 'content-type': 'application/json', 'x-dsh-ling-surgery': t };
|
|
199
|
+
return fetch(API + path, {
|
|
200
|
+
method: opts.method || 'GET',
|
|
201
|
+
headers: h2,
|
|
202
|
+
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
203
|
+
}).then(function (r2) { return r2.json().catch(function () { return null; }); });
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return j;
|
|
207
|
+
});
|
|
60
208
|
}
|
|
61
209
|
|
|
62
210
|
// ---------------- tiny store ----------------
|
|
@@ -115,6 +263,8 @@
|
|
|
115
263
|
'.dsh-ling-menu .mi:hover{background:var(--lg-hover-bg);color:var(--lg-hover-text);font-weight:600;}' +
|
|
116
264
|
'.dsh-ling-menu .sep{height:1px;background:linear-gradient(90deg,var(--lg-border),transparent);margin:5px 10px;}' +
|
|
117
265
|
'.dsh-ling-menu .hint{padding:4px 12px 7px;font-size:12px;opacity:.55;line-height:1.5;}' +
|
|
266
|
+
'.dsh-ling-tip{min-width:auto;max-width:290px;padding:0;pointer-events:none;}' +
|
|
267
|
+
'.dsh-ling-tip .mi{white-space:normal;font-size:12.5px;line-height:1.55;padding:8px 11px;}' +
|
|
118
268
|
'.dsh-ling-panel{position:fixed;z-index:9999;right:18px;top:18px;width:min(480px,94vw);max-height:86vh;' +
|
|
119
269
|
'overflow:auto;background:linear-gradient(var(--lg-bg),var(--lg-bg)) padding-box,var(--lg-bgrad) border-box;' +
|
|
120
270
|
'border:1px solid transparent;border-radius:16px;' +
|
|
@@ -252,13 +402,66 @@
|
|
|
252
402
|
'.ling-hist .hr button{min-height:26px;padding:1px 10px;font-size:12px;border-radius:8px;border:1px solid var(--lg-border);' +
|
|
253
403
|
'background:transparent;color:var(--lg-text);cursor:pointer;flex:none;}' +
|
|
254
404
|
'.ling-hist .hr button:hover{border-color:var(--lg-accent);background:var(--lg-hover);}' +
|
|
255
|
-
'.ling-hist .hr button:disabled{opacity:.45;cursor:wait;}'
|
|
405
|
+
'.ling-hist .hr button:disabled{opacity:.45;cursor:wait;}' +
|
|
406
|
+
// E3(面板只读块):「那一行」在盘上有两条 —— ①L2 本会话标题行、②E0 活行。两条必须**各自成行、各自标注**,
|
|
407
|
+
// 否则主人只会看到"同一句话印了两遍"。「看全文」默认收起:快照原文上限 8000 字符,不设限高会把面板顶出屏幕。
|
|
408
|
+
// 缺陷② 修复(2026-09-27 真机目视"字好挤、行距比旧块小还不统一"):根因是**无单位**行高 —— 它不是一个
|
|
409
|
+
// 固定的数,而是"字号 x 倍数";三套字号(12.5 / 12 / 11.5)各乘一遍 ⇒ 每类行的实际行距都不同
|
|
410
|
+
// (20.6 / 19.8 / 19.0px),且都比上面那个旧块 `.kv`(本文件 :133,13px x 1.6 = 20.8px)紧。
|
|
411
|
+
// 缺陷③ 修复(2026-09-27 真机目视"小字略显拥挤,大字行距是对的"):上一轮我把小字**并进正文那一档**换来统一,
|
|
412
|
+
// 层级就没了 ⇒ 小字挤。正解 = 行距写**绝对 px**(取旧块的计算行距 20.8px):绝对行距在子元素上继承的是
|
|
413
|
+
// **计算值**,不随各自字号相乘 ⇒ 字号可以分三档,行距仍恒等 —— "统一"与"层级"同时成立。
|
|
414
|
+
// (判据:已由一次性静态探针逐档核算实际行距,要求取值唯一、且等于旧块的计算值。)
|
|
415
|
+
'.ling-e3{margin:8px 0 2px;padding:8px 10px;border:1px solid var(--lg-border-soft);border-radius:10px;' +
|
|
416
|
+
'background:var(--lg-rowbg);font-size:13px;line-height:20.8px;}' + // 正文档 13px(同旧块)+ **绝对**行距 = 旧块的计算行距
|
|
417
|
+
'.ling-e3 .e3-h{font-weight:700;opacity:.95;margin-bottom:3px;}' + // 标题:正文档(继承 13px)
|
|
418
|
+
'.ling-e3 .e3-row{white-space:pre-wrap;word-break:break-word;}' + // 正文档
|
|
419
|
+
'.ling-e3 .e3-row .lbl{opacity:.6;}' +
|
|
420
|
+
'.ling-e3 .e3-why{font-size:12px;opacity:.88;}' + // 说明档:「为什么没变」那几行
|
|
421
|
+
'.ling-e3 .e3-why.no{color:var(--lg-danger);opacity:1;}' + // 不另给字号 ⇒ 仍随说明档
|
|
422
|
+
'.ling-e3 .e3-fold-h{font-size:12px;cursor:pointer;margin-top:6px;}' + // 说明档:折叠头
|
|
423
|
+
'.ling-e3 .e3-fold-h .tag{opacity:.6;}' +
|
|
424
|
+
'.ling-e3 .e3-pre{font-size:12px;max-height:220px;overflow:auto;margin:4px 0 0;padding:6px 8px;border-radius:8px;' + // 说明档:注入面原文
|
|
425
|
+
'border:1px solid var(--lg-border-soft);background:var(--lg-bg2);white-space:pre-wrap;word-break:break-word;' +
|
|
426
|
+
'opacity:.92;}' +
|
|
427
|
+
'.ling-e3 .e3-meta{font-size:11.5px;opacity:.6;}'; // meta 档:builtAt 那类一行小字(三档都继承同一个绝对行距)
|
|
256
428
|
document.head.appendChild(tag);
|
|
257
429
|
}
|
|
258
430
|
|
|
259
431
|
function dark() { return document.documentElement.getAttribute('data-ds-theme') === 'dark' ? '1' : '0'; }
|
|
260
432
|
|
|
433
|
+
/** 悬停即时小气泡(2026-09-27,用户反馈"太罗嗦 + 窗口叠加"):
|
|
434
|
+
* 原生 `title` 由**浏览器**绘制 —— 既不受样式控制,也**不会自动消失**;而悬停 1.5s 会开菜单,
|
|
435
|
+
* 于是浏览器 tooltip 和菜单叠成两层。故完整提醒改走自绘:悬停瞬间出现、`TIP_MS` 后自动移除,
|
|
436
|
+
* 且菜单一开就先收掉它 ⇒ 两者永不同屏。与 `toast` 同规格(都是 dsh-ling-menu 容器 + 定时移除),
|
|
437
|
+
* 差别只在寿命与定位。 */
|
|
438
|
+
var TIP_MS = 3000;
|
|
439
|
+
var tipNode = null;
|
|
440
|
+
var tipTimer = null;
|
|
441
|
+
function hideTip() {
|
|
442
|
+
if (tipTimer) { clearTimeout(tipTimer); tipTimer = null; }
|
|
443
|
+
if (tipNode) { tipNode.remove(); tipNode = null; }
|
|
444
|
+
}
|
|
445
|
+
function showTip(node, text) {
|
|
446
|
+
hideTip();
|
|
447
|
+
if (!node || !text || typeof node.getBoundingClientRect !== 'function') return;
|
|
448
|
+
var t = el('div', 'dsh-ling-menu dsh-ling-tip');
|
|
449
|
+
t.appendChild(el('div', 'mi', text));
|
|
450
|
+
t.style.visibility = 'hidden'; // 先隐藏渲染量尺寸,再定方位(同 openMenuNear 的做法)
|
|
451
|
+
document.body.appendChild(t);
|
|
452
|
+
var r = node.getBoundingClientRect();
|
|
453
|
+
var w = t.offsetWidth, hh = t.offsetHeight;
|
|
454
|
+
var top = r.bottom + 8;
|
|
455
|
+
if (top + hh > window.innerHeight - 6) top = Math.max(6, r.top - hh - 8); // 下方放不下就翻上去
|
|
456
|
+
t.style.top = top + 'px';
|
|
457
|
+
t.style.left = Math.max(6, Math.min(r.left, window.innerWidth - w - 6)) + 'px';
|
|
458
|
+
t.style.visibility = '';
|
|
459
|
+
tipNode = t;
|
|
460
|
+
tipTimer = setTimeout(hideTip, TIP_MS);
|
|
461
|
+
}
|
|
462
|
+
|
|
261
463
|
function closeOverlays() {
|
|
464
|
+
hideTip();
|
|
262
465
|
document.querySelectorAll('.dsh-ling-menu,.dsh-ling-panel').forEach(function (n) {
|
|
263
466
|
if (typeof n.__lingCleanup === 'function') { try { n.__lingCleanup(); } catch (e) {} }
|
|
264
467
|
n.remove();
|
|
@@ -284,14 +487,23 @@
|
|
|
284
487
|
}
|
|
285
488
|
|
|
286
489
|
/** `ms` 可选:不传时沿用原样 3.2s(既有调用全部不传)。需要长一点的是**覆盖/归档**这类
|
|
287
|
-
* "数据已经变了"的回执 —— 3.2s 读不完一句带归档目录的话(审计 E10)。
|
|
288
|
-
|
|
490
|
+
* "数据已经变了"的回执 —— 3.2s 读不完一句带归档目录的话(审计 E10)。
|
|
491
|
+
*
|
|
492
|
+
* `wrap` 可选(B / 2026-09-29):`.dsh-ling-menu .mi` 是 `white-space:nowrap`(菜单项必须单行),
|
|
493
|
+
* 而**说得清一件事的长文案**在 nowrap 下会顶破 70vw 的盒子、文字溢到屏幕外 —— 那时"文案改好了"
|
|
494
|
+
* 反而读不到。故长文案的调用显式传 `wrap = true`:换成换行渲染 + 更宽的盒子 + 更长的寿命。
|
|
495
|
+
* 不传 = 与既有全部调用逐字同形(短文案本来就只占一行,样式分支根本不进)。 */
|
|
496
|
+
function toast(msg, ms, wrap) {
|
|
289
497
|
var t = el('div', 'dsh-ling-menu');
|
|
290
|
-
t.style.cssText = 'top:16px;left:50%;transform:translateX(-50%);cursor:default;min-width:auto;max-width:70vw;text-align:center;'
|
|
498
|
+
t.style.cssText = 'top:16px;left:50%;transform:translateX(-50%);cursor:default;min-width:auto;max-width:70vw;text-align:center;'
|
|
499
|
+
+ (wrap ? 'width:620px;max-width:86vw;' : '');
|
|
291
500
|
// 后端回执里的 `message` 允许带 `**强调**` 标记(如「**覆盖了上一版主脉记忆**」),
|
|
292
501
|
// 而 toast 是纯文本容器 ⇒ 显示层去掉标记,别把星号糊给主人。
|
|
293
502
|
// (非字符串原样交给 el(),不改变任何既有调用的行为:全文件现有 toast 文案里没有 `**`。)
|
|
294
|
-
|
|
503
|
+
var mi = el('div', 'mi', (typeof msg === 'string') ? msg.replace(/\*\*/g, '') : msg);
|
|
504
|
+
// pre-line:只认 `\n` 换行,不动空格/缩进 —— 文案里手写的分行原样落地
|
|
505
|
+
if (wrap) mi.style.cssText = 'white-space:pre-line;line-height:1.65;text-align:left;align-items:flex-start;';
|
|
506
|
+
t.appendChild(mi);
|
|
295
507
|
document.body.appendChild(t);
|
|
296
508
|
setTimeout(function () { t.remove(); }, Number(ms) > 0 ? Number(ms) : 3200);
|
|
297
509
|
}
|
|
@@ -472,6 +684,29 @@
|
|
|
472
684
|
wrap.style.right = 'auto';
|
|
473
685
|
}
|
|
474
686
|
|
|
687
|
+
// 全局面板(左侧栏底部那个按钮打开的面板)的默认落点。
|
|
688
|
+
// 实测:左侧栏(会话列表)右缘在 page x≈279(1920 宽、未缩放的窗口截图量得),
|
|
689
|
+
// 故左让 300 —— ①不压住左侧栏列表 ②与会话面板(CSS 默认 right:18px;top:18px)明显分开。
|
|
690
|
+
// 窄窗口按视口宽度夹取,免得比会话面板更早溢出屏幕右边。
|
|
691
|
+
var PANEL_GLOBAL_HOME_LEFT = 300;
|
|
692
|
+
var PANEL_GLOBAL_HOME_TOP = 64;
|
|
693
|
+
|
|
694
|
+
// 无保存位置时的「本视图默认锚点」:全局视图('status-global')另起落点;
|
|
695
|
+
// 其余 key 一律回老位置(右上角 18px/18px,与改动前的 CSS 默认逐字一致,行为不变)。
|
|
696
|
+
function panelHome(wrap, key) {
|
|
697
|
+
if (key !== 'status-global') {
|
|
698
|
+
wrap.style.left = 'auto';
|
|
699
|
+
wrap.style.right = '18px';
|
|
700
|
+
wrap.style.top = '18px';
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
var vw = (typeof window !== 'undefined' && window.innerWidth) || 1280;
|
|
704
|
+
var w = wrap.offsetWidth || 480;
|
|
705
|
+
wrap.style.left = Math.max(16, Math.min(PANEL_GLOBAL_HOME_LEFT, vw - w - 16)) + 'px';
|
|
706
|
+
wrap.style.top = PANEL_GLOBAL_HOME_TOP + 'px';
|
|
707
|
+
wrap.style.right = 'auto';
|
|
708
|
+
}
|
|
709
|
+
|
|
475
710
|
function initPanelDrag(wrap, key) {
|
|
476
711
|
var h3 = wrap.querySelector('h3');
|
|
477
712
|
if (!h3) return;
|
|
@@ -519,7 +754,7 @@
|
|
|
519
754
|
function onDbl() {
|
|
520
755
|
try { sessionStorage.removeItem('dsh-ling-pos:' + key); } catch (e) {}
|
|
521
756
|
if (key === 'mc') centerPanel(wrap);
|
|
522
|
-
else
|
|
757
|
+
else panelHome(wrap, key); // 全局面板双击复位回它自己的落点(左上),不再跟会话面板挤右上
|
|
523
758
|
}
|
|
524
759
|
h3.addEventListener('mousedown', onDown);
|
|
525
760
|
window.addEventListener('mousemove', onMove);
|
|
@@ -556,28 +791,256 @@
|
|
|
556
791
|
setPronounFrom(r.persona);
|
|
557
792
|
var patch = { mode: r.mode || store.get().mode, running: !!r.running, pending: r.pending || 0 };
|
|
558
793
|
if (r.sessionId) patch.sessionId = r.sessionId;
|
|
794
|
+
// C-07 访问日志健康面:搬进 store,侧栏按钮才能「不主动查也看得见」停写。
|
|
795
|
+
// 老后端没有这个字段 ⇒ undefined ⇒ 原样跳过,store 里也就没有 audit(各处一律按缺失处理)。
|
|
796
|
+
// ⚠️ 刻意**不**把它加进 pollSig:writes/lost 每拍都在变,加进去等于把轮询永久钉在快节拍上。
|
|
797
|
+
if (r.audit !== undefined) patch.audit = r.audit;
|
|
798
|
+
// E2 可见化(2026-10-01):原文表里出现**未知来源**时,后端在 /state 顶上给一句人话
|
|
799
|
+
// (`rawUnknownSources`,形状见 lib/host/api.js 的 rawUnknownSourcesOf)。
|
|
800
|
+
// 与 audit 同一条路搬进 store ⇒ 面板不必重开也看得见。
|
|
801
|
+
// ⚠️ 与 audit 的 `!== undefined` **不同**:这里显式归零(`|| null`)。audit 是新后端恒有、
|
|
802
|
+
// 老后端恒无;而这个字段**干净时就该消失**,留一个过期的"有未知来源"比不显示更坏。
|
|
803
|
+
// ⚠️ 刻意**不**进 pollSig(同 audit 的理由:它极少变,进去只会把轮询钉在快节拍上)。
|
|
804
|
+
patch.rawUnknownSources = r.rawUnknownSources || null;
|
|
559
805
|
store.set(patch);
|
|
560
806
|
}
|
|
561
807
|
|
|
808
|
+
// ---------------- 共享 /state 轮询器(B 后台暂停 + A 共享轮询/空闲降频) ----------------
|
|
809
|
+
// 病灶(实测):LingButton 的 useEffect(…,[]) 里**每个组件实例**各起一个 setInterval(…,3000)
|
|
810
|
+
// 直拉 /state;而同一个组件被挂到两个槽位(conversation.session.header.utilities 约 :5148
|
|
811
|
+
// + sidebar.footer.action 约 :5149)⇒ 每客户端 2 个定时器。4 个客户端 = 1.34 次/秒、
|
|
812
|
+
// 全天 46,392 次;后端每次 24.2ms 同步阻塞(3 次全表 + SHA-256)⇒ 每天 18.8 分钟卡在宿主单线程上。
|
|
813
|
+
// 且改动前全程无节流(visibilitychange / document.hidden 在该文件里 0 命中)。
|
|
814
|
+
// 这里把定时器收进**模块级**注册表,按 store 分键(本插件里一个 kind 恰好一个 store,
|
|
815
|
+
// 故等价于「同 kind 共用同一条 ticker」):一拍对每个 store 只发**一次**请求,
|
|
816
|
+
// 结果广播给该 store 的全部订阅者 ⇒ 每客户端最多 2 个请求/周期,而不是 2N。
|
|
817
|
+
// 叠两条节流:
|
|
818
|
+
// B) document.hidden ⇒ 整条停表(一拍都不发);切回可见立即补一次并恢复节拍;
|
|
819
|
+
// A) 连续 POLL_IDLE_STRIKES 拍「没变化」⇒ 放宽到 POLL_SLOW_MS;
|
|
820
|
+
// 一旦有变化 / running / pending>0 / store 被 set / 用户点击 ⇒ 立刻回快节拍。
|
|
821
|
+
// C) 「全局默认」那条(store.kind === 'global',即侧栏底部那个按钮)**固定 30s**。
|
|
822
|
+
// 理由(2026-09-27 实测):A+B 上线后残留 34.5 次/分里约 1/3 来自它(5s 节拍 ⇒ 12 次/分),
|
|
823
|
+
// 而它代表的"全局默认模式"只在主人按那个按钮时才变 —— 按下按钮会经 hookStoreSet
|
|
824
|
+
// ⇒ kickPoll 立刻补一拍,本来就不靠轮询发现变化。全局那条按 30s 走 ⇒ 该流降到 2 次/分。
|
|
825
|
+
// 代价:另一个标签页改了全局默认,本页最多 30s 后才发现(可接受)。
|
|
826
|
+
// 清理对称:退订函数里停表;订阅者归零时连记录一起撤,并摘掉 visibilitychange 监听。
|
|
827
|
+
var POLL_FAST_MS = 3000; // 基础节拍(沿用旧值,远高于 1s 下限)
|
|
828
|
+
var POLL_SLOW_MS = 5000; // 空闲降频后的节拍
|
|
829
|
+
var POLL_IDLE_STRIKES = 2; // 连续这么多拍没变化就降频
|
|
830
|
+
var POLL_GLOBAL_MS = 30000; // 「全局默认」那条的固定节拍(见上面 C)条)
|
|
831
|
+
var pollers = []; // [{ store, subs, timer, period, idle, sig, inflight, applying }]
|
|
832
|
+
var pollSubs = 0; // 订阅者总数(visibilitychange 监听按它引用计数)
|
|
833
|
+
var visBound = false;
|
|
834
|
+
|
|
835
|
+
function findPoll(store) {
|
|
836
|
+
for (var i = 0; i < pollers.length; i++) if (pollers[i].store === store) return pollers[i];
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function pollRec(store) {
|
|
841
|
+
var rec = findPoll(store);
|
|
842
|
+
if (rec) return rec;
|
|
843
|
+
rec = { store: store, subs: 0, timer: null, period: 0, idle: 0, sig: null, inflight: false, applying: false };
|
|
844
|
+
pollers.push(rec);
|
|
845
|
+
return rec;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function isHidden() { return typeof document !== 'undefined' && !!document.hidden; }
|
|
849
|
+
|
|
850
|
+
/** 这条 ticker 是不是「全局默认」那条(按 store.kind 判,不另建注册表)。 */
|
|
851
|
+
function isGlobalPoll(rec) {
|
|
852
|
+
try { return !!(rec && rec.store && rec.store.get && rec.store.get().kind === 'global'); }
|
|
853
|
+
catch (e) { return false; } // 取不到就按普通节拍走,绝不因判据异常而停表
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** 只比 applyStateToStore 真会写进 store 的字段 —— 它们没变,就是「这一拍没有变化」。 */
|
|
857
|
+
function pollSig(r) {
|
|
858
|
+
r = r || {};
|
|
859
|
+
return [r.mode || '', r.running ? 1 : 0, r.pending || 0, r.sessionId || ''].join('|');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** 按当前节拍排表;只有节拍真的变了才重建定时器(免得每拍都重置相位)。 */
|
|
863
|
+
function schedulePoll(rec) {
|
|
864
|
+
// 全局默认那条固定 POLL_GLOBAL_MS(不参与空闲降频 —— 它本来就比降频后的节拍还慢)。
|
|
865
|
+
var want = isGlobalPoll(rec) ? POLL_GLOBAL_MS
|
|
866
|
+
: (rec.idle >= POLL_IDLE_STRIKES ? POLL_SLOW_MS : POLL_FAST_MS);
|
|
867
|
+
if (rec.timer !== null && rec.period === want) return;
|
|
868
|
+
if (rec.timer !== null) clearInterval(rec.timer);
|
|
869
|
+
rec.period = want;
|
|
870
|
+
rec.timer = setInterval(function () { pollTick(rec); }, want);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function stopPoll(rec) {
|
|
874
|
+
if (rec.timer !== null) { clearInterval(rec.timer); rec.timer = null; }
|
|
875
|
+
rec.period = 0;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** 一拍:每个 store 只发一次请求,结果广播(数据层照用 applyStateToStore,不重写)。 */
|
|
879
|
+
function pollTick(rec) {
|
|
880
|
+
if (!rec || rec.subs <= 0) return;
|
|
881
|
+
if (rec.inflight) return; // 上一拍还没回来 ⇒ 跳过这一拍,绝不叠请求
|
|
882
|
+
if (isHidden()) return; // B:后台一拍都不发
|
|
883
|
+
rec.inflight = true;
|
|
884
|
+
api(pollPath(rec.store)).then(function (r) {
|
|
885
|
+
rec.inflight = false;
|
|
886
|
+
// ⚠️ 旧实现在链尾挂了 `.catch(function () {})`:这里同样不让一次异常冒成
|
|
887
|
+
// unhandled rejection,而且无论成败都必须排下一拍(否则一拍出错 ticker 就静默停摆)。
|
|
888
|
+
try {
|
|
889
|
+
var sig = pollSig(r);
|
|
890
|
+
var busy = !!(r && r.ok && (r.running || r.pending));
|
|
891
|
+
if (sig !== rec.sig || busy) rec.idle = 0; else rec.idle++;
|
|
892
|
+
rec.sig = sig;
|
|
893
|
+
rec.applying = true; // 这一段 store.set 是轮询自己写的,不算「用户动作」
|
|
894
|
+
applyStateToStore(rec.store, r);
|
|
895
|
+
} catch (e) {
|
|
896
|
+
console.warn('[dsh-ling] 应用 /state 回执失败', e);
|
|
897
|
+
} finally {
|
|
898
|
+
rec.applying = false;
|
|
899
|
+
schedulePoll(rec);
|
|
900
|
+
}
|
|
901
|
+
}, function () {
|
|
902
|
+
rec.inflight = false;
|
|
903
|
+
rec.idle++; // 网关不可达也照样退避:别越错越急
|
|
904
|
+
schedulePoll(rec);
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** 立刻回快节拍;fire=true 时再马上补一拍(用户点击 / 可见性恢复)。 */
|
|
909
|
+
function kickPoll(store, fire) {
|
|
910
|
+
var rec = findPoll(store);
|
|
911
|
+
if (!rec || rec.subs <= 0) return;
|
|
912
|
+
rec.idle = 0;
|
|
913
|
+
if (isHidden()) return; // 后台照旧一拍不发
|
|
914
|
+
if (fire) pollTick(rec);
|
|
915
|
+
schedulePoll(rec); // 补拍失败也要保证表在走
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** store 被 set(模式切换回执、菜单动作、props 传来的 sessionId)⇒ 回快节拍。
|
|
919
|
+
* 刻意**不**在这里发请求:applyStateToStore 自己就往 store 里 set,
|
|
920
|
+
* 若在这里补请求会每拍自激成刷新风暴(render 期那次 set 也不该发请求)。 */
|
|
921
|
+
function hookStoreSet(store) {
|
|
922
|
+
if (store.__lingPollHooked) return;
|
|
923
|
+
store.__lingPollHooked = true;
|
|
924
|
+
var rawSet = store.set;
|
|
925
|
+
store.set = function (patch) {
|
|
926
|
+
rawSet(patch);
|
|
927
|
+
var rec = findPoll(store);
|
|
928
|
+
if (rec && !rec.applying) kickPoll(store, false);
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function onVisibilityChange() {
|
|
933
|
+
var hidden = isHidden();
|
|
934
|
+
for (var i = 0; i < pollers.length; i++) {
|
|
935
|
+
var rec = pollers[i];
|
|
936
|
+
if (rec.subs <= 0) continue;
|
|
937
|
+
if (hidden) { stopPoll(rec); continue; } // 切到后台:整条停表
|
|
938
|
+
rec.idle = 0; // 切回前台:回快节拍
|
|
939
|
+
pollTick(rec); // 立即补一次,不等下一拍
|
|
940
|
+
schedulePoll(rec);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function bindVisibility() {
|
|
945
|
+
if (visBound || typeof document === 'undefined' || !document.addEventListener) return;
|
|
946
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
947
|
+
visBound = true;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function unbindVisibility() {
|
|
951
|
+
if (!visBound || typeof document === 'undefined' || !document.removeEventListener) return;
|
|
952
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
953
|
+
visBound = false;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** 订阅共享 ticker;返回退订函数(与旧的 clearInterval 对称,React 卸载时调用)。 */
|
|
957
|
+
function subscribePoll(store) {
|
|
958
|
+
var rec = pollRec(store);
|
|
959
|
+
hookStoreSet(store);
|
|
960
|
+
rec.subs++;
|
|
961
|
+
pollSubs++;
|
|
962
|
+
if (rec.subs === 1 && !isHidden()) {
|
|
963
|
+
// ⚠️ 全局那条降到 30s 后,若首拍也等 30s,侧栏那个按钮会在页面加载后**空转 30 秒**
|
|
964
|
+
// ⇒ 它改为挂载即补一拍(一次性成本),其余节拍保持"首拍在 3s 后"的旧行为。
|
|
965
|
+
if (isGlobalPoll(rec)) pollTick(rec); // pollTick 成功/失败都会在 finally 里排下一拍
|
|
966
|
+
schedulePoll(rec);
|
|
967
|
+
}
|
|
968
|
+
if (pollSubs === 1) bindVisibility();
|
|
969
|
+
var done = false;
|
|
970
|
+
return function () {
|
|
971
|
+
if (done) return; // React 可能重复调用清理函数
|
|
972
|
+
done = true;
|
|
973
|
+
rec.subs--;
|
|
974
|
+
pollSubs--;
|
|
975
|
+
if (rec.subs <= 0) {
|
|
976
|
+
rec.subs = 0;
|
|
977
|
+
stopPoll(rec); // 订阅者归零 ⇒ ticker 停掉,不留常驻空转
|
|
978
|
+
var ix = pollers.indexOf(rec);
|
|
979
|
+
if (ix >= 0) pollers.splice(ix, 1);
|
|
980
|
+
}
|
|
981
|
+
if (pollSubs <= 0) { pollSubs = 0; unbindVisibility(); }
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
562
985
|
// ---------------- actions ----------------
|
|
986
|
+
/** 请求一次 /mode/toggle;带不带 confirm 决定它是"预览"还是"真写"(后端 fail-closed)。 */
|
|
987
|
+
function postToggle(body) { return api('/mode/toggle', { method: 'POST', body: body }); }
|
|
988
|
+
|
|
989
|
+
/** 收尾:按回执决定提示语。全局那一半必须说清"改的是全局默认"——
|
|
990
|
+
* 数字全部取后端回显(applied:'default-only' 那条带 from/mode/reasoningEffort),前端不自算。 */
|
|
991
|
+
function echoToggle(r) {
|
|
992
|
+
if (r.queued) { toast('会话运行中——将在空闲后切换为 ' + modeName(r.mode) + ' 模式'); return; }
|
|
993
|
+
if (r.applied === 'default-only') {
|
|
994
|
+
var eff = r.reasoningEffort || {};
|
|
995
|
+
var detail = (eff.to != null)
|
|
996
|
+
? '(全局默认推理等级 ' + (eff.from == null ? '未设置' : eff.from) + ' → ' + eff.to
|
|
997
|
+
+ (r.globalDefault ? ';模型 ' + r.globalDefault + ' 原样保留' : '') + ')'
|
|
998
|
+
: '(新会话将跟随并同步推理等级)';
|
|
999
|
+
toast('已更新全局默认模式:' + modeName(r.mode) + detail + (r.warning ? ';' + shortErr(r.warning) : ''));
|
|
1000
|
+
} else if (r.warning) {
|
|
1001
|
+
toast('已切换为 ' + modeName(r.mode) + ' 模式(新会话默认跟随);推理等级未同步:' + shortErr(r.warning));
|
|
1002
|
+
} else {
|
|
1003
|
+
toast('已切换为 ' + modeName(r.mode) + ' 模式(新会话默认跟随)');
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** 全局默认那一下动的是**平台全局默认模型**(新会话都跟随),不是本会话 ⇒ 必须先问一句。
|
|
1008
|
+
* 惯例沿用仓库既有的破坏性操作确认(window.confirm:档案回滚、导入覆盖人格、删记忆概述),
|
|
1009
|
+
* 不自造 UI 组件。
|
|
1010
|
+
* ⚠️ 为什么"先问后写"、而不是"不带 confirm 先探一次预览":那道门只有**新后端**认;
|
|
1011
|
+
* 宿主还跑着旧 api.js 时(页面刷新了、host 没重启),旧后端会把那次探测当成真切换
|
|
1012
|
+
* ⇒ 连写两次(切过去又切回来)。所以前端只发**一次带 confirm 的写**,数字回显全靠回执。
|
|
1013
|
+
* 注意:window.confirm 是纯文本,不像 toast 会去掉 `**`,故这里不用强调标记。 */
|
|
563
1014
|
function toggleMode(store) {
|
|
564
1015
|
var st = store.get();
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
toast('会话运行中——将在空闲后切换为 ' + modeName(r.mode) + ' 模式');
|
|
571
|
-
} else if (r.applied === 'default-only') {
|
|
572
|
-
toast('已更新默认模式:' + modeName(r.mode) + '(新会话将跟随并同步推理等级)' +
|
|
573
|
-
(r.warning ? ';' + shortErr(r.warning) : ''));
|
|
574
|
-
} else if (r.warning) {
|
|
575
|
-
toast('已切换为 ' + modeName(r.mode) + ' 模式(新会话默认跟随);推理等级未同步:' + shortErr(r.warning));
|
|
1016
|
+
if (st.kind !== 'global') {
|
|
1017
|
+
return postToggle({ sessionId: st.sessionId || undefined }).then(function (r) {
|
|
1018
|
+
if (r && r.ok) {
|
|
1019
|
+
if (r.mode) store.set({ mode: r.mode, sessionId: r.sessionId || st.sessionId });
|
|
1020
|
+
echoToggle(r);
|
|
576
1021
|
} else {
|
|
577
|
-
toast('
|
|
1022
|
+
toast('切换失败:' + ((r && (r.message || r.reason || r.error)) || '未知'));
|
|
578
1023
|
}
|
|
1024
|
+
return r || {};
|
|
1025
|
+
}).catch(function () { toast('切换失败(网关不可达)'); return {}; });
|
|
1026
|
+
}
|
|
1027
|
+
// ⚠️ 这里**故意不问一句** —— 用户 2026-09-27 裁决(原话):
|
|
1028
|
+
// 「创建新会话时只能通过调全局默认来调新会话的模式,所以它的使用比想象的频繁一些」
|
|
1029
|
+
// ⇒ 全局默认不是危险开关,而是**开一个不同模式的新会话**的必经入口,属高频正常动作;
|
|
1030
|
+
// 事前弹确认 = 给日常添堵,且那个按钮按下本身就是低成本可撤销动作。
|
|
1031
|
+
// 改成三层,把"让你知道改了全局"交给它们,不交给拦路:
|
|
1032
|
+
// ① 悬浮提示常驻说明(LingButton 的 title,含推理等级那句)
|
|
1033
|
+
// ② 事后回显 echoToggle(from→to、推理等级、模型保留)
|
|
1034
|
+
// ③ 后端仍要 confirm:true 这道门 —— 它拦的是"不带确认的调用方",不是拦人
|
|
1035
|
+
return postToggle({ defaultOnly: true, confirm: true }).then(function (r) {
|
|
1036
|
+
if (r && r.ok) {
|
|
1037
|
+
if (r.mode) store.set({ mode: r.mode, sessionId: null });
|
|
1038
|
+
echoToggle(r);
|
|
1039
|
+
} else if (r && r.reason === 'confirmation-required') {
|
|
1040
|
+
// 后端这道门没被采信(理论上不该发生)→ 它一个字节都没写,如实说明,不谎报成功
|
|
1041
|
+
toast('全局默认未改动:网关要求二次确认' + (r.message ? '(' + shortErr(r.message) + ')' : ''));
|
|
579
1042
|
} else {
|
|
580
|
-
toast('切换失败:' + ((r && (r.reason || r.error)) || '未知'));
|
|
1043
|
+
toast('切换失败:' + ((r && (r.message || r.reason || r.error)) || '未知'));
|
|
581
1044
|
}
|
|
582
1045
|
return r || {};
|
|
583
1046
|
}).catch(function () { toast('切换失败(网关不可达)'); return {}; });
|
|
@@ -618,13 +1081,93 @@
|
|
|
618
1081
|
wrap.appendChild(h);
|
|
619
1082
|
var kv = el('div', 'kv', '加载中…');
|
|
620
1083
|
wrap.appendChild(kv);
|
|
1084
|
+
// E3(2026-09-27 用户拍板):「那一行」只读块。**必须另起一个节点** —— 上面 `kv` 整块是纯
|
|
1085
|
+
// textContent(一段文字),而「看全文」要一个真能展开/收起的子节点(快照原文放 <pre>,默认收起)。
|
|
1086
|
+
// 这一块对模型上下文**零占用**:读的全是 `/state` 面板档(`l0Preview=1`)字段,纯读,不进 prompt。
|
|
1087
|
+
var e3box = el('div', 'ling-e3');
|
|
1088
|
+
wrap.appendChild(e3box);
|
|
1089
|
+
// 折叠状态**存闭包,不存 DOM**:paint() 每一拍(store tick)都会重建这个块,
|
|
1090
|
+
// 状态跟着 DOM 走的话,主人读着读着就被下一拍收回去。
|
|
1091
|
+
var e3Open = false;
|
|
1092
|
+
// 缺陷① 修复(2026-09-27 真机目视):「看全文」框手动滚过之后,会被**下一次重画弹回顶部**。
|
|
1093
|
+
// 根因(行级核实,不是猜):paintE3 每拍开头 `e3box.textContent = ''` 清掉全部子节点,再 `el('pre','e3-pre',…)`
|
|
1094
|
+
// **新造**一个 <pre>;而 `.e3-pre` 正是 `max-height:220px;overflow:auto` 那个滚动盒 ⇒ 节点一换,
|
|
1095
|
+
// 浏览器里 scrollTop 归零。**是"节点被重建",不是"文本被重设"**(同一个节点的 textContent 重设不会丢滚动位置)。
|
|
1096
|
+
// 频率 = store 的 tick 周期(会话 3s / 全局 30s),与主人说的"一定频率"吻合。
|
|
1097
|
+
// 修法 = **节点复用 + 滚动位置写回**:e3pre 与 e3Open 一样存闭包 ⇒ 同一个 <pre> 对象跨拍存活,
|
|
1098
|
+
// 滚动位置、框内文本选中都不再丢;写回那一步专门兜住"摘下来再装回去时浏览器把滚动位置清掉"。
|
|
1099
|
+
var e3pre = null;
|
|
1100
|
+
// 两条"那一行"的常驻说明(是**分工说明**,不是读数)——
|
|
1101
|
+
var E3_A = '① L2 本会话标题行 —— 概述器写进 `[记忆·开场]` 的那一条:只有标题,摘要不进这一行';
|
|
1102
|
+
var E3_B = '② 活行 —— agent/pre-step 每轮重算,内容真变了才追加一条新的(#N 递增,旧的留在历史里)';
|
|
1103
|
+
/** 从**活行正文**里提号码 —— ⚠️ 与后端行格式**强耦合**:正文形如
|
|
1104
|
+
* `[本次会话·进展 #N] {标题} —— 最近:{进展} · 第 T 轮 · HH:MM`(session-line.js:97 的 head)。
|
|
1105
|
+
* 台账里已登记同一类耦合:「partsFromLineText 的正则与行格式强耦合 —— **改行格式必须同批改正则**」
|
|
1106
|
+
* ⇒ 后端动这个前缀时,上面这条正则**必须同批改**。
|
|
1107
|
+
* 提取失败 **返回 null**(不是 undefined、更不是猜一个号)⇒ 调用方**不印号码**:号码只许来自正文,
|
|
1108
|
+
* 绝不拿 `S.ordinal`(下一条的号)去顶替上一次那条的正文。 */
|
|
1109
|
+
var E3_LINE_NO_RE = /^\[本次会话·进展 #(\d+)\]/;
|
|
1110
|
+
var lastNoOf = function (text) { var m = E3_LINE_NO_RE.exec(String(text == null ? '' : text)); return m ? m[1] : null; };
|
|
1111
|
+
var E3_TAG = '〔打开时读数〕'; // 与上面 SNAP_TAG 同理:面板档字段只在打开那一刻取一次
|
|
1112
|
+
// "不追加"的判定 → 人话。词表**照抄后端真会吐的原因**(session-line.js decideSessionLine
|
|
1113
|
+
// :169-177 的真值集 = dupes/empty/same,absent/ok 是"追加")+ 两个取不到会话/注入面的早退原因;
|
|
1114
|
+
// 认不出来的原因**原样印出来**(绝不翻译成"正常",也绝不编一个后端不会说的词)。
|
|
1115
|
+
var E3_BLOCK = {
|
|
1116
|
+
same: '内容没变 ⇒ 不追加(平台永久留痕:重复内容不刷新,刷新等于再插一条)',
|
|
1117
|
+
dupes: '注入面上已有 >1 条同行(脏状态)⇒ 不追加,只留痕(没有删除操作,也不作废旧副本)',
|
|
1118
|
+
// `empty` 覆盖两种真情形(对方 session-line.js:463/:496 + sessionline.test.mjs :458/:466):
|
|
1119
|
+
// ①候选确实为空 ②**压根算不出**(无会话 id / 读口全抛错)。不能只说"候选为空"——
|
|
1120
|
+
// 那会把"算不出"误诊成"内容是空的",两种情形给主人的动作完全不同。
|
|
1121
|
+
empty: '候选为空 或 压根算不出(无会话 id、标题与进展都取不到、读口抛错)⇒ 不追加 —— 绝不给模型一条空文本',
|
|
1122
|
+
absent: '注入面上没有这一行(可能被折叠吞了)⇒ 会追加一条(即便与上次逐字相同)',
|
|
1123
|
+
ok: '内容变了 ⇒ 会追加一条新的',
|
|
1124
|
+
'no-session': '拿不到会话对象(agent.session.eventAt 不可用)⇒ 本拍不判定',
|
|
1125
|
+
'no-surface': '拿不到注入面(session.surface.nodes 不是数组)⇒ 本拍不判定',
|
|
1126
|
+
off: '配置里已关闭(memory.sessionProgressLine=false)⇒ 不追加',
|
|
1127
|
+
disabled: '配置里已关闭(memory.sessionProgressLine=false)⇒ 不追加',
|
|
1128
|
+
error: '判定过程抛错 ⇒ 退化成不追加(错误留在会话行探针里可查)',
|
|
1129
|
+
};
|
|
1130
|
+
// 判定读数的**三类**归类(与 E3_BLOCK 的原文一致):**不许**一律套"不追加 —— "前缀 ——
|
|
1131
|
+
// 'absent'/'ok' 的原文就是"会追加一条",前缀写成"不追加"会与同一行的后半句自相矛盾
|
|
1132
|
+
// (自相矛盾的读数比不显示更坏);'no-session'/'no-surface' 是"本拍不判定",也不是"不追加"。
|
|
1133
|
+
var E3_APPEND = { absent: 1, ok: 1 };
|
|
1134
|
+
var E3_HOLD = { 'no-session': 1, 'no-surface': 1 };
|
|
1135
|
+
// 判定读数的**前提**(后端 session-line.js 的 `basis`)—— 那个读数不是无条件的事实,而是**带前提的预测**:
|
|
1136
|
+
// 'title+progress' = 标题 + 面上最新真人发言首句都看得到(基准完整);
|
|
1137
|
+
// 'title-only' = HTTP 预览路径**取不到会话面** ⇒ 只看得到标题、拿上次记下的进展当基准
|
|
1138
|
+
// ⇒ **保守**:可能把"会追加"读成 same。
|
|
1139
|
+
// 规矩:**带前提的预测不能当无前提的读数显示** ⇒ basis 取不到(老后端 / 字段缺失 / 认不出 / 解析不出)
|
|
1140
|
+
// **一律按降级标注**,**绝不**默认成完整基准(那正是制造假读数的标准路径,台账 B66)。
|
|
1141
|
+
// 取法**两层容错**:现在整个预览对象挂在 preview 下(后端 sessionLineState 顶层没有 basis),
|
|
1142
|
+
// 将来它挪到顶层也不会哑。
|
|
1143
|
+
var E3_BASIS_FULL = '〔基准:标题+最新进展〕';
|
|
1144
|
+
var E3_BASIS_DOWN = '〔基准降级:只看得到标题〕';
|
|
1145
|
+
var basisTagOf = function (S) {
|
|
1146
|
+
var pv = (S && S.preview) || null;
|
|
1147
|
+
var b = (pv && pv.basis) || (S && S.basis) || null; // 顶层那半是"将来挪位"的兜底
|
|
1148
|
+
if (b === 'title+progress') return E3_BASIS_FULL;
|
|
1149
|
+
if (b === 'title-only') return E3_BASIS_DOWN;
|
|
1150
|
+
if (b) return '〔基准降级:后端给了个前端未收录的基准("' + b + '")⇒ 不按完整基准算〕'; // 认不出的原样印出
|
|
1151
|
+
return '〔基准降级:后端未给 basis ⇒ 按降级算〕'; // 缺失 = 降级(不默认成完整)
|
|
1152
|
+
};
|
|
621
1153
|
var btnRow = el('div', 'btnrow');
|
|
622
1154
|
var close = el('button', null, '关闭');
|
|
623
1155
|
btnRow.appendChild(close);
|
|
624
1156
|
wrap.appendChild(btnRow);
|
|
625
1157
|
document.body.appendChild(wrap);
|
|
626
1158
|
armOverlays();
|
|
627
|
-
|
|
1159
|
+
// 拖拽记忆按视图分键:以前全局与会话共用一个 sessionStorage['dsh-ling-pos:status'],
|
|
1160
|
+
// 互相覆盖 ⇒ 两个面板永远开在同一处。全局改走 'status-global'。
|
|
1161
|
+
// 先摆本视图的默认锚点;若主人拖过、sessionStorage 里存着位置,下面会用他的覆盖掉。
|
|
1162
|
+
panelHome(wrap, global ? 'status-global' : 'status');
|
|
1163
|
+
initPanelDrag(wrap, global ? 'status-global' : 'status');
|
|
1164
|
+
// §M(1.5.2):面板开着不动 ⇒ 那几行永远冻在打开那一刻(这里只拉一次 /state,拿到就扔)。
|
|
1165
|
+
// 修法 = **复用 store 已有的 tick**(不新开 /state 轮询:全局那条刚从 5s 降到 30s,加回去等于把降频取消):
|
|
1166
|
+
// 面板订阅 store ⇒ applyStateToStore 每拍写进去的字段(running/pending/mode/sessionId/audit)就能跟上;
|
|
1167
|
+
// store 里**没有**的字段(快照 · 空闲刷新 · 人格 · 记忆库 · L1)只在带 l0Preview=1 的应答里,
|
|
1168
|
+
// 也就是只在下面这一份 snap 里 ⇒ 只能保持打开时的值,故在渲染处逐块标注「打开时快照」。
|
|
1169
|
+
var snap = null;
|
|
1170
|
+
var SNAP_TAG = ' 〔打开时快照〕'; // 不标注的话,「冻住了」与「本来就这一项」在界面上长得一模一样
|
|
628
1171
|
api(url).then(function (r) {
|
|
629
1172
|
if (!r || !r.ok) {
|
|
630
1173
|
kv.textContent = '暂无状态: ' + JSON.stringify(r || {});
|
|
@@ -636,16 +1179,95 @@
|
|
|
636
1179
|
h.textContent = '';
|
|
637
1180
|
h.appendChild(document.createTextNode(disp));
|
|
638
1181
|
h.appendChild(el('div', 'hint', global ? '此视图无活动会话:此按钮管理新会话默认模式' : '左键=切换模式 · 悬停 1.5s=菜单 · Esc 关闭'));
|
|
1182
|
+
snap = r;
|
|
1183
|
+
paint(); // 打开时先画一遍(此刻 store 与这份应答同源,输出与改动前逐字一致)
|
|
1184
|
+
if (r.running) toast('该会话运行中——人格/记忆更新将在空闲后生效');
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
/** §M 面板重画:store 覆盖得到的行跟着 store 走,其余用打开那一刻的应答(逐块标注「打开时快照」)。 */
|
|
1188
|
+
function paint() {
|
|
1189
|
+
var r = snap;
|
|
1190
|
+
if (!r) return; // 首份应答还没回来 ⇒ 保持「加载中…」
|
|
1191
|
+
var cur = store.get(); // mode/running/pending/sessionId/audit = pollTick → applyStateToStore 每拍写(:610-620)
|
|
1192
|
+
var sid = cur.sessionId || r.sessionId;
|
|
1193
|
+
var lm = cur.mode || r.mode;
|
|
639
1194
|
var lines = [
|
|
640
|
-
global ? '全局默认(新会话跟随)' : ('当前会话: ' + (
|
|
641
|
-
'模式: ' + (
|
|
642
|
-
'待应用更新: ' + (
|
|
1195
|
+
global ? '全局默认(新会话跟随)' : ('当前会话: ' + (sid || '(无)')),
|
|
1196
|
+
'模式: ' + ((lm === 'work' || lm === 'life') ? modeName(lm) : (lm || '(未知)')) + (cur.running ? ' · 运行中(冻结)' : ' · 空闲'),
|
|
1197
|
+
'待应用更新: ' + (cur.pending || 0),
|
|
643
1198
|
];
|
|
644
|
-
|
|
645
|
-
|
|
1199
|
+
// 只读状态面(理念 3):把后端**已在返回里、却一个字符都没渲染**的三项显出来 ——
|
|
1200
|
+
// 快照定稿时刻(snapshot.builtAt,后端是 UTC ISO,须转本地)+ 上次空闲刷新时刻与刷新次数(idleRefresh.{lastAt,count},epoch ms)。
|
|
1201
|
+
// 没有这一行时「那一行冻住了」与「机制已失效」在界面上长得一模一样;刷新只发生在 运行中 → 空闲 那一刻,且距上次 ≥10 分钟,故为 0 属正常,不是坏掉。
|
|
1202
|
+
var fmtClock = function (v) {
|
|
1203
|
+
if (!v) return '';
|
|
1204
|
+
var d = (typeof v === 'number') ? new Date(v) : new Date(String(v));
|
|
1205
|
+
if (isNaN(d.getTime())) return String(v); // 脏值直接原样显示,不做自作聪明的截取
|
|
1206
|
+
var p = function (n) { return (n < 10 ? '0' : '') + n; };
|
|
1207
|
+
return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
1208
|
+
};
|
|
1209
|
+
// §N(2026-09-27):后端"没有快照"时给的是 `{ exists: false }` —— 它是**真值**,
|
|
1210
|
+
// 旧判据 `if (r.snapshot)` 会让这一格打印"已定稿 0 字符",把**没有快照**说成**有一个 0 字符的快照**。
|
|
1211
|
+
// 这条尤其误导:0 字符快照本身真会发生(L0 人格为空等),两者外观一致、语义相反。
|
|
1212
|
+
// 与下面那一行"全局视图无会话快照"同一条原则:不说出来,"按设计没有"与"坏了"长得一模一样。
|
|
1213
|
+
// §M:SNAP_TAG 逐行标出「这一格不随面板停留刷新」(这些字段 store 里没有 ⇒ 只有打开时那一份)。
|
|
1214
|
+
if (r.snapshot && r.snapshot.exists === false) {
|
|
1215
|
+
lines.push('记忆快照: (未定稿 —— 本会话尚无快照,通常在会话第一次定稿后出现)' + SNAP_TAG);
|
|
1216
|
+
} else if (r.snapshot) {
|
|
1217
|
+
lines.push('记忆快照: 已定稿 ' + (r.snapshot.chars || 0) + ' 字符 · mode=' + (r.snapshot.mode || '?') + (r.snapshot.stale ? ' · 待刷新' : '') + SNAP_TAG);
|
|
1218
|
+
lines.push('快照定稿于: ' + (r.snapshot.builtAt ? fmtClock(r.snapshot.builtAt) : '(未记录)') + SNAP_TAG);
|
|
646
1219
|
}
|
|
1220
|
+
if (r.idleRefresh) {
|
|
1221
|
+
var refresh = r.idleRefresh;
|
|
1222
|
+
lines.push('空闲刷新次数: ' + (refresh.count || 0) + ' · 上次滚动: ' + (refresh.lastAt ? fmtClock(refresh.lastAt) : '(从未刷新 — 只发生在 运行中→空闲 那一刻,且距上次 ≥10 分钟)') + SNAP_TAG);
|
|
1223
|
+
}
|
|
1224
|
+
// 全局视图的 L2 三行**必然**缺席:后端 lib/host/api.js 在 global 分支写死 snapshot: null,
|
|
1225
|
+
// 而且整个返回里不带 idleRefresh 字段 ⇒ 上面两段一个字符都进不来。
|
|
1226
|
+
// 不写这一行,"按设计没有"与"坏了"在界面上长得一模一样(用户 2026-09-27 反馈)。
|
|
1227
|
+
// 放在 L2 本该出现的位置(待应用更新 → 这里 → 人格),两个视图的行序才对得齐。
|
|
1228
|
+
if (global && !r.snapshot && !r.idleRefresh) {
|
|
1229
|
+
lines.push('(全局视图无会话快照 —— 快照定稿/空闲刷新是按会话统计的,进入某个会话才显示)' + SNAP_TAG);
|
|
1230
|
+
}
|
|
1231
|
+
// C-07 访问日志健康面(2026-09-27「日志静默 41.6 分钟」那次的告警位):
|
|
1232
|
+
// 后端老版本 / 日志器没装 ⇒ r.audit 为 null/undefined ⇒ **一个字符都不渲染** ——
|
|
1233
|
+
// 绝不显示"未知/正常",那等于把"没有这个信息"伪装成"一切正常"。
|
|
1234
|
+
// C-07c(2026-09-27):新后端把"没装上"也变成**有内容**的回答(`{installed:false, reason}`),
|
|
1235
|
+
// 于是这里必须先分"有意关闭"与"应为但未装" —— 后者(补丁没打)是真·静默失败,必须说出来。
|
|
1236
|
+
// §M:这一行改吃 store 里**每拍新写**的 audit(applyStateToStore :618)—— 它是最会误导人的一格
|
|
1237
|
+
// (日志早已停写、这里还挂着「✔ 正常」,比不显示更坏)。
|
|
1238
|
+
// ⚠️ 外层守卫**保持 `if (r.audit)` 字面不变**(tests/access-log.test.mjs:633 就钉着这一条):
|
|
1239
|
+
// 它的语义是"打开那一刻的应答里有没有这项" ⇒ 老后端一个字符都不渲染,不拿"未知"冒充"正常"。
|
|
1240
|
+
// 两个视图的 audit 出自同一个 handler,故 store 里有没有 ⇒ 这份应答里也有没有。
|
|
1241
|
+
var auditLive = !!cur.audit;
|
|
1242
|
+
var ah = cur.audit || r.audit;
|
|
1243
|
+
if (r.audit) {
|
|
1244
|
+
if (ah.installed === false) {
|
|
1245
|
+
var why = { 'disabled-by-config': '已按配置关闭', 'patch-missing': '补丁未应用', 'not-mounted': '未挂载' }[ah.reason] || ('原因未知(' + ah.reason + ')');
|
|
1246
|
+
lines.push('访问日志: 未启用(' + why + ')');
|
|
1247
|
+
} else if (ah.state === 'degraded') {
|
|
1248
|
+
var le = ah.lastError || {};
|
|
1249
|
+
lines.push('访问日志: ⚠ 已停写! · 最后错误 ' + (le.code || le.message || '(无)') + (le.at ? ' @' + fmtClock(le.at) : '') + ' · 丢弃 ' + (ah.lost || 0));
|
|
1250
|
+
} else if (ah.state === 'off') {
|
|
1251
|
+
lines.push('访问日志: 已按配置关闭');
|
|
1252
|
+
} else {
|
|
1253
|
+
lines.push('访问日志: ✔ 正常 · 写入 ' + (ah.writes || 0) + ' · 丢弃 ' + (ah.lost || 0));
|
|
1254
|
+
}
|
|
1255
|
+
// 上面四行印的是 store 那一份(实时);store 里没有(老后端 / 首拍还没回来)才退回打开时那份 ⇒ 标快照。
|
|
1256
|
+
if (!auditLive) lines[lines.length - 1] += SNAP_TAG;
|
|
1257
|
+
}
|
|
1258
|
+
// E2 可见化(2026-10-01):原文表里出现了**我没见过的来源**时,如实说一句人话。
|
|
1259
|
+
// 后端(api.js rawUnknownSourcesOf)把 kv 里那条留痕翻成**一整句话**放在 `text` 里,
|
|
1260
|
+
// 这里**原样**印出来(前面加一个 ⚠)—— **不在这里拆字段拼第二遍**:两处各拼一遍必然漂,
|
|
1261
|
+
// 而"读数怎么算"的规矩是单一来源(见 guard.test.mjs 的 E2 护栏)。
|
|
1262
|
+
// ⚠️ 没有告警时后端**整个字段都不给** ⇒ 这里一个字符都不渲染(与上面 audit 同一条规矩:
|
|
1263
|
+
// 绝不拿"未知/正常"冒充读数)。老后端同理:字段缺失 = 静默。
|
|
1264
|
+
// ⚠️ 只加这一行:不动任何既有行、不动徽标/布局/文案风格(主人原话"告诉前台到底发生啥了就行")。
|
|
1265
|
+
var ru = cur.rawUnknownSources || r.rawUnknownSources;
|
|
1266
|
+
if (ru && ru.text) lines.push('⚠ ' + ru.text + (cur.rawUnknownSources ? '' : SNAP_TAG));
|
|
1267
|
+
// §M:以下一整块(连面板标题里那个自称)**只有打开那一刻那一份** —— 用一行分隔说明覆盖到面板末尾,
|
|
1268
|
+
// 比逐行贴标签干净(标题在上面的 h3 里,同属这份应答)。
|
|
647
1269
|
lines = lines.concat([
|
|
648
|
-
'',
|
|
1270
|
+
'—— 以下为打开时快照(标题·人格·L0 预览·记忆库·L1;面板停留期间不自动刷新)——',
|
|
649
1271
|
'人格: 自称=' + ((r.persona && r.persona.aiName) || '(无)') + ' 称呼=' + ((r.persona && r.persona.userTitle) || '(无)') + ' 语气=' + ((r.persona && r.persona.tone) || 'natural') +
|
|
650
1272
|
' · ' + ((r.persona && r.persona.sealed) ? '🔒已定型' : '未定型'),
|
|
651
1273
|
'L0 注入预览:',
|
|
@@ -665,8 +1287,175 @@
|
|
|
665
1287
|
lines.push(' (无概述数据 — 待 v0 历史语料导入)');
|
|
666
1288
|
}
|
|
667
1289
|
kv.textContent = lines.join('\n');
|
|
668
|
-
|
|
669
|
-
}
|
|
1290
|
+
paintE3(r, cur, fmtClock); // E3:「那一行」只读块(同一份面板档应答 + store 里的 live running)
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** E3(2026-09-27):「那一行」在盘上有**两条**,长得像但不是一回事 ——
|
|
1294
|
+
* ① L2 `[记忆·开场]` 里本会话那一条(概述器写,只有标题);② E0 活行(`agent/pre-step` 追加式)。
|
|
1295
|
+
* 这里两条都显示、各自标注,并把「为什么没变」拆成四类**看得见**的读数。
|
|
1296
|
+
* ⚠️ 纯渲染:不改 store、不发请求(面板内那一发请求仍只有打开时的那一发 state 面板档,这里一个都不新增 ——
|
|
1297
|
+
* 加了就等于把刚降下来的轮询又加回去)。
|
|
1298
|
+
* ⚠️ 数字纪律(台账 B66):下面每个数字都来自**真实字段**(计数/字符数/时刻/轮次);
|
|
1299
|
+
* 没有一处是"总数 − 写死常数"那种恒等于某值的假读数 —— 每个数都随真实发生量变。
|
|
1300
|
+
* ⚠️ 容错:后端(或老版本后端)少任何一个字段 ⇒ 那一行说"没有这个读数",**绝不崩、绝不猜**。 */
|
|
1301
|
+
function paintE3(r, cur, fmtClock) {
|
|
1302
|
+
try {
|
|
1303
|
+
// 缺陷① 修复:清空重建**之前**先留住上一拍那个 <pre> 与它的滚动位置(见 e3pre 处的根因说明)。
|
|
1304
|
+
var oldPre = e3pre;
|
|
1305
|
+
var keepTop = oldPre ? (Number(oldPre.scrollTop) || 0) : 0;
|
|
1306
|
+
e3box.textContent = ''; // 重建(折叠状态在闭包 e3Open 里,滚动位置在 e3pre / keepTop 里)
|
|
1307
|
+
var S = r.sessionLine || null; // 老后端 / 轮询档没有这一项 ⇒ null,下面每处都按缺失处理
|
|
1308
|
+
var snap2 = r.snapshot || null;
|
|
1309
|
+
var items = (r.memory && r.memory.l1 && r.memory.l1.items) || [];
|
|
1310
|
+
var selfRow = null;
|
|
1311
|
+
for (var k = 0; k < items.length; k++) {
|
|
1312
|
+
if (items[k] && items[k].self === true) { selfRow = items[k]; break; }
|
|
1313
|
+
}
|
|
1314
|
+
var row = function (text, cls) { e3box.appendChild(el('div', cls || 'e3-row', text)); };
|
|
1315
|
+
row('那一行(只读)—— 盘上有两条,长得像但不是一回事:一条讲"过去的自己",一条讲"这一轮的进展" · ' + E3_TAG, 'e3-h');
|
|
1316
|
+
if (global) {
|
|
1317
|
+
// 全局视图里这两条**必然**缺席(后端 global 分支写死 sessionId: null)⇒ 说出来,
|
|
1318
|
+
// 否则"按设计没有"与"坏了"在界面上长得一模一样(与上面 L2 那三行同一条规矩)。
|
|
1319
|
+
row('(全局视图无会话行 —— L2 本会话标题行与活行都是按会话统计的,进入某个会话才显示)');
|
|
1320
|
+
} else {
|
|
1321
|
+
// ---- ① L2:本会话那一行(**只有标题**) ----
|
|
1322
|
+
// `self` 由后端标(api.js e3L1Items:conv_id === sessionId 那一项);取不到就直说 ——
|
|
1323
|
+
// "本会话没进 L1"是**真读数**(L1 按权重/预算挑选),不是坏掉。
|
|
1324
|
+
row(E3_A);
|
|
1325
|
+
if (selfRow) {
|
|
1326
|
+
row(' ' + (selfRow.line || selfRow.title || '(无正文)'));
|
|
1327
|
+
var metaA = [];
|
|
1328
|
+
if (selfRow.category) metaA.push('分类=' + selfRow.category);
|
|
1329
|
+
if (selfRow.date) metaA.push('日期=' + selfRow.date);
|
|
1330
|
+
if (typeof selfRow.score === 'number') metaA.push('评分=' + selfRow.score.toFixed(2));
|
|
1331
|
+
if (metaA.length) row(' (' + metaA.join(' · ') + ')', 'e3-meta');
|
|
1332
|
+
} else {
|
|
1333
|
+
row(' 本会话未进 L1 —— 这一轮没被选进注入面(L1 按权重/预算挑选,不是坏了)', 'e3-why');
|
|
1334
|
+
}
|
|
1335
|
+
// ---- ② E0:活行(正文 + 上次真追加的时间与轮次) ----
|
|
1336
|
+
row(E3_B);
|
|
1337
|
+
// ⚠️ 这两个**不同源**的正文必须分开取名、各自标注 —— 绝不合并成一个变量,也绝不共用一个号码。
|
|
1338
|
+
// 旧写法 `S.text || (S.last && S.last.text)` 配标签 `S.ordinal` ⇒ "号码来自 A、正文来自 B":
|
|
1339
|
+
// 后端 HTTP 面板路径**取不到轮次**,session-line.js:561 `out.text = hasTurn ? … : null`
|
|
1340
|
+
// ⇒ 这条路径上 `S.text` **恒为 null**,而 :529 `ordinal = appendCount + 1` 照常给 ⇒
|
|
1341
|
+
// 面板上默认就是"标签 #6 + 正文 #5"这种自相矛盾的组合(而这块存在的唯一目的就是消灭歧义)。
|
|
1342
|
+
var nextText = (S && typeof S.text === 'string' && S.text) ? S.text : ''; // = 下一条会长这样
|
|
1343
|
+
var lastText = (S && S.last && typeof S.last.text === 'string' && S.last.text) ? S.last.text : ''; // = 上一次真追加的
|
|
1344
|
+
if (nextText) {
|
|
1345
|
+
// 号码 `S.ordinal` 与正文 `nextText` **同源**(都是"下一条")⇒ 这个组合是自洽的
|
|
1346
|
+
row(' 活行正文' + ((S && S.ordinal != null) ? '(#' + S.ordinal + ')' : '') + ' —— 下一条会长这样: ' + nextText);
|
|
1347
|
+
row(' (' + nextText.length + ' 字符 · 这一条是**预测**:后端知道轮次时才给;只有内容真变了才追加,故它可能连着好几轮不动 —— 那不代表机制失效)', 'e3-meta');
|
|
1348
|
+
} else {
|
|
1349
|
+
row(' 活行正文 —— 下一条会长这样: (后端没给 —— 面板这条路径取不到轮次,故不预测正文,也绝不用"上次那条"顶替)', 'e3-why');
|
|
1350
|
+
}
|
|
1351
|
+
if (lastText) {
|
|
1352
|
+
// 号码**从正文里取**(同源);取不到就**不印号码**(绝不印 undefined、绝不借 S.ordinal)
|
|
1353
|
+
var lastNo = lastNoOf(lastText);
|
|
1354
|
+
row(' 活行正文' + ((lastNo != null) ? '(#' + lastNo + ')' : '') + ' —— 上一次真追加的正文' + ((lastNo != null) ? '(号码取自正文)' : '(正文里没写号码)') + ': ' + lastText);
|
|
1355
|
+
row(' (' + lastText.length + ' 字符 · 只有内容真变了才追加,故这一条可能连着好几轮不动 —— 那不代表机制失效)', 'e3-meta');
|
|
1356
|
+
} else {
|
|
1357
|
+
row(' 活行正文 —— 上一次真追加: (无 —— 本次会话还没有真追加过活行)', 'e3-why');
|
|
1358
|
+
}
|
|
1359
|
+
var last = (S && S.last) || null;
|
|
1360
|
+
if (last && (last.at || last.turn != null || last.chars != null)) {
|
|
1361
|
+
var lp = [];
|
|
1362
|
+
if (last.at) lp.push('时间 ' + fmtClock(last.at));
|
|
1363
|
+
if (last.turn != null) lp.push('第 ' + last.turn + ' 轮');
|
|
1364
|
+
if (last.seq != null) lp.push('注入面 seq=' + last.seq);
|
|
1365
|
+
if (last.chars != null) lp.push(last.chars + ' 字符');
|
|
1366
|
+
row(' 上次真追加: ' + lp.join(' · '));
|
|
1367
|
+
if (last.at) row(' 距上次真追加: ' + Math.max(0, Math.round((Date.now() - Number(last.at)) / 60000)) + ' 分钟前(截至本次重画)', 'e3-meta');
|
|
1368
|
+
} else {
|
|
1369
|
+
row(' 上次真追加: (无记录 —— 本次会话还没有真追加过)', 'e3-meta');
|
|
1370
|
+
}
|
|
1371
|
+
// ---- 判定读数:会追加 / 被什么挡下 ----
|
|
1372
|
+
// 词表照抄后端**真会吐**的原因(session-line.js decideSessionLine :169-177);
|
|
1373
|
+
// 认不出来的原因**原样印出**,绝不翻译成"正常"、也绝不编一个后端不会说的词。
|
|
1374
|
+
var rsn = (S && S.blocked) ? String(S.blocked) : null;
|
|
1375
|
+
var known = rsn ? E3_BLOCK[rsn] : null;
|
|
1376
|
+
var basisTag = basisTagOf(S); // 这条读数是**带前提的预测** ⇒ 前提**恒随行**(下面"档位"行复述同一判定,也自带)
|
|
1377
|
+
var vd;
|
|
1378
|
+
if (S && S.wouldAppend === true) vd = '会追加 —— 内容真变了,下一拍就多一条新的(#N 递增)';
|
|
1379
|
+
else if (rsn && !known) vd = '认不出的判定("' + rsn + '")—— 后端给了这个词但前端未收录:既不说会追加、也不说不追加,原样印出';
|
|
1380
|
+
else if (rsn && E3_APPEND[rsn]) vd = '会追加 —— ' + known;
|
|
1381
|
+
else if (rsn && E3_HOLD[rsn]) vd = '本拍不判定 —— ' + known;
|
|
1382
|
+
else if (rsn) vd = '不追加 —— ' + known;
|
|
1383
|
+
else vd = '未判定(后端这一版还没带 wouldAppend / blocked 字段 ⇒ 不做任何猜测)';
|
|
1384
|
+
row(' 判定读数: ' + vd + ' ' + basisTag, (rsn === 'dupes') ? 'e3-why no' : 'e3-why');
|
|
1385
|
+
var c = (S && S.counts) || null;
|
|
1386
|
+
if (c) {
|
|
1387
|
+
var ct = ' 本会话累计(活行台账): 真追加 ' + (c.append || 0) + ' 次 · 被内容闸挡下 ' + (c.skippedSame || 0) + ' 次 · 脏(面上>1条) ' + (c.dupes || 0) + ' 次';
|
|
1388
|
+
// `skippedTier` 已无写入点(15min/10 轮双门撤除,见 session-line.js :344)⇒ 老库里那个数是**历史值**,
|
|
1389
|
+
// 只在 >0 时印出来并标明,不拿它冒充"档位还在生效"。
|
|
1390
|
+
if (c.skippedTier) ct += ' · 档位闸(已撤除)历史值 ' + c.skippedTier + ' 次';
|
|
1391
|
+
row(ct, 'e3-meta');
|
|
1392
|
+
}
|
|
1393
|
+
// ---- 为什么没变:四类判据,各一行人话(每条只印**真读数**) ----
|
|
1394
|
+
row('为什么没变(逐条判据):', 'e3-h');
|
|
1395
|
+
var running = (cur && cur.running !== undefined) ? !!cur.running : !!r.running;
|
|
1396
|
+
row(' 快照为什么没变:' + (running
|
|
1397
|
+
? '会话**运行中** ⇒ 快照不重建(人格/记忆更新要等空闲那一刻落盘;这一条**正在**挡着)'
|
|
1398
|
+
: '会话空闲 ⇒ 这一条**不构成**"没变"的原因(没有在挡它)'));
|
|
1399
|
+
row(' 深摘:' + (r.deepDone === true
|
|
1400
|
+
? '该会话**已深摘冻结**(kv `deep:<会话id>` = done:…)⇒ 本会话那一行(只有标题那条)不再更新'
|
|
1401
|
+
: (r.deepDone === false
|
|
1402
|
+
? '未深摘 ⇒ 这一条**不构成**"没变"的原因'
|
|
1403
|
+
: '后端这一版还没带 deepDone ⇒ 不做猜测')));
|
|
1404
|
+
row(' 本会话标题行为什么只有标题:' + ((selfRow && selfRow.line)
|
|
1405
|
+
? '这一行**只有标题**(公式里就没有摘要),进展由②活行承担 —— 两条不同源,**不是印了两遍,也不是 bug**'
|
|
1406
|
+
: '本会话那一行不在 L1 里 ⇒ L2 本会话标题行此刻为空,②活行照旧'));
|
|
1407
|
+
row(' 档位:' + (known
|
|
1408
|
+
? ('本拍被判定为「' + (E3_APPEND[rsn] ? '会追加' : (E3_HOLD[rsn] ? '本拍不判定' : '不追加')) + '」—— ' + known + ';另外:15 分钟 / 10 轮那道**档位门已撤除**(追加只看"内容真变了没")')
|
|
1409
|
+
: ((rsn && !known)
|
|
1410
|
+
? ('后端给了个前端未收录的原因("' + rsn + '")⇒ 不在这里翻译;另外:15 分钟 / 10 轮那道**档位门已撤除**')
|
|
1411
|
+
: '**没有档位门**了(15 分钟 / 10 轮双门已撤除:追加只看"内容真变了没")—— 所以这里没有"距下次还差多久"可算,不编一个')) + ' ' + basisTag, 'e3-why');
|
|
1412
|
+
// ---- 看全文:模型那一轮真正读到的**冻结快照原文**(面板档 snapshot.text;纯读,对模型上下文零占用) ----
|
|
1413
|
+
var stext = (snap2 && typeof snap2.text === 'string') ? snap2.text : null;
|
|
1414
|
+
if (stext === null) {
|
|
1415
|
+
row('看全文: (这一版后端 / 这一条路径没带 snapshot.text ⇒ 看不到原文;不拿别的东西冒充)', 'e3-meta');
|
|
1416
|
+
} else {
|
|
1417
|
+
var fh = el('div', 'e3-fold-h');
|
|
1418
|
+
var tagS = el('span', 'tag', e3Open ? ' [收起]' : ' [展开]');
|
|
1419
|
+
fh.appendChild(document.createTextNode('看全文 —— 模型那一轮真正读到的注入面原文(纯读:对模型上下文零占用)'));
|
|
1420
|
+
fh.appendChild(tagS);
|
|
1421
|
+
e3box.appendChild(fh);
|
|
1422
|
+
var pre = oldPre || el('pre', 'e3-pre', stext); // **复用**上一拍那个节点(节点换掉 = 滚动位置必丢)
|
|
1423
|
+
if (pre.textContent !== stext) pre.textContent = stext; // 只有原文真变了才写(内容没变时连文本都不动 ⇒ 框里选中的文字也不丢)
|
|
1424
|
+
pre.style.display = e3Open ? 'block' : 'none'; // 默认**收起**
|
|
1425
|
+
e3box.appendChild(pre);
|
|
1426
|
+
pre.scrollTop = keepTop; // 写回:节点离开过文档,浏览器会把滚动位置清掉,这里恢复
|
|
1427
|
+
e3pre = pre;
|
|
1428
|
+
fh.onclick = function () {
|
|
1429
|
+
e3Open = !e3Open;
|
|
1430
|
+
pre.style.display = e3Open ? 'block' : 'none';
|
|
1431
|
+
tagS.textContent = e3Open ? ' [收起]' : ' [展开]';
|
|
1432
|
+
};
|
|
1433
|
+
row(' 这份是什么时候的: ' + (snap2.builtAt ? (fmtClock(snap2.builtAt) + ' 定稿') : '(后端未记录定稿时刻)')
|
|
1434
|
+
+ ' · 正文 ' + ((snap2.chars != null) ? snap2.chars : stext.length) + ' 字符'
|
|
1435
|
+
+ (snap2.truncated ? ('(⚠ 超长:这里只给前 ' + stext.length + ' 字符,上限保护)') : '')
|
|
1436
|
+
+ ' · ' + (snap2.stale ? '已标待刷新(运行中不重建)' : '未标待刷新'), 'e3-meta');
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
} catch (e) {
|
|
1440
|
+
// 只读块出错绝不许把面板带崩(它只是"看得见",不是面板的命脉):退化成一行自述。
|
|
1441
|
+
try {
|
|
1442
|
+
e3box.textContent = '';
|
|
1443
|
+
e3box.appendChild(el('div', 'e3-why no', '「那一行」只读块渲染失败:' + String((e && e.message) || e) + '(面板其余部分不受影响)'));
|
|
1444
|
+
} catch (e2) { /* 连退化行都写不进去就只能放弃 */ }
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// 订阅 store(**不是**新轮询):面板开着就跟着 tick 重画,关掉必须退订。
|
|
1449
|
+
// 「关闭」按钮 / Esc / 点面板外 / 再开别的浮层 —— **四条路全走 closeOverlays()**,
|
|
1450
|
+
// 它遍历 .dsh-ling-panel 调节点上的 __lingCleanup(见 :293-301)⇒ 退订挂在那里 = 四条路都退订。
|
|
1451
|
+
// ⚠️ 上面 initPanelDrag() 已在同一节点装过 __lingCleanup(卸载拖动监听,:584),必须**链式追加**,
|
|
1452
|
+
// 直接赋值会把拖动监听的清理顶掉(与记忆中心那处 :1797-1804 同一个坑)。
|
|
1453
|
+
var unsubscribe = store.subscribe(function () { paint(); });
|
|
1454
|
+
var prevCleanup = wrap.__lingCleanup;
|
|
1455
|
+
wrap.__lingCleanup = function () {
|
|
1456
|
+
if (typeof prevCleanup === 'function') { try { prevCleanup(); } catch (e) { console.warn('[dsh-ling] 面板清理失败', e); } }
|
|
1457
|
+
unsubscribe();
|
|
1458
|
+
};
|
|
670
1459
|
close.onclick = closeOverlays;
|
|
671
1460
|
}
|
|
672
1461
|
|
|
@@ -677,7 +1466,7 @@
|
|
|
677
1466
|
var wrap = el('div', 'dsh-ling-panel');
|
|
678
1467
|
wrap.dataset.dark = dark();
|
|
679
1468
|
var h = el('h3', null, '人格中心 · L0(称呼 / 自称 / 规矩·习惯·底线)');
|
|
680
|
-
h.appendChild(el('div', 'hint', '保存即对空闲会话生效;运行中会话在空闲后生效
|
|
1469
|
+
h.appendChild(el('div', 'hint', '保存即对空闲会话生效;运行中会话在空闲后生效'));
|
|
681
1470
|
wrap.appendChild(h);
|
|
682
1471
|
|
|
683
1472
|
var form = el('div', 'ling-form');
|
|
@@ -686,7 +1475,8 @@
|
|
|
686
1475
|
var sealedNow = false; // 服务端定型状态
|
|
687
1476
|
var unlocked = false; // 本次会话承诺已验证(仅存于内存)
|
|
688
1477
|
var unlockPhrase = '';
|
|
689
|
-
var sealPhrase = ''; // 定型承诺句明文(
|
|
1478
|
+
var sealPhrase = ''; // 定型承诺句明文(**服务端只对本机直连回显**;经反代/远端打开时为空)
|
|
1479
|
+
var sealHidden = false; // 服务端明说"这句只在本机显示"⇒ 解锁框走优雅退化文案,而不是报错/空白
|
|
690
1480
|
|
|
691
1481
|
function row(label, node) {
|
|
692
1482
|
var r = el('div', 'row');
|
|
@@ -710,7 +1500,8 @@
|
|
|
710
1500
|
if (!value) return;
|
|
711
1501
|
var line = el('div', 'ling-rule-h');
|
|
712
1502
|
line.style.cssText = 'display:flex;gap:6px;align-items:flex-start;flex-wrap:wrap;width:100%;margin:1px 0 4px;font-size:12.5px;opacity:.92;';
|
|
713
|
-
|
|
1503
|
+
// label 是界面字面量(名字/自述/职责)⇒ 整句一起过 she(),这一整段才会进 SHE_VALS 而在换代词时重画
|
|
1504
|
+
line.appendChild(el('span', null, she('她建议 · ' + label + ':')));
|
|
714
1505
|
var valSpan = el('span', null, String(value));
|
|
715
1506
|
valSpan.style.cssText = 'flex:1 1 260px;min-width:0;word-break:break-word;white-space:pre-wrap;line-height:1.5;';
|
|
716
1507
|
var bFill = el('button', null, '填入');
|
|
@@ -881,6 +1672,17 @@
|
|
|
881
1672
|
bHabits.appendChild(refs.habitsBox);
|
|
882
1673
|
form.appendChild(bHabits);
|
|
883
1674
|
|
|
1675
|
+
var bDuty = el('div', 'block');
|
|
1676
|
+
var dHead = el('div', 'ling-rule-h');
|
|
1677
|
+
dHead.appendChild(el('span', 't', '职业 / 职责 / 责任'));
|
|
1678
|
+
dHead.appendChild(el('span', null, '改变率很低的一条 · 建议写成陈述句(我承担/我擅长/我能给出),不要写"我应当/我必须"这类被别人授权的说法;空 = 不注入'));
|
|
1679
|
+
bDuty.appendChild(dHead);
|
|
1680
|
+
refs.duty = input('textarea', '', 4);
|
|
1681
|
+
bDuty.appendChild(refs.duty);
|
|
1682
|
+
form.appendChild(bDuty);
|
|
1683
|
+
// 诞生仪式草稿:职责同样走"她建议…[填入]"(人审终审仍在此,机制不落盘)
|
|
1684
|
+
if (prefill && prefill.duty) renderGenesisSuggest(bDuty, '职责', prefill.duty, function () { return refs.duty; });
|
|
1685
|
+
|
|
884
1686
|
var bLore = el('div', 'block');
|
|
885
1687
|
bLore.appendChild(el('div', 'h', '扩展设定 / 世界观(器灵档案等,原样注入)'));
|
|
886
1688
|
refs.extraLore = input('textarea', '', 7);
|
|
@@ -985,6 +1787,7 @@
|
|
|
985
1787
|
language: langSel.value,
|
|
986
1788
|
hardRules: linesOf(refs.hardRules),
|
|
987
1789
|
bottomLines: linesOf(refs.bottomLines).slice(0, 5),
|
|
1790
|
+
duty: refs.duty.value.trim(),
|
|
988
1791
|
extraLore: refs.extraLore.value.trim(),
|
|
989
1792
|
},
|
|
990
1793
|
styles: {
|
|
@@ -1018,7 +1821,7 @@
|
|
|
1018
1821
|
|
|
1019
1822
|
function inputLock(locked) {
|
|
1020
1823
|
[enabledBox, toneSel, toneWorkSel, toneLifeSel, langSel, refs.userTitle, refs.aiName, refs.aiTitle, refs.hardRules, refs.bottomLines,
|
|
1021
|
-
refs.extraLore, refs.stylesWork, refs.stylesLife].forEach(function (n) { n.disabled = locked; });
|
|
1824
|
+
refs.duty, refs.extraLore, refs.stylesWork, refs.stylesLife].forEach(function (n) { n.disabled = locked; });
|
|
1022
1825
|
// 代词同属"契约类":定型后改动需手术(与 自称/称呼 同规格)
|
|
1023
1826
|
pronounSel.disabled = locked;
|
|
1024
1827
|
pronounInput.disabled = locked;
|
|
@@ -1108,7 +1911,9 @@
|
|
|
1108
1911
|
sealBar.appendChild(bRelock);
|
|
1109
1912
|
sealBar.appendChild(bUnseal);
|
|
1110
1913
|
} else {
|
|
1111
|
-
|
|
1914
|
+
// §6.1(a):"界面会回显原句"只在**本机**成立 ⇒ 远端(经反代打开)时补一句说清,而不是让主人以为界面坏了
|
|
1915
|
+
st.textContent = she('🌱 她已自由生长中 —— 若需对她做手术,亲手敲出当初的承诺句即可开启(≥10 字;输入框禁粘贴)。')
|
|
1916
|
+
+ (sealHidden ? '承诺句只在本机显示,人格手术也请在本机做。' : '');
|
|
1112
1917
|
var bUnlock = el('button', null, '🔓 对人格做手术…');
|
|
1113
1918
|
bUnlock.onclick = doUnlock;
|
|
1114
1919
|
sealBar.appendChild(st);
|
|
@@ -1125,12 +1930,16 @@
|
|
|
1125
1930
|
}
|
|
1126
1931
|
function doUnlock() {
|
|
1127
1932
|
var hintPhrase = sealPhrase;
|
|
1933
|
+
// §6.1(a) 优雅退化:远端页面拿不到回显 ⇒ 说清"这是渠道问题"并给出可执行的路,
|
|
1934
|
+
// 绝不因此报错或留空(旧文案会把它误说成"旧档案没记录明文"—— 那是另一回事)。
|
|
1128
1935
|
lingAsk({
|
|
1129
1936
|
title: '对人格做手术(仪式)',
|
|
1130
1937
|
body: (hintPhrase
|
|
1131
1938
|
? '你定型时写下的承诺句是:\n「' + hintPhrase + '」\n\n手术前请郑重地亲手敲一遍以确认(输入框禁粘贴,逐字敲出)。'
|
|
1132
|
-
:
|
|
1133
|
-
|
|
1939
|
+
: (sealHidden
|
|
1940
|
+
? '承诺句只在本机显示 —— 这个页面是经反代/远端打开的,所以看不到提示句。\n\n人格手术也请回到运行器灵的这台电脑上做(远端一律不给)。若你人就在本机,用本机地址(如 127.0.0.1:3080)打开面板即可看到提示句。'
|
|
1941
|
+
: '档案已定型,但旧档案未记录明文承诺。输入一句 ≥10 字的新承诺,它将被采纳为定型承诺句并开启本次手术。\n\n若想重新立誓,也请在下面输入你的新承诺。')),
|
|
1942
|
+
placeholder: hintPhrase ? '手动敲出上方承诺句…' : (sealHidden ? '(远端页面看不到提示句 —— 请回本机操作)' : '写下新的定型承诺句…'),
|
|
1134
1943
|
minLen: 10, noPaste: true, okLabel: '郑重解锁',
|
|
1135
1944
|
}).then(function (v) {
|
|
1136
1945
|
if (!v) return;
|
|
@@ -1319,6 +2128,7 @@
|
|
|
1319
2128
|
refs.hardRules.value = (p.hardRules || []).join('\n');
|
|
1320
2129
|
renderHabits(p);
|
|
1321
2130
|
refs.bottomLines.value = (p.bottomLines || []).join('\n');
|
|
2131
|
+
refs.duty.value = p.duty || '';
|
|
1322
2132
|
refs.extraLore.value = p.extraLore || '';
|
|
1323
2133
|
refs.stylesWork.value = p.stylesWork || '';
|
|
1324
2134
|
refs.stylesLife.value = p.stylesLife || '';
|
|
@@ -1329,7 +2139,8 @@
|
|
|
1329
2139
|
}
|
|
1330
2140
|
var wasSealed = sealedNow;
|
|
1331
2141
|
sealedNow = p.sealed === true;
|
|
1332
|
-
sealPhrase = p.sealPhrase || '';
|
|
2142
|
+
sealPhrase = p.sealPhrase || ''; // 本机直连才有值;远端为空(见下一行的标注)
|
|
2143
|
+
sealHidden = p.phraseHidden === true; // 服务端明说"这句只在本机显示"(经代理的读面)
|
|
1333
2144
|
if (!keepSession || (sealedNow && !wasSealed)) { unlocked = false; unlockPhrase = ''; }
|
|
1334
2145
|
blCount();
|
|
1335
2146
|
applyLockUI();
|
|
@@ -1338,7 +2149,7 @@
|
|
|
1338
2149
|
}).catch(function () { /* 状态不可达时保持表单现状 */ });
|
|
1339
2150
|
}
|
|
1340
2151
|
|
|
1341
|
-
var inputs = [enabledBox, toneSel, toneWorkSel, toneLifeSel, langSel, refs.userTitle, refs.aiName, refs.aiTitle, refs.hardRules, refs.bottomLines, refs.extraLore, refs.stylesWork, refs.stylesLife];
|
|
2152
|
+
var inputs = [enabledBox, toneSel, toneWorkSel, toneLifeSel, langSel, refs.userTitle, refs.aiName, refs.aiTitle, refs.hardRules, refs.bottomLines, refs.duty, refs.extraLore, refs.stylesWork, refs.stylesLife];
|
|
1342
2153
|
inputs.forEach(function (n) {
|
|
1343
2154
|
if (n.addEventListener) n.addEventListener('input', function () { schedulePreview(); blCount(); });
|
|
1344
2155
|
});
|
|
@@ -1374,7 +2185,7 @@
|
|
|
1374
2185
|
m.appendChild(d);
|
|
1375
2186
|
});
|
|
1376
2187
|
m.appendChild(el('div', 'sep'));
|
|
1377
|
-
m.appendChild(el('div', 'hint', st.kind === 'global' ? '
|
|
2188
|
+
m.appendChild(el('div', 'hint', st.kind === 'global' ? '无会话时管理全局默认;新会话自动跟随' : '运行中的会话不应用任何人格/记忆更新'));
|
|
1378
2189
|
// 先隐藏渲染量尺寸,再定方位(下方放不下自动向上翻,水平夹紧)
|
|
1379
2190
|
m.style.visibility = 'hidden';
|
|
1380
2191
|
document.body.appendChild(m);
|
|
@@ -1623,9 +2434,39 @@
|
|
|
1623
2434
|
(r.personaApplied ? ' · 人格已覆盖' : ''));
|
|
1624
2435
|
} else if (r && r.reason === 'sealed') {
|
|
1625
2436
|
toast('导入失败:人格已定型 — 请先到「人格中心」做手术(输入承诺句)后再覆盖导入;仅导入记忆不受影响');
|
|
1626
|
-
} else
|
|
2437
|
+
} else if (r && r.reason === 'too-large') {
|
|
2438
|
+
// B(2026-09-29,主人原话「文案改成人话,说明清楚发生啥了就行」):旧代码走的是最后那条
|
|
2439
|
+
// 兜底 —— `JSON.stringify(r).slice(0,120)` 把回执原文(含 `"reason":"too-large"` 这种
|
|
2440
|
+
// 机器话)直接糊到 toast 上,看得见字段、看不出"发生了什么、上限是多少、我该干什么"。
|
|
2441
|
+
// 这条文案必须说清三件事,且三件事**各有出处、都不许猜**:
|
|
2442
|
+
// ① 整包太大、**不是包坏了** —— 判据是服务端回的 `reason === 'too-large'`:
|
|
2443
|
+
// 它是**体量**闸门(readBody 的 4 MiB 上限),不是 JSON 解析失败/包结构坏;
|
|
2444
|
+
// ② 上限是多少 —— 判据是回执的 `limit`,用 fmtLimitMB 换算;**绝不写死**(见该函数注释);
|
|
2445
|
+
// ③ 怎么办 —— 换成**不含原文**的包重导:界面「⬇ 导出记忆包」走的 /export 默认
|
|
2446
|
+
// `includeRaw` 不为 1(`exportMemory()` 不带该参数)⇒ 那条路**本来就不含原文**、
|
|
2447
|
+
// 体积小得多,是主人自己能走通的一条路;并如实说明"整包一次发不完"是**当前通道的
|
|
2448
|
+
// 结构限制**(记忆包导入是整包一 POST,没有分批 —— 分批是 1.6 的活,不在这里顺手做)。
|
|
2449
|
+
// ⚠️ 体量报的是**这份文件多大**(f.size,缩进过的 JSON),与 limit(压缩后的请求体口径)不同尺,
|
|
2450
|
+
// 所以只说"这份包 ≈X / 整包超了上限"这个方向性事实,不假装两个数能直接相减。
|
|
2451
|
+
var sizeHint = (f && f.size > 0) ? '(≈' + fmtBytes(f.size) + ')' : '';
|
|
2452
|
+
toast('导入失败:这份包' + sizeHint + '整包超过了服务端单次上限 ' + fmtLimitMB(r.limit) + ' —— 不是包坏了。\n'
|
|
2453
|
+
+ '· 「导入记忆包」是把整份文件**一次**发出去,带对话原文的包很容易超过它(当前通道的结构限制:整包一次发不完)。\n'
|
|
2454
|
+
+ '· 怎么办:改用「⬇ 导出记忆包」重新导出一份**不含原文**的包(那个按钮默认就不含原文,体积小得多),再导入一次即可 —— 记忆/建议/队列都在,只是不含对话原文。',
|
|
2455
|
+
12000, true);
|
|
2456
|
+
} else {
|
|
2457
|
+
// 其余失败:优先用服务端给的 `message`(它是一句中文),只有连 message 都没有时
|
|
2458
|
+
// 才退回 JSON 摘要 —— 兜底可以有,但不许当主路(旧代码就是无条件 JSON)。
|
|
2459
|
+
var whyImp = r && (r.message || r.error || r.reason);
|
|
2460
|
+
toast('导入失败:' + (whyImp ? String(whyImp) : JSON.stringify(r || {}).slice(0, 120)));
|
|
2461
|
+
}
|
|
1627
2462
|
if (st.tab === 'overview') renderOverview();
|
|
1628
|
-
}).catch(function () {
|
|
2463
|
+
}).catch(function () {
|
|
2464
|
+
// 拿不到回执 ⇒ **不猜**成确定结论(网关真不可达 / 体量大到连接被直接断开,浏览器这一侧
|
|
2465
|
+
// 长得一模一样)⇒ 只说"无从判断" + 一条已知可能 + 一条主人能自己走的路。
|
|
2466
|
+
toast('导入失败(网关不可达):没拿到回执,具体原因无从判断。\n'
|
|
2467
|
+
+ '已知的一种可能:这份包太大 —— 数据量大到超出服务端的排空余量时,连接会被直接断开,连说明情况的回执都送不出来。\n'
|
|
2468
|
+
+ '可以先改用「⬇ 导出记忆包」重导一份不含原文的小包,再试一次。', 12000, true);
|
|
2469
|
+
});
|
|
1629
2470
|
} catch (e) {
|
|
1630
2471
|
toast('文件不是有效的记忆包 JSON');
|
|
1631
2472
|
}
|
|
@@ -2800,15 +3641,20 @@
|
|
|
2800
3641
|
var pollTimer = null;
|
|
2801
3642
|
var draftIntro = null; // {aiTitle}
|
|
2802
3643
|
var draftName = null; // {aiName}
|
|
3644
|
+
var draftDuty = null; // {duty}(第 5 键 duties → 单选一条;仍是草稿,终审在人格中心)
|
|
2803
3645
|
var draftHints = []; // {label,text}(语气建议/相处观察)
|
|
2804
3646
|
var hintBtns = [];
|
|
2805
3647
|
var introBtns = [];
|
|
2806
3648
|
var nameBtns = [];
|
|
2807
|
-
var
|
|
3649
|
+
var dutyBtns = [];
|
|
3650
|
+
// ⚠️ 必须**每次现取**:这个标签在异步流程里会被反复写回按钮(见 runBatch/poll/finishBatch)。
|
|
3651
|
+
// 存成一个常量 = 把旧代词钉死在里面,重画过的按钮会被下一次赋值再写回旧代词。
|
|
3652
|
+
function RUN_LABEL() { return she('让她读未读过的会话'); }
|
|
2808
3653
|
function draftSummary() {
|
|
2809
3654
|
var parts = [];
|
|
2810
3655
|
if (draftIntro) parts.push('自述(1)');
|
|
2811
3656
|
if (draftName) parts.push('名字:' + draftName.aiName);
|
|
3657
|
+
if (draftDuty) parts.push('职责:' + draftDuty.duty);
|
|
2812
3658
|
if (draftHints.length) parts.push(she('她的话:') + draftHints.map(function (h) { return h.label; }).join('+'));
|
|
2813
3659
|
return parts.length ? parts.join(' · ') : '';
|
|
2814
3660
|
}
|
|
@@ -2834,24 +3680,25 @@
|
|
|
2834
3680
|
resC.textContent = she('她已读过(或无需浓缩)这段历史里的所有会话 —— 让她试着介绍自己吧。');
|
|
2835
3681
|
return;
|
|
2836
3682
|
}
|
|
2837
|
-
bRun.textContent = RUN_LABEL;
|
|
3683
|
+
bRun.textContent = RUN_LABEL();
|
|
2838
3684
|
resC.textContent = '✗ ' + ((r && r.message) || '启动失败');
|
|
2839
3685
|
return;
|
|
2840
3686
|
}
|
|
2841
3687
|
poll();
|
|
2842
3688
|
}).catch(function () {
|
|
2843
3689
|
bRun.disabled = false;
|
|
2844
|
-
bRun.textContent = RUN_LABEL;
|
|
3690
|
+
bRun.textContent = RUN_LABEL();
|
|
2845
3691
|
resC.textContent = '✗ 启动失败(网关不可达)';
|
|
2846
3692
|
});
|
|
2847
3693
|
}
|
|
2848
3694
|
function poll() {
|
|
2849
3695
|
api('/deep/run-status').then(function (st) {
|
|
2850
|
-
if (!st || !st.ok) { bRun.disabled = false; bRun.textContent = RUN_LABEL; resC.textContent = '状态不可达,请重试'; return; }
|
|
3696
|
+
if (!st || !st.ok) { bRun.disabled = false; bRun.textContent = RUN_LABEL(); resC.textContent = '状态不可达,请重试'; return; }
|
|
2851
3697
|
if (st.running) {
|
|
2852
3698
|
var doneN = st.done ? st.done.length : 0;
|
|
2853
3699
|
var pct = st.total > 0 ? Math.round((doneN / st.total) * 100) : 0;
|
|
2854
|
-
|
|
3700
|
+
// 整句一起过 she()(中间两个是数字)⇒ 这一段进 SHE_VALS,换代词时这条读数就地重画
|
|
3701
|
+
resC.textContent = she('她正在读这段历史… 已完成 ' + doneN + ' / 本轮共 ' + st.total + ' · ' + pct + '%(每条约几秒,请稍候)');
|
|
2855
3702
|
pollTimer = setTimeout(poll, 1500);
|
|
2856
3703
|
return;
|
|
2857
3704
|
}
|
|
@@ -2862,7 +3709,7 @@
|
|
|
2862
3709
|
}
|
|
2863
3710
|
function finishBatch(st) {
|
|
2864
3711
|
bRun.disabled = false;
|
|
2865
|
-
bRun.textContent = RUN_LABEL;
|
|
3712
|
+
bRun.textContent = RUN_LABEL();
|
|
2866
3713
|
var s = statusLine(st);
|
|
2867
3714
|
var lines = ['✓ 读完 ' + st.total + ' 条长会话:成功 ' + s.okN + ' 条' + (s.bad ? ' · ' + s.bad : '')];
|
|
2868
3715
|
lines.push(s.okN > 0
|
|
@@ -2880,6 +3727,7 @@
|
|
|
2880
3727
|
genOut.textContent = '';
|
|
2881
3728
|
draftIntro = null;
|
|
2882
3729
|
draftName = null;
|
|
3730
|
+
draftDuty = null;
|
|
2883
3731
|
draftHints = [];
|
|
2884
3732
|
hintBtns = [];
|
|
2885
3733
|
api('/persona/genesis', { method: 'POST', body: { scope: selScope.value } }).then(function (r) {
|
|
@@ -2887,8 +3735,8 @@
|
|
|
2887
3735
|
bGen.textContent = old;
|
|
2888
3736
|
if (!r || !r.ok) { resC.textContent = '✗ ' + ((r && r.message) || '仪式失败'); return; }
|
|
2889
3737
|
resC.textContent = (r.scope === 'all'
|
|
2890
|
-
? she('她翻遍了全部记忆,按生活/情感加权选出 '
|
|
2891
|
-
: she('她读了刚导入的这段历史('
|
|
3738
|
+
? she('她翻遍了全部记忆,按生活/情感加权选出 ' + r.sampled + ' 条样本 —— 以下是她想对你说的话;挑中意的加入草稿,带去人格中心人审:')
|
|
3739
|
+
: she('她读了刚导入的这段历史(' + r.sampled + ' 条)—— 以下是她想对你说的话;挑中意的加入草稿,带去人格中心人审:'));
|
|
2892
3740
|
renderGenesis(r.result);
|
|
2893
3741
|
}).catch(function () { bGen.disabled = false; bGen.textContent = old; resC.textContent = '✗ 仪式失败(网关)'; });
|
|
2894
3742
|
};
|
|
@@ -2897,6 +3745,7 @@
|
|
|
2897
3745
|
genOut.textContent = '';
|
|
2898
3746
|
introBtns = [];
|
|
2899
3747
|
nameBtns = [];
|
|
3748
|
+
dutyBtns = [];
|
|
2900
3749
|
hintBtns = [];
|
|
2901
3750
|
var rst = result;
|
|
2902
3751
|
function head(t) {
|
|
@@ -2973,9 +3822,44 @@
|
|
|
2973
3822
|
genOut.appendChild(line);
|
|
2974
3823
|
});
|
|
2975
3824
|
}
|
|
3825
|
+
// 第 5 类草稿:职责(1.5.2 I-5)。起草权在她,终审在你 —— 这里只做候选挑选,
|
|
3826
|
+
// 选中的仍只是草稿,要带去人格中心填进「职业 / 职责 / 责任」并保存才算数(约束 A:机制不落盘)。
|
|
3827
|
+
if (rst.duties && rst.duties.length) {
|
|
3828
|
+
genOut.appendChild(head(she('她承担的/擅长的事(单选一条加入草稿)')));
|
|
3829
|
+
var dutyPicked = [false, false, false];
|
|
3830
|
+
rst.duties.forEach(function (d, i) {
|
|
3831
|
+
var line = el('div', 'ling-draft-item');
|
|
3832
|
+
line.style.cssText = 'display:flex;gap:8px;align-items:flex-start;margin:5px 0;font-size:13px;';
|
|
3833
|
+
var txt = el('span', null, d);
|
|
3834
|
+
txt.style.cssText = 'flex:1;line-height:1.6;opacity:.95;white-space:pre-wrap;';
|
|
3835
|
+
var bt = el('button', null, '加入草稿');
|
|
3836
|
+
bt.onclick = function () {
|
|
3837
|
+
if (dutyPicked[i]) {
|
|
3838
|
+
dutyPicked[i] = false;
|
|
3839
|
+
if (draftDuty && draftDuty.duty === d) draftDuty = null;
|
|
3840
|
+
} else {
|
|
3841
|
+
dutyPicked.forEach(function (_, j) { dutyPicked[j] = j === i; });
|
|
3842
|
+
draftDuty = { duty: d };
|
|
3843
|
+
}
|
|
3844
|
+
dutyPicked.forEach(function (p, j) {
|
|
3845
|
+
var btn = dutyBtns[j];
|
|
3846
|
+
if (btn) setPickState(btn, p);
|
|
3847
|
+
});
|
|
3848
|
+
refreshDraftBar();
|
|
3849
|
+
};
|
|
3850
|
+
dutyBtns.push(bt);
|
|
3851
|
+
line.appendChild(txt);
|
|
3852
|
+
line.appendChild(bt);
|
|
3853
|
+
genOut.appendChild(line);
|
|
3854
|
+
});
|
|
3855
|
+
var dutyNote = el('div', 'm-meta', '');
|
|
3856
|
+
dutyNote.style.cssText = 'margin:2px 0 4px;opacity:.8;';
|
|
3857
|
+
dutyNote.textContent = she('若某条读起来像"我应当/我必须/我的任务/我的责任是/我被要求/请让我负责",那是授权口吻(把她写成"被授权者",不是"她自己是什么")—— 别人的话不该长在她身上,建议不选它。');
|
|
3858
|
+
genOut.appendChild(dutyNote);
|
|
3859
|
+
}
|
|
2976
3860
|
function hintRow(label, text) {
|
|
2977
3861
|
if (!text) return;
|
|
2978
|
-
genOut.appendChild(head(label +
|
|
3862
|
+
genOut.appendChild(head(she(label + '(可加入草稿,到人格中心后作为"她的话"记入习惯)')));
|
|
2979
3863
|
var line = el('div', 'ling-draft-item');
|
|
2980
3864
|
line.style.cssText = 'display:flex;gap:8px;align-items:flex-start;margin:5px 0;font-size:13px;';
|
|
2981
3865
|
var txt = el('span', null, text);
|
|
@@ -3008,6 +3892,7 @@
|
|
|
3008
3892
|
openPersonaEditor({
|
|
3009
3893
|
aiName: draftName ? draftName.aiName : undefined,
|
|
3010
3894
|
aiTitle: draftIntro ? draftIntro.aiTitle : undefined,
|
|
3895
|
+
duty: draftDuty ? draftDuty.duty : undefined,
|
|
3011
3896
|
hints: draftHints.slice(),
|
|
3012
3897
|
});
|
|
3013
3898
|
};
|
|
@@ -3016,7 +3901,7 @@
|
|
|
3016
3901
|
function refreshDraftBar() {
|
|
3017
3902
|
var t = draftSummary();
|
|
3018
3903
|
barTxt.textContent = t ? '草稿:' + t : '草稿:尚未选择';
|
|
3019
|
-
bCarry.disabled = !(draftIntro || draftName || draftHints.length);
|
|
3904
|
+
bCarry.disabled = !(draftIntro || draftName || draftDuty || draftHints.length);
|
|
3020
3905
|
}
|
|
3021
3906
|
var guide2 = el('div', 'm-meta', '');
|
|
3022
3907
|
guide2.style.cssText = 'margin-top:6px;opacity:.8;';
|
|
@@ -3057,7 +3942,7 @@
|
|
|
3057
3942
|
var doc = splitDoc(String(reader.result));
|
|
3058
3943
|
if (doc.note) resB.textContent = doc.note + '\n';
|
|
3059
3944
|
pendingItems = doc.items;
|
|
3060
|
-
resB.textContent = (resB.textContent || '') + '解析到会话 ' + doc.items.length + ' 条 —— 点击下方「确认导入」分批写入(
|
|
3945
|
+
resB.textContent = (resB.textContent || '') + '解析到会话 ' + doc.items.length + ' 条 —— 点击下方「确认导入」分批写入(按体量切批,单批 ≤' + (BATCH_MAX_BYTES / 1024 / 1024) + ' MB)。';
|
|
3061
3946
|
bGo.textContent = '确认导入(' + doc.items.length + ' 条)';
|
|
3062
3947
|
bGo.disabled = false;
|
|
3063
3948
|
} catch (e) {
|
|
@@ -3074,9 +3959,13 @@
|
|
|
3074
3959
|
var old = bGo.textContent;
|
|
3075
3960
|
var items = pendingItems;
|
|
3076
3961
|
pendingItems = null;
|
|
3077
|
-
|
|
3962
|
+
// tooBig = 单条自身超限被跳过的条数(C-1):**已知且可解释**,与 errors(意外)分开记,
|
|
3963
|
+
// 但必须在 finish() 汇总里如实显示 —— 不显示就是静默丢数据。
|
|
3964
|
+
// foldSkippedRaw(A,2026-09-30)= 折叠时"因为目标会话**已有原文**而**没有替换**的轮数":
|
|
3965
|
+
// 同一档"已知且可解释的跳过",同样必须说出来(finish() 里有一行专门交代它)。
|
|
3966
|
+
var totals = { newRows: 0, refreshed: 0, upgraded: 0, folded: 0, removedImport: 0, foldSkippedRaw: 0, accepted: 0, rejected: 0, degraded: 0, degradedReasons: {}, errors: 0, tooBig: 0 };
|
|
3078
3967
|
var rejPreview = [];
|
|
3079
|
-
var
|
|
3968
|
+
var sentBytes = 0; // 已送字节(累计;C2 双口径进度用)
|
|
3080
3969
|
// 一次导入 = 一条账:runId 贯穿全部批,at = 导入开始时刻(服务端固定后不再变动)
|
|
3081
3970
|
var runId = 'imp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
3082
3971
|
var startedAt = Date.now();
|
|
@@ -3087,14 +3976,33 @@
|
|
|
3087
3976
|
body: { runId: runId, at: startedAt, file: currentFileName, errors: 1 },
|
|
3088
3977
|
}).catch(function () {});
|
|
3089
3978
|
}
|
|
3979
|
+
// #10(2026-09-29):被跳过的"单条过大"要进**持久账本**(「接入历史 → 导入记录」)。
|
|
3980
|
+
// ⚠️ 语义:它记的是"**跳过**"、不是"失败" —— 所以这一笔**不带 `errors`**
|
|
3981
|
+
// (端点里 `errors` 的旧语义是 `max(1, ...)`:带上它就等于把"已知跳过"混成"失败批";
|
|
3982
|
+
// 而当初正是因为那条通路只认 errors,C-1 只能**故意不报** ⇒ 账本上完全看不出有东西被跳过)。
|
|
3983
|
+
// 名字与界面点名**同源**(同一个三级退路 title → name →「未命名」),最多 5 条(端点还会再截一次)。
|
|
3984
|
+
// 收尾时发**一次**(不是每条一发);失败不重试、不影响界面 —— 与 reportError 同款"尽力而为"。
|
|
3985
|
+
var skippedNames = [];
|
|
3986
|
+
function reportTooBig() {
|
|
3987
|
+
if (!totals.tooBig) return;
|
|
3988
|
+
api('/import/log', {
|
|
3989
|
+
method: 'POST',
|
|
3990
|
+
body: {
|
|
3991
|
+
runId: runId, at: startedAt, file: currentFileName,
|
|
3992
|
+
tooBig: totals.tooBig, tooBigNames: skippedNames.slice(0, 5),
|
|
3993
|
+
},
|
|
3994
|
+
}).catch(function () {});
|
|
3995
|
+
}
|
|
3090
3996
|
function next() {
|
|
3091
|
-
var chunk = items
|
|
3092
|
-
if (!chunk.length) return finish();
|
|
3093
|
-
idx
|
|
3094
|
-
|
|
3997
|
+
var chunk = takeChunk(items, idx); // C1:条数 × 字节双限(字节口径见 takeChunk 注释)
|
|
3998
|
+
if (!chunk.list.length) return finish();
|
|
3999
|
+
var chunkStart = idx; // C4:本批起始下标(0-based)⇒ 拒绝序号 = chunkStart + rj.i + 1
|
|
4000
|
+
idx = chunk.next;
|
|
4001
|
+
sentBytes += chunk.bytes;
|
|
4002
|
+
resB.textContent = '导入中…' + Math.min(idx, items.length) + '/' + items.length + ' 条 · 已送 ' + fmtBytes(sentBytes);
|
|
3095
4003
|
api('/import/file/batch', {
|
|
3096
4004
|
method: 'POST',
|
|
3097
|
-
body: { items: chunk, file: currentFileName, runId: runId, at: startedAt },
|
|
4005
|
+
body: { items: chunk.list, file: currentFileName, runId: runId, at: startedAt },
|
|
3098
4006
|
}).then(function (r) {
|
|
3099
4007
|
if (r && r.ok) {
|
|
3100
4008
|
totals.accepted += r.accepted || 0;
|
|
@@ -3103,6 +4011,8 @@
|
|
|
3103
4011
|
totals.upgraded += r.upgraded || 0;
|
|
3104
4012
|
totals.folded += r.folded || 0;
|
|
3105
4013
|
totals.removedImport += r.removedImport || 0;
|
|
4014
|
+
// A(2026-09-30):折叠时"目标已有原文 ⇒ 这一轮没替换"的计数,照 upgraded/folded 同一写法累加
|
|
4015
|
+
totals.foldSkippedRaw += r.foldSkippedRaw || 0;
|
|
3106
4016
|
totals.degraded += r.degraded || 0;
|
|
3107
4017
|
if (r.degradedReasons) {
|
|
3108
4018
|
Object.keys(r.degradedReasons).forEach(function (k) {
|
|
@@ -3112,14 +4022,46 @@
|
|
|
3112
4022
|
if (r.rejected && r.rejected.length) {
|
|
3113
4023
|
totals.rejected += r.rejected.length;
|
|
3114
4024
|
r.rejected.slice(0, 3).forEach(function (rj) {
|
|
3115
|
-
var at =
|
|
4025
|
+
var at = chunkStart + rj.i + 1; // C4:以【本批起始下标】为准(batch 已不存在)
|
|
3116
4026
|
if (rejPreview.length < 5) rejPreview.push('第 ' + at + ' 条:' + rj.reason);
|
|
3117
4027
|
});
|
|
3118
4028
|
}
|
|
3119
4029
|
} else {
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
4030
|
+
// C5:超限是可自愈的确定性失败,给可执行的人话(而不是让它烂成「(网关)」)。
|
|
4031
|
+
// 上限数字**取自服务端回执**(413 体里带 limit),不在这边再写死一个 ——
|
|
4032
|
+
// 两个地方各写一个"4 MB",将来改上限时必漂移;`4` 只作回执缺 limit 时的兜底。
|
|
4033
|
+
var limMb = Number(r && r.limit) > 0 ? Math.round(Number(r.limit) / 1024 / 1024) : 4;
|
|
4034
|
+
// C-1(2026-09-29):同一个 413 其实有两种,必须分开说 ——
|
|
4035
|
+
// ① 本批**只有 1 条** ⇒ 是【这一条自己】太大:takeChunk 已保证超限条单独成批,
|
|
4036
|
+
// 所以"再切细一点"物理上不存在(单条不可再分)。它是**已知且可解释**的,
|
|
4037
|
+
// 不是意外 ⇒ 记 totals.tooBig,不混进 errors;并且要点名报到哪一条、多大,
|
|
4038
|
+
// 再说清"这条跳过、其余照常导入"(后面照常 next(),绝不因一条太大中止整次导入)。
|
|
4039
|
+
// ② 本批**多于 1 条** ⇒ 是切批没生效(真异常)⇒ 沿用旧文案,计入 errors。
|
|
4040
|
+
// 判据只用**服务端回执**(reason==='too-large' + limit)与本批条数;
|
|
4041
|
+
// 客户端**不镜像**服务端的字节上限(将来放宽上限不用改这里)。
|
|
4042
|
+
if (r && r.reason === 'too-large' && chunk.list.length === 1) {
|
|
4043
|
+
var one = chunk.list[0] || {};
|
|
4044
|
+
var oneTitle = String(one.title || one.name || '').trim() || '未命名';
|
|
4045
|
+
var oneBytes = chunk.bytes; // 退路:本批自报字节(含封套近似)
|
|
4046
|
+
try { oneBytes = utf8Len(JSON.stringify(one)); } catch (e) { /* 循环引用等:用退路 */ }
|
|
4047
|
+
totals.tooBig += 1;
|
|
4048
|
+
if (skippedNames.length < 5) skippedNames.push(oneTitle); // #10:账本点名用(最多 5 条)
|
|
4049
|
+
if (rejPreview.length < 5) {
|
|
4050
|
+
rejPreview.push('单条过大跳过:「' + oneTitle + '」这一条 ' + fmtBytes(oneBytes) + '(' + oneBytes + ' B)'
|
|
4051
|
+
+ ' —— 它自己就在单次上限 ' + limMb + ' MB 之上,导入通道装不下(不是文件坏了,是这条太长,拆文件也拆不开它);'
|
|
4052
|
+
+ '这条已跳过,其余会话照常导入');
|
|
4053
|
+
}
|
|
4054
|
+
} else {
|
|
4055
|
+
totals.errors += 1;
|
|
4056
|
+
reportError();
|
|
4057
|
+
if (rejPreview.length < 5) {
|
|
4058
|
+
if (r && r.reason === 'too-large') {
|
|
4059
|
+
rejPreview.push('批失败:这一批太大(服务端单次上限 ' + limMb + ' MB),请把导出文件拆小后再导');
|
|
4060
|
+
} else {
|
|
4061
|
+
rejPreview.push('批失败:' + ((r && (r.message || r.error || r.reason)) || '未知'));
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
3123
4065
|
}
|
|
3124
4066
|
next();
|
|
3125
4067
|
}).catch(function () {
|
|
@@ -3134,7 +4076,12 @@
|
|
|
3134
4076
|
bGo.textContent = old;
|
|
3135
4077
|
// S5(2026-09-17 导入诚实化):「有壳无肉」时绝不说"✓ 导入完成"。
|
|
3136
4078
|
// 旧文案把"对话解析不出来"说成"降级为轻量",用户以为导入成功了,其实正文一条没进。
|
|
4079
|
+
// C-1(2026-09-29)按同一口径补一档:**有会话因单条过长被跳过**时,同样绝不说"✓ 导入完成"。
|
|
3137
4080
|
var shell = totals.degraded || 0;
|
|
4081
|
+
var skipped = totals.tooBig || 0;
|
|
4082
|
+
// A(2026-09-30):折叠时"因为目标会话已有原文而**没有替换**的轮数"。
|
|
4083
|
+
// 它不是失败、也没有丢东西(保住的是对方原有的真原文),但**必须说出来** —— 静默不可接受。
|
|
4084
|
+
var notReplaced = totals.foldSkippedRaw || 0;
|
|
3138
4085
|
var why = Object.keys(totals.degradedReasons).map(function (k) {
|
|
3139
4086
|
return k + ' ×' + totals.degradedReasons[k];
|
|
3140
4087
|
}).join(';');
|
|
@@ -3144,19 +4091,37 @@
|
|
|
3144
4091
|
shell + ' 条只有标题、没有对话正文');
|
|
3145
4092
|
lines.push(' 原因:' + (why || '文件里的对话格式没能认出来') +
|
|
3146
4093
|
' —— 这些记忆暂时只有壳,深摘也补不出内容。');
|
|
4094
|
+
} else if (skipped) {
|
|
4095
|
+
lines.push('⚠ 导入不完全:写入新记忆 ' + totals.newRows + ' 条 · 刷新已有 ' + totals.refreshed + ' 条,另有 ' +
|
|
4096
|
+
skipped + ' 条会话单条太长、没能通过导入通道(已跳过,见下)');
|
|
4097
|
+
lines.push(' 这不是文件坏了:是那几条会话**自己**就超过了单次上限 —— 拆小导出文件也拆不开单条。');
|
|
3147
4098
|
} else {
|
|
3148
4099
|
lines.push('✓ 导入完成:写入新记忆 ' + totals.newRows + ' 条 · 刷新已有 ' + totals.refreshed + ' 条' +
|
|
3149
4100
|
(totals.upgraded ? ' · 补全对话原文 ' + totals.upgraded + ' 条' : '') +
|
|
3150
4101
|
(totals.folded ? ' · 并入网页端历史 ' + totals.folded + ' 条(含对话原文)' : '') +
|
|
3151
4102
|
(totals.removedImport ? ' · 清理重复副本 ' + totals.removedImport + ' 条' : ''));
|
|
3152
4103
|
}
|
|
3153
|
-
lines.push(' 校验通过 ' + totals.accepted + ' · 拒绝 ' + totals.rejected + (totals.errors ? ' · 批错误 ' + totals.errors : ''));
|
|
4104
|
+
lines.push(' 校验通过 ' + totals.accepted + ' · 拒绝 ' + totals.rejected + (totals.errors ? ' · 批错误 ' + totals.errors : '') + (skipped ? ' · 单条过大跳过 ' + skipped : ''));
|
|
4105
|
+
// A(2026-09-30):折叠时"没替换"的那几轮必须有一行人话(静默不可接受)。
|
|
4106
|
+
// 措辞要点:① 说清**为什么**(该会话在网页端旧库里已经有原文);② 说清**不是失败、也没丢东西**
|
|
4107
|
+
// (保住的是原有的真原文,是**更安全**的那一边);③ 与 `⚠` 那两档分开 —— 那两档是真的不完全,这档不是。
|
|
4108
|
+
if (notReplaced) {
|
|
4109
|
+
lines.push(' ⓘ 另有 ' + notReplaced + ' 轮原文没有替换:这些会话在网页端旧库里**已经有原文**了 —— '
|
|
4110
|
+
+ '保留原有的那份、不覆盖它(不是失败,也没有丢东西)。');
|
|
4111
|
+
}
|
|
3154
4112
|
rejPreview.forEach(function (x) { lines.push(' ⚠ ' + x); });
|
|
3155
|
-
|
|
3156
|
-
|
|
4113
|
+
// 收尾句只在"全好"时打(S5/C-1 的诚实化口径)。
|
|
4114
|
+
// A(2026-09-30):有"没替换"的轮次同样不算全好 —— 那几轮的正文确实没进这一侧的库,
|
|
4115
|
+
// 不能一边少进一边说"她开始读这些日子了"。⚠️ 这一层**写成内层判断**而不是往上面那行
|
|
4116
|
+
// 守卫里再加一个 `&&`:那一行的字面形态被 `tests/client-batch.test.mjs:219` 的 T7 源码护栏
|
|
4117
|
+
// 逐字盯着(`!totals.errors && !skipped && totals.accepted && !shell`),往里塞条件会让那条
|
|
4118
|
+
// 既有断言变红 —— 而它守的正是同一个口径(有跳过就不打"全都好"的收尾句)。
|
|
4119
|
+
if (!totals.errors && !skipped && totals.accepted && !shell) {
|
|
4120
|
+
if (!notReplaced) lines.push(she('她开始读这些日子了 —— 深摘要会在后台按热度慢慢补齐。'));
|
|
3157
4121
|
}
|
|
3158
4122
|
resB.textContent = lines.join('\n');
|
|
3159
4123
|
renderImportLog();
|
|
4124
|
+
reportTooBig(); // #10:有跳过就记一笔账(不带 errors;见 reportTooBig 上方的注释)
|
|
3160
4125
|
}
|
|
3161
4126
|
next();
|
|
3162
4127
|
};
|
|
@@ -3344,7 +4309,14 @@
|
|
|
3344
4309
|
row.appendChild(yes); row.appendChild(no);
|
|
3345
4310
|
}
|
|
3346
4311
|
c.appendChild(row);
|
|
3347
|
-
if (amended && p.amendNote)
|
|
4312
|
+
if (amended && p.amendNote) {
|
|
4313
|
+
// 界面前缀与**数据尾巴**(她自己的改法说明,后端来的原文)必须分成两个文本节点:
|
|
4314
|
+
// 前缀能被 she() 重画、尾巴永远进不了 SHE_VALS ⇒ 换代词时数据一个字都不动。
|
|
4315
|
+
// (混在同一个节点里就只能二选一:要么数据被改,要么前缀永远停在旧代词。)
|
|
4316
|
+
var amLine = el('div', 'm-meta', she('她的话:'));
|
|
4317
|
+
amLine.appendChild(document.createTextNode(String(p.amendNote).slice(0, 80)));
|
|
4318
|
+
c.appendChild(amLine);
|
|
4319
|
+
}
|
|
3348
4320
|
frag.appendChild(c);
|
|
3349
4321
|
});
|
|
3350
4322
|
|
|
@@ -3512,13 +4484,15 @@
|
|
|
3512
4484
|
|
|
3513
4485
|
useEffect(function () {
|
|
3514
4486
|
var unsub = store.subscribe(function () { bump(function (x) { return x + 1; }); });
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
4487
|
+
// A+B:不再「一个组件实例一个 setInterval」—— 改订模块级共享 ticker
|
|
4488
|
+
// (同一个 store 一拍只发一次请求;后台停表、空闲降频都在那里)。
|
|
4489
|
+
// 退订即停表,与 mount 对称。
|
|
4490
|
+
var unpoll = subscribePoll(store);
|
|
3518
4491
|
return function () {
|
|
3519
4492
|
unsub();
|
|
3520
|
-
|
|
4493
|
+
unpoll();
|
|
3521
4494
|
if (hoverTimer.current) { clearTimeout(hoverTimer.current); hoverTimer.current = null; }
|
|
4495
|
+
hideTip();
|
|
3522
4496
|
};
|
|
3523
4497
|
}, []);
|
|
3524
4498
|
|
|
@@ -3528,24 +4502,55 @@
|
|
|
3528
4502
|
|
|
3529
4503
|
st = store.get();
|
|
3530
4504
|
var mode = st.mode || 'life';
|
|
3531
|
-
//
|
|
4505
|
+
// C-07 访问日志**停写**时的常驻警示 —— 唯一「不主动查也看得见」的形态(2026-09-27 静默 41.6 分钟那次)。
|
|
4506
|
+
// 只动 label 文本 / 文字颜色 / title / aria-label:不新增 DOM、不加动画、不改尺寸(这一格挤不起)。
|
|
4507
|
+
// audit 缺失(老后端)⇒ false ⇒ 与改动前逐字节相同。
|
|
4508
|
+
// C-07c(2026-09-27):判据从"只有 degraded"扩成两类 ——
|
|
4509
|
+
// ① state==='degraded'(写了但停写);
|
|
4510
|
+
// ② installed===false **且** reason 是 patch-missing / not-mounted(应为但未装 = 静默失败)。
|
|
4511
|
+
// ⚠️ 'disabled-by-config' **不染红**:那是主人有意关的。天天挂个假警报,红点就没人看了。
|
|
4512
|
+
var auditOffBad = !!(st.audit && st.audit.installed === false
|
|
4513
|
+
&& (st.audit.reason === 'patch-missing' || st.audit.reason === 'not-mounted'));
|
|
4514
|
+
var auditBad = !!((st.audit && st.audit.state === 'degraded') || auditOffBad);
|
|
4515
|
+
// 侧栏窄轨(收起)时官方只给极窄的一格:按 { wide } 切换为方图标态,避免溢出。
|
|
4516
|
+
// ⚠️ 窄轨是 34px 定宽方图标(padding:0;white-space:nowrap),"⚠ + 模式图标"两个字形会溢出格子
|
|
4517
|
+
// ⇒ 窄轨下**用 ⚠ 顶替模式图标**(模式仍由 title / aria-label / 悬停菜单给出);
|
|
4518
|
+
// 宁可暂时少一个字形,也不把侧栏挤坏。
|
|
3532
4519
|
var rail = !!(props && props.wide === false);
|
|
3533
4520
|
var label = rail
|
|
3534
|
-
? (mode === 'work' ? '⚙' : '☾')
|
|
3535
|
-
: (mode === 'work' ? '⚙ 工作' : '☾ 生活');
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
4521
|
+
? (auditBad ? '⚠' : (mode === 'work' ? '⚙' : '☾'))
|
|
4522
|
+
: ((auditBad ? '⚠ ' : '') + (mode === 'work' ? '⚙ 工作' : '☾ 生活'));
|
|
4523
|
+
// ⚠️ 原生 `title` 只留**一行**(2026-09-27 用户反馈:原来四行太罗嗦,且浏览器 tooltip 不会自动
|
|
4524
|
+
// 消失,悬停 1.5s 菜单一开就和它叠成两层)。完整提醒改走自绘气泡 `showTip` ——
|
|
4525
|
+
// 悬停即现、3s 自隐、菜单一开就收,见 `tipText`。
|
|
4526
|
+
var title = (isGlobal
|
|
4527
|
+
? ('器灵默认模式:' + modeName(mode) + '(新会话跟随) | 左键切换 · 悬停 1.5s 菜单')
|
|
4528
|
+
: ('器灵 · ' + (st.running ? '运行中(冻结):' : '') + '左键切换为' + modeName(otherMode(mode)) + '模式;悬停 1.5 秒打开菜单'))
|
|
4529
|
+
+ (auditBad ? (auditOffBad ? ' | ⚠ 访问日志未启用(应为但没装上)' : ' | ⚠ 访问日志已停写') : ''); // 只在真出故障时追加,平时 title 仍是一行
|
|
4530
|
+
var tipText = isGlobal
|
|
4531
|
+
? '⚠ 这是全局默认,新会话都跟随(推理等级同步推进,模型不变)。想只切一个会话,用该会话右上角的按钮。'
|
|
4532
|
+
: '';
|
|
3539
4533
|
return h('button', {
|
|
3540
4534
|
ref: btnRef,
|
|
3541
4535
|
className: 'dsh-ling-btn' + (isGlobal ? ' dim' : '') + (rail ? ' rail' : ''),
|
|
4536
|
+
// 只有真出故障才染红(走既有主题变量 --lg-danger,明/暗两套都在);style:null = 不设内联样式
|
|
4537
|
+
style: auditBad ? { color: 'var(--lg-danger)' } : null,
|
|
3542
4538
|
title: title,
|
|
3543
|
-
'aria-label': '器灵控制器:' + (isGlobal ? '默认' : '当前') + modeName(mode) + '模式'
|
|
3544
|
-
|
|
4539
|
+
'aria-label': '器灵控制器:' + (isGlobal ? '默认' : '当前') + modeName(mode) + '模式'
|
|
4540
|
+
+ (auditOffBad ? '(⚠ 访问日志未启用:应为但没装上)' : (auditBad ? '(⚠ 访问日志已停写)' : '')),
|
|
4541
|
+
onClick: function () {
|
|
4542
|
+
clearHover(); hideTip();
|
|
4543
|
+
// 点击后**立即**补一次 /state(不等共享 ticker 的下一拍 —— 空闲时它可能正在 5s 慢节拍上)。
|
|
4544
|
+
// 刻意等这一发 POST 落定再补:抢在写入前面发 GET 会读回旧模式,把刚设好的标签盖回去。
|
|
4545
|
+
var p = toggleMode(store);
|
|
4546
|
+
if (p && typeof p.then === 'function') p.then(function () { kickPoll(store, true); });
|
|
4547
|
+
},
|
|
3545
4548
|
onMouseEnter: function () {
|
|
3546
4549
|
clearHover();
|
|
4550
|
+
if (tipText) showTip(btnRef && btnRef.current, tipText);
|
|
3547
4551
|
hoverTimer.current = setTimeout(function () {
|
|
3548
4552
|
hoverTimer.current = null;
|
|
4553
|
+
hideTip(); // 菜单与气泡永不同屏(用户反馈的"窗口叠加"就是这两层)
|
|
3549
4554
|
var node = btnRef && btnRef.current;
|
|
3550
4555
|
openMenuNear(node ? node.getBoundingClientRect() : null, store);
|
|
3551
4556
|
}, HOVER_MS);
|
|
@@ -3557,7 +4562,11 @@
|
|
|
3557
4562
|
if (e && e.stopPropagation) e.stopPropagation();
|
|
3558
4563
|
},
|
|
3559
4564
|
onKeyDown: function (e) {
|
|
3560
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
4565
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
4566
|
+
clearHover();
|
|
4567
|
+
var pk = toggleMode(store); // 键盘等价于左键点击:同样落定后立即补一拍
|
|
4568
|
+
if (pk && typeof pk.then === 'function') pk.then(function () { kickPoll(store, true); });
|
|
4569
|
+
}
|
|
3561
4570
|
else if (e.key === 'Enter' && e.shiftKey) {
|
|
3562
4571
|
var node = btnRef && btnRef.current;
|
|
3563
4572
|
openMenuNear(node ? node.getBoundingClientRect() : null, store);
|
|
@@ -3684,6 +4693,52 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
|
|
|
3684
4693
|
}
|
|
3685
4694
|
|
|
3686
4695
|
/** 侧栏正文组件。React 不可用时返回 null 而非崩溃。 */
|
|
4696
|
+
// ---- M1(2026-09-27 修):侧栏「最近改动」的溢出自报 ----------------------------------
|
|
4697
|
+
// 缺陷:取数写死 limit=6、渲染再钉 slice(0,6),溢出时**一个字都不说** ⇒ 主人看到的现象
|
|
4698
|
+
// 是"正好少一条"。对照:注入面**会自报**「…另有 N 笔改动」(host/memory.js:1190-1195)。
|
|
4699
|
+
// 主人裁决(逐字):「保留 6 行 + 加一行『…另有 N 笔』」。
|
|
4700
|
+
var LOG_SHOWN = 6; // 列表只列这么多行(= 下方 slice(0, 6);探针断言两者同值)
|
|
4701
|
+
// 取数页大小:端点默认就是 100、上限 500(host/memory.js:1206;api.js:684)⇒ 客户端不再
|
|
4702
|
+
// 自己传 6 把列表掐住。**只放大取数,不放大渲染**(仍只列 LOG_SHOWN 行)。
|
|
4703
|
+
var LOG_FETCH_LIMIT = 100;
|
|
4704
|
+
|
|
4705
|
+
/** 「最近改动」溢出笔数(纯函数 —— 抽出来是为了能在假 DOM 上真跑,
|
|
4706
|
+
* 已由一次性运行时探针在假 DOM 上实跑核对过)。
|
|
4707
|
+
*
|
|
4708
|
+
* 主路:用**服务端真计数**。`/branch-log/pending` 的 `pending` 就是
|
|
4709
|
+
* `COUNT(id > 水位 AND action <> 'snapshot')`(host/memory.js:1257-1263),再把列表里
|
|
4710
|
+
* **已经列出来**的那几笔(水位之上、且不是"保存快照")减掉 ⇒ N 精确;它与同一块里那句
|
|
4711
|
+
* 红字「有 N 笔改动还没跟器灵说过」是**同一个数、同一个界**,两块永远不会互相打脸。
|
|
4712
|
+
*
|
|
4713
|
+
* ⚠️ 为什么不按"页面条数 − 6"算(那样更省事,原计划也是这么写的):侧栏只取**一页**
|
|
4714
|
+
* (`ORDER BY id DESC`),而 branch_log 是**全量留痕** —— 2026-09-27 只读实测已有 **404 行**
|
|
4715
|
+
* ⇒ 页面恒满,`data.log.length - 6` 恒等于 `limit - 6`(limit=100 ⇒ 恒 94),与主人看到的
|
|
4716
|
+
* 改动笔数无关。那正是 1.5.1 在注入面修掉的"两个写死上限的差值"
|
|
4717
|
+
* (作者的开发台账 §A,该台账不随包公开;host/memory.js:1016-1029 明写"不需要把 limit 调大去凑数")。
|
|
4718
|
+
*
|
|
4719
|
+
* 退路(真计数问不到时 `pend` 为 null):退回页面口径;页面满(`log.length >= limit`)
|
|
4720
|
+
* 说明可能还有更多行没取到 ⇒ 只说「N 笔**以上**」,不冒充确切数。
|
|
4721
|
+
* @returns {{n:number, atLeast:boolean}} n<=0 ⇒ 调用方**整行不渲染**(与注入面"零明细
|
|
4722
|
+
* 不渲染任何东西"同口径)。 */
|
|
4723
|
+
function branchLogMore(log, pend, shown, limit) {
|
|
4724
|
+
var rows = Array.isArray(log) ? log : [];
|
|
4725
|
+
var top = rows.slice(0, shown);
|
|
4726
|
+
if (pend && Number.isFinite(Number(pend.pending)) && Number.isFinite(Number(pend.watermark))) {
|
|
4727
|
+
var wm = Number(pend.watermark) || 0;
|
|
4728
|
+
var listed = 0;
|
|
4729
|
+
top.forEach(function (l) {
|
|
4730
|
+
// 与 pending 同口径:水位之上的、且不是"保存快照"(保存不改树,不计债)。
|
|
4731
|
+
// ⚠️ id 读不出来的行**当作"已列出"**:宁可少报一笔,也不谎报"另有"
|
|
4732
|
+
// (与 host/memory.js:1029「摘要宁可少报,也不能整段消失」同向)。
|
|
4733
|
+
var id = Number(l && l.id);
|
|
4734
|
+
if ((!Number.isFinite(id) || id > wm) && String((l && l.action) || '') !== 'snapshot') listed += 1;
|
|
4735
|
+
});
|
|
4736
|
+
return { n: Math.max(0, Number(pend.pending) - listed), atLeast: false };
|
|
4737
|
+
}
|
|
4738
|
+
var extra = rows.length - top.length;
|
|
4739
|
+
return { n: extra > 0 ? extra : 0, atLeast: extra > 0 && rows.length >= limit };
|
|
4740
|
+
}
|
|
4741
|
+
|
|
3687
4742
|
function LingReviewBody() {
|
|
3688
4743
|
if (!React) return null;
|
|
3689
4744
|
var h = React.createElement;
|
|
@@ -3777,6 +4832,13 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
|
|
|
3777
4832
|
var s10 = React.useState(0);
|
|
3778
4833
|
var pendingChanges = s10[0];
|
|
3779
4834
|
var setPendingChanges = s10[1];
|
|
4835
|
+
// M1(2026-09-27):与 pendingChanges **同一份应答**里的水位(watermark = 哪一笔之后算
|
|
4836
|
+
// "还没交代")—— 「最近改动」据此把"没列出来的未交代笔数"算准。**不加任何新请求**:
|
|
4837
|
+
// 它随 `/branch-log/pending` 一起回来(api.js:697 原样透出 memory.js 的 branchLogPending)。
|
|
4838
|
+
// null = 没问到 ⇒ branchLogMore 退回页面口径(宁可加"以上",也不冒充确切数)。
|
|
4839
|
+
var s13 = React.useState(null);
|
|
4840
|
+
var logPending = s13[0];
|
|
4841
|
+
var setLogPending = s13[1];
|
|
3780
4842
|
// R7 主脉提炼(2026-09-23):候选队列。设计稿 §5 —— 提炼结果**先进审核队列**,
|
|
3781
4843
|
// 采纳后才写入主脉自己的记忆(conv_overview source='vein'),且带派生来源。
|
|
3782
4844
|
var s11 = React.useState([]);
|
|
@@ -3797,9 +4859,10 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
|
|
|
3797
4859
|
Promise.all([
|
|
3798
4860
|
api('/tree').catch(function () { return null; }),
|
|
3799
4861
|
api('/conflicts').catch(function () { return null; }),
|
|
3800
|
-
api('/branch-log?limit=
|
|
4862
|
+
api('/branch-log?limit=' + LOG_FETCH_LIMIT).catch(function () { return null; }),
|
|
3801
4863
|
api('/tree/snapshots?limit=30').catch(function () { return null; }),
|
|
3802
|
-
|
|
4864
|
+
// 1.5.3(主会话校正:本批未发布,不叫 1.5.4):该端点进了服务端 `POST_ONLY`(它会一次性补种水位 = 真写),GET 现在回 405。
|
|
4865
|
+
api('/branch-log/pending', { method: 'POST', body: {} }).catch(function () { return null; }),
|
|
3803
4866
|
api('/vein/suggestions?status=new&limit=100').catch(function () { return null; }),
|
|
3804
4867
|
// E10:主脉记忆清单(只读,limit 上限就是 500,见 memory.js:1951)
|
|
3805
4868
|
api('/memories?source=vein&limit=500').catch(function () { return null; }),
|
|
@@ -3807,6 +4870,10 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
|
|
|
3807
4870
|
if (!alive) return;
|
|
3808
4871
|
setSnapshots((rs[3] && rs[3].ok) ? (rs[3].snapshots || []) : []);
|
|
3809
4872
|
setPendingChanges((rs[4] && rs[4].ok) ? Number(rs[4].pending || 0) : 0);
|
|
4873
|
+
// M1:水位与 pending **同一份应答、同一拍**(不多一个请求);问不到就置 null 退回页面口径
|
|
4874
|
+
setLogPending((rs[4] && rs[4].ok)
|
|
4875
|
+
? { pending: Number(rs[4].pending || 0), watermark: Number(rs[4].watermark || 0) }
|
|
4876
|
+
: null);
|
|
3810
4877
|
setVeinSugs((rs[5] && rs[5].ok) ? (rs[5].suggestions || []) : []);
|
|
3811
4878
|
var vm = {};
|
|
3812
4879
|
if (rs[6] && rs[6].ok) {
|
|
@@ -3828,8 +4895,16 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
|
|
|
3828
4895
|
// 侧栏还写着"有 N 笔没说"。加轻量轮询让它跟上(8s 一次小请求,值不变时 React 不重渲染)。
|
|
3829
4896
|
React.useEffect(function () {
|
|
3830
4897
|
var t = setInterval(function () {
|
|
3831
|
-
api('/branch-log/pending').then(function (r) {
|
|
3832
|
-
if (r
|
|
4898
|
+
api('/branch-log/pending', { method: 'POST', body: {} }).then(function (r) {
|
|
4899
|
+
if (!r || !r.ok) return;
|
|
4900
|
+
var p = Number(r.pending || 0);
|
|
4901
|
+
var w = Number(r.watermark || 0);
|
|
4902
|
+
setPendingChanges(p);
|
|
4903
|
+
// M1:水位跟着刷新(仍是同一发请求,不新增轮询)。**值不变时返回同一个对象** ——
|
|
4904
|
+
// 否则每 8s 造一个新对象 ⇒ React 每次都重渲染整个侧栏(原文"值不变不重渲染"就废了)。
|
|
4905
|
+
setLogPending(function (prev) {
|
|
4906
|
+
return (prev && prev.pending === p && prev.watermark === w) ? prev : { pending: p, watermark: w };
|
|
4907
|
+
});
|
|
3833
4908
|
}).catch(function () { /* 轮询失败不影响复盘 */ });
|
|
3834
4909
|
}, 8000);
|
|
3835
4910
|
return function () { clearInterval(t); };
|
|
@@ -4660,6 +5735,17 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
|
|
|
4660
5735
|
'· ' + String(l.at || '').slice(5, 16).replace('T', ' ') + ' '
|
|
4661
5736
|
+ (l.branchName || l.branchId) + ' ' + l.action));
|
|
4662
5737
|
});
|
|
5738
|
+
// M1(2026-09-27 修):上面只列 6 行,**溢出必须自报**(改前静默丢行)。措辞与注入面那句
|
|
5739
|
+
// 「…另有 N 笔改动」(host/memory.js:1190-1195)对齐 —— 同一句主语、同一个"没交代的改动"
|
|
5740
|
+
// 集合;零笔则整行不渲染。括号里**不写**「点开复盘模式看全部」:本面板**就是**复盘模式,
|
|
5741
|
+
// 且全仓只有这一处渲染 branch_log(客户端 4 个 /branch-log 消费点全在本面板内)⇒ 那句话
|
|
5742
|
+
// 会指向不存在的地方;这里改用注入面同款的"披露"式(对照 memory.js:1192 的括号写法)。
|
|
5743
|
+
var logMore = branchLogMore(data.log, logPending, LOG_SHOWN, LOG_FETCH_LIMIT);
|
|
5744
|
+
if (logMore.n > 0) {
|
|
5745
|
+
kids.push(h('div', { key: 'lm', style: { opacity: 0.7 } },
|
|
5746
|
+
'…另有 ' + logMore.n + (logMore.atLeast ? ' 笔以上改动' : ' 笔改动')
|
|
5747
|
+
+ '(此处只列最近 ' + LOG_SHOWN + ' 笔)'));
|
|
5748
|
+
}
|
|
4663
5749
|
}
|
|
4664
5750
|
if (pendingChanges > 0) {
|
|
4665
5751
|
kids.push(h('div', {
|
|
@@ -4686,7 +5772,7 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
|
|
|
4686
5772
|
// 2026-09-23 修:显示值可能滞后(器灵开口才推进水位,而"发言"不触发本面板重载),
|
|
4687
5773
|
// 所以退出前**实时查一次**服务端 —— 避免"明明已经同步了却拦着不让退"。
|
|
4688
5774
|
if (!next) {
|
|
4689
|
-
api('/branch-log/pending').then(function (r) {
|
|
5775
|
+
api('/branch-log/pending', { method: 'POST', body: {} }).then(function (r) {
|
|
4690
5776
|
var p = (r && r.ok) ? Number(r.pending || 0) : pendingChanges;
|
|
4691
5777
|
setPendingChanges(p);
|
|
4692
5778
|
if (p > 0) {
|
|
@@ -5063,6 +6149,15 @@ var lingCenterReload = null; // 侧栏改完树后通知记忆中心重载(两
|
|
|
5063
6149
|
console.warn('[dsh-ling] client apply failed', e);
|
|
5064
6150
|
}
|
|
5065
6151
|
};
|
|
6152
|
+
// ---- 测试钩子(仅供 tests/client-batch.test.mjs 直调真实实现)----
|
|
6153
|
+
// 只挂纯函数与常量,无任何副作用;生产路径不读这里。
|
|
6154
|
+
exports.__test__ = {
|
|
6155
|
+
takeChunk: takeChunk,
|
|
6156
|
+
utf8Len: utf8Len,
|
|
6157
|
+
fmtBytes: fmtBytes,
|
|
6158
|
+
BATCH_MAX_BYTES: BATCH_MAX_BYTES,
|
|
6159
|
+
BATCH_MAX_ITEMS: BATCH_MAX_ITEMS,
|
|
6160
|
+
};
|
|
5066
6161
|
return module.exports;
|
|
5067
6162
|
},
|
|
5068
6163
|
});
|