dsh-streamfold 0.5.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.en.md +73 -64
- package/README.md +74 -67
- package/lib/client.js +1514 -847
- package/package.json +6 -6
package/lib/client.js
CHANGED
|
@@ -2,7 +2,7 @@ window.__ModuleLoader__.load({ id: "dsh-streamfold", factory: (require) => {
|
|
|
2
2
|
var module = { exports: {} }; var exports = module.exports;
|
|
3
3
|
"use strict";
|
|
4
4
|
/**
|
|
5
|
-
* dsh-streamfold —— 流式折叠(DSH Web 显示增强)
|
|
5
|
+
* dsh-streamfold v0.2 —— 流式折叠(DSH Web 显示增强)
|
|
6
6
|
*
|
|
7
7
|
* 模型只有两条:
|
|
8
8
|
* 1) 正在跑的那一轮:只自动展开本轮**最新的那一个**思考小窗;被取代的旧窗按 supersedeDelay 秒后折回一行摘要
|
|
@@ -25,11 +25,14 @@ const NS = "dsh-streamfold";
|
|
|
25
25
|
const NATIVE_NS = "ui-chat";
|
|
26
26
|
const NATIVE_FIELD = "transcriptView";
|
|
27
27
|
const DEFAULTS = {
|
|
28
|
-
mode: "
|
|
29
|
-
foldHistory: true,
|
|
30
|
-
keepInterleavedText:
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
mode: "fold", // fold = 本插件(用户裁决:默认折叠);native = 官方标准/紧凑
|
|
29
|
+
foldHistory: true,
|
|
30
|
+
keepInterleavedText: true, // 折叠时保留"穿插正文"行(只有正文,不含思考)—— 默认值按用户当前设置固化(2026-10-04)
|
|
31
|
+
keepUserQuestions: true, // LLM 提问工具卡(ask_user_question)及其回复节点(question-reply)不折;关掉则随其余内容一起折叠
|
|
32
|
+
windowHeight: 360, // 默认值按用户当前设置固化(2026-10-04)
|
|
33
|
+
followMaxSpeed: 240, // 跟随"追赶"速度上限(px/s);默认值按用户当前设置固化(2026-10-04)
|
|
34
|
+
followMaxAccel: 10000, // 跟随加速度上限(px/s²);默认值按用户当前设置固化(2026-10-04)
|
|
35
|
+
returnToTail: true, // 停在中途(我方无目标)时:出现你新发的一条消息 ⇒ 滑回尾部;关掉则一律停在原地
|
|
33
36
|
showJumpButton: true,
|
|
34
37
|
animations: true,
|
|
35
38
|
autoExpandReasoning: true, // 运行中的那一轮自动展开思考
|
|
@@ -37,7 +40,6 @@ const DEFAULTS = {
|
|
|
37
40
|
settleToPrompt: true, // 一轮跑完后平滑移到本轮提问处(读者已接管滚动时不动)
|
|
38
41
|
pinPrompt: true, // 置顶显示提问:跟着"正在看的那一轮"换,超过一行只显示一行
|
|
39
42
|
smoothGrow: 0.15, // 跟随底部时每帧吃掉多少差距(越小越柔)
|
|
40
|
-
smoothMin: 1, // 每 16ms 至少推进多少像素
|
|
41
43
|
supersedeDelay: 2, // 运行中旧的思考窗被取代后,延迟多少秒再自动折回
|
|
42
44
|
sparks: true, // 思考小窗底部的火星特效(独立模块,见「火星特效」一节)
|
|
43
45
|
sparkColor: "#4fa8ff", // 火星颜色(核心自动提亮成白热)
|
|
@@ -66,17 +68,9 @@ let nativeScope = null;
|
|
|
66
68
|
const NATIVE_MODES = ["compact", "standard", "detailed", "verbose"];
|
|
67
69
|
const NATIVE_ALIAS = { normal: "standard", expanded: "detailed" };
|
|
68
70
|
const NATIVE_LABELS = { compact: "简洁", standard: "标准", detailed: "详细", verbose: "完全展开", fold: "折叠" };
|
|
69
|
-
const NATIVE_LABELS_EN = { compact: "Compact", standard: "Standard", detailed: "Detailed", verbose: "Verbose", fold: "Fold" };
|
|
70
71
|
let tChat = null; // 官方 chat 词典:标签就跟官方设置页一致,改动由官方那边带过来
|
|
71
|
-
/** 界面语言:官方词典 zh 里 compact 是「简洁」、en 里是 Compact。
|
|
72
|
-
* 官方**没有** fold 键,所以「折叠」这一档和设置页的两处标题必须自带双语,不能只靠 tChat 回退。 */
|
|
73
|
-
const isZh = () => {
|
|
74
|
-
if (!tChat) return true;
|
|
75
|
-
try { return tChat("settings.transcript.compact") === NATIVE_LABELS.compact; } catch { return true; }
|
|
76
|
-
};
|
|
77
|
-
const uiText = (zh, en) => (isZh() ? zh : en);
|
|
78
72
|
const modeLabel = (mode) => {
|
|
79
|
-
const fallback =
|
|
73
|
+
const fallback = NATIVE_LABELS[mode] || mode;
|
|
80
74
|
if (!tChat) return fallback;
|
|
81
75
|
try {
|
|
82
76
|
const text = tChat("settings.transcript." + mode);
|
|
@@ -108,26 +102,17 @@ function displayChoice() {
|
|
|
108
102
|
}
|
|
109
103
|
let nativePinned = false; // 官方取值是否已校正(只做一次,避免写值 → 订阅 → 再写的回环)
|
|
110
104
|
let nativeProbing = false; // 写入进行中(挡住订阅回调重入)
|
|
111
|
-
let nativeTries = 0; // 写入被拒的次数(失败会经订阅回调回到这里,不能无限重写)
|
|
112
|
-
const NATIVE_TRIES_MAX = 3;
|
|
113
105
|
/** 折叠模式必须让官方「别自己折」:0.1.7 的四档里只有 verbose 是 foldCompletedTurns=false + stepGrouping=none
|
|
114
106
|
* (= 完全展开),其余三档官方自己就会折叠或分组,会和我们的折叠双重生效。
|
|
115
107
|
* 镜像没就绪时不写、也不落定 —— 订阅回调会再来一次。 */
|
|
116
108
|
async function enforceNativeBaseline() {
|
|
117
|
-
if (nativePinned || nativeProbing ||
|
|
109
|
+
if (nativePinned || nativeProbing || state.mode !== "fold" || !nativeScope) return;
|
|
118
110
|
const section = nativeScope.getSnapshot();
|
|
119
111
|
if (!section || section.status !== "ready") return;
|
|
120
112
|
nativeProbing = true;
|
|
121
113
|
try {
|
|
122
114
|
const value = section.value ? section.value[NATIVE_FIELD] : undefined;
|
|
123
|
-
|
|
124
|
-
// set() 的返回值 = 宿主是否接受写入。被拒(只读/内存态宿主)时必须当作没做成:
|
|
125
|
-
// 否则我们谎报 nativeValue、官方那条 hidden 永远清不掉(React 的 layout effect 依赖不变、不会自愈)。
|
|
126
|
-
if (!ok) {
|
|
127
|
-
nativeTries += 1;
|
|
128
|
-
try { ok = (await nativeScope.set(NATIVE_FIELD, "verbose")) === true; } catch { ok = false; }
|
|
129
|
-
}
|
|
130
|
-
if (!ok) return; // 订阅回调会重试,最多 NATIVE_TRIES_MAX 次
|
|
115
|
+
if (value !== "verbose") { try { await nativeScope.set(NATIVE_FIELD, "verbose"); } catch { /* ignore */ } }
|
|
131
116
|
nativeValue = "verbose";
|
|
132
117
|
nativePinned = true;
|
|
133
118
|
} finally { nativeProbing = false; }
|
|
@@ -151,15 +136,24 @@ const STYLE_ID = "dsh-streamfold/styles";
|
|
|
151
136
|
const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全局 CSS 对象,本插件已不再用 CSS.highlights)
|
|
152
137
|
":root{--dshsf-h:260px}",
|
|
153
138
|
"[data-chat-flow]{overflow-anchor:none}", // 滚动位置只由我们和读者决定,别让浏览器锚定插一脚
|
|
154
|
-
"[data-dshsf-native-row]{display:none!important}",
|
|
155
139
|
/* 限高滚动窗:一个位置只有一个滚动条 */
|
|
156
140
|
"[data-dshsf-window]{max-height:var(--dshsf-h);overflow:auto;overscroll-behavior:contain;contain:content;scrollbar-gutter:stable;",
|
|
157
141
|
"scrollbar-width:thin;scrollbar-color:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.35)) transparent;",
|
|
158
142
|
"border-radius:8px;padding:2px 8px 2px 6px;margin-right:4px;",
|
|
159
143
|
"transition:background-color .2s ease,box-shadow .2s ease}", // 高度由 tickFollow 每帧推进:固定过渡跟不上快输出,不要用
|
|
160
144
|
"[data-dshsf-window='reasoning']{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.05))}",
|
|
145
|
+
/* 穿插正文小窗:折叠后仍保留的正文行,限高内滚 + 毛玻璃底(与思考小窗同一套跟随/回底) */
|
|
146
|
+
"[data-dshsf-textwindow]{max-height:var(--dshsf-h);overflow:auto;overscroll-behavior:contain;contain:content;scrollbar-gutter:stable;",
|
|
147
|
+
"scrollbar-width:thin;scrollbar-color:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.35)) transparent;",
|
|
148
|
+
"border-radius:12px;padding:8px 12px;margin:4px 0 8px;",
|
|
149
|
+
"border:1px solid var(--dsw-alias-border-l3,rgba(127,127,127,.3));",
|
|
150
|
+
"background:rgba(127,127,127,.08);",
|
|
151
|
+
"background:color-mix(in srgb,var(--dsw-alias-label-primary,#7a869a) 5%,transparent);",
|
|
152
|
+
"backdrop-filter:blur(14px) saturate(150%);",
|
|
153
|
+
"-webkit-backdrop-filter:blur(14px) saturate(150%);",
|
|
154
|
+
"box-shadow:0 1px 3px rgba(0,0,0,.06)}",
|
|
161
155
|
/* 穿插正文与正式答案之间的分隔线(用 l3:l1/l2 的 alpha 太低,画了看不见) */
|
|
162
|
-
"[data-dshsf-answer-sep]{border-top:1px solid var(--dsw-alias-border-l4,rgba(127,127,127,.38));padding-top:
|
|
156
|
+
"[data-dshsf-answer-sep]{border-top:1px solid var(--dsw-alias-border-l4,rgba(127,127,127,.38));padding-top:10px}",
|
|
163
157
|
"@keyframes dshsf-window-in{from{max-height:0;opacity:0;transform:translateY(-3px)}to{max-height:var(--dshsf-hw,var(--dshsf-h));opacity:1;transform:none}}",
|
|
164
158
|
"html[data-dshsf-anim] [data-dshsf-window]{animation:dshsf-window-in .26s var(--ds-ease-out,cubic-bezier(.2,.8,.2,1)) backwards}",
|
|
165
159
|
"[data-dshsf-window][data-dshsf-closing]{transition:max-height .2s var(--ds-ease-in,ease),opacity .2s ease}",
|
|
@@ -168,12 +162,14 @@ const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全
|
|
|
168
162
|
"[class*='_bodyWrap'][data-dshsf-closing]{transition:max-height .2s var(--ds-ease-in,ease),opacity .2s ease}",
|
|
169
163
|
"html[data-dshsf-anim] [data-context-injection-body][data-dshsf-reveal]{overflow:auto}", // 官方这块正文自带内滚,别被上面的通用规则压成 hidden
|
|
170
164
|
"[data-dshsf-reveal][data-dshsf-closing],[data-context-injection-body][data-dshsf-closing]{transition:max-height .2s var(--ds-ease-in,ease),opacity .2s ease}",
|
|
171
|
-
"[data-dshsf-window]::-webkit-scrollbar{width:8px;height:8px}",
|
|
172
|
-
"[data-dshsf-window]::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.35));border-radius:4px}",
|
|
173
|
-
"[data-dshsf-window]::-webkit-scrollbar-track{background:transparent}",
|
|
165
|
+
"[data-dshsf-window]::-webkit-scrollbar,[data-dshsf-textwindow]::-webkit-scrollbar{width:8px;height:8px}",
|
|
166
|
+
"[data-dshsf-window]::-webkit-scrollbar-thumb,[data-dshsf-textwindow]::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.35));border-radius:4px}",
|
|
167
|
+
"[data-dshsf-window]::-webkit-scrollbar-track,[data-dshsf-textwindow]::-webkit-scrollbar-track{background:transparent}",
|
|
174
168
|
"[data-dshsf-window][data-dshsf-offbottom]{box-shadow:inset 0 -14px 12px -14px var(--dsw-alias-label-caption,rgba(0,0,0,.4))}",
|
|
175
169
|
/* 折叠 / 折叠条 / 回到底部 */
|
|
176
170
|
"[data-dshsf-row]{transition:max-height .3s var(--ds-ease-in-out,ease),opacity .22s ease}",
|
|
171
|
+
/* T9:官方「工作步骤展示」行只在我们的行渲染成功时隐藏(可逆;只改可见性,不改其文字/属性/菜单) */
|
|
172
|
+
"[data-dshsf-hide-official]{display:none!important}",
|
|
177
173
|
"[data-dshsf-folded]{max-height:0;opacity:0;overflow:hidden;margin-top:0!important;margin-bottom:0!important}",
|
|
178
174
|
// 折叠内容跳过渲染走 hidden="until-found"(长会话最大的一笔省项):
|
|
179
175
|
// 它和 content-visibility:hidden 一样不布局,但**允许 Ctrl+F 搜到**并在命中前派发 beforematch。
|
|
@@ -187,17 +183,16 @@ const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全
|
|
|
187
183
|
"color:var(--dsw-alias-label-tertiary,#888);font-size:12px;line-height:18px;cursor:pointer;text-align:left;",
|
|
188
184
|
"transition:background-color .18s ease,color .18s ease}",
|
|
189
185
|
"[data-dshsf-chip]:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1));color:var(--dsw-alias-label-secondary,#666)}",
|
|
190
|
-
"[data-dshsf-jump]{position:sticky;bottom:6px;float:left;z-index:3;display:inline-flex;align-items:center;justify-content:center;gap:4px;",
|
|
191
|
-
"margin:0 0 -27px 6px;height:27px;box-sizing:border-box;", // 负下边距:窗口是 BFC,会把浮动包进高度 —— 不抵消每个窗底下白多 27px
|
|
186
|
+
"[data-dshsf-jump]{position:sticky;bottom:6px;float:left;margin-left:6px;z-index:3;display:inline-flex;align-items:center;justify-content:center;gap:4px;",
|
|
192
187
|
"padding:4px 7px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));",
|
|
193
188
|
"background:var(--dsw-alias-button-floating-fill,var(--dsw-alias-bg-layer-1,#fff));color:var(--dsw-alias-label-secondary,#555);",
|
|
194
189
|
"font-size:12px;line-height:18px;cursor:pointer;box-shadow:var(--dsw-elevation-panel,0 2px 8px rgba(0,0,0,.12));",
|
|
195
|
-
"opacity:0;
|
|
196
|
-
"[data-dshsf-jump][data-dshsf-visible]{opacity:1;
|
|
190
|
+
"opacity:0;transform:translateY(4px);transition:opacity .2s ease,transform .2s ease}",
|
|
191
|
+
"[data-dshsf-jump][data-dshsf-visible]{opacity:1;transform:translateY(0)}",
|
|
197
192
|
/* 主窗口那颗回底按钮:外观与位置都用官方的,只把"何时出现"接管过来——默认藏起来,
|
|
198
193
|
只有我们判定"读者接管了、而且确实离底"时才放出来(官方自己按 25px 阈值无滞回地切 = 闪)。 */
|
|
199
|
-
"
|
|
200
|
-
"html[data-dshsf-
|
|
194
|
+
"[class*='_toBottomSlot']{display:none}",
|
|
195
|
+
"html[data-dshsf-jumpgate] [class*='_toBottomSlot']{display:flex}",
|
|
201
196
|
"[data-dshsf-jump]>svg{display:block}",
|
|
202
197
|
/* 置顶的提问:固定层贴在会话视口顶端,跟着"正在看的那一轮"换;超过一行只显示一行,它自己在视口里时让位。 */
|
|
203
198
|
"[data-dshsf-pin]{position:fixed;z-index:7;display:none;cursor:pointer;box-sizing:border-box;",
|
|
@@ -208,7 +203,10 @@ const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全
|
|
|
208
203
|
"white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
|
|
209
204
|
"[data-dshsf-pin][data-dshsf-visible]{display:block}",
|
|
210
205
|
/* 火星特效:零高度 sticky 容器贴住可视底边,画布往上铺——容器不参与布局,窗口高度与 scrollHeight 都不受影响 */
|
|
211
|
-
|
|
206
|
+
/* 层级契约(2026-10-01 真机取证):画布挂在 body 的 fixed 层,与官方界面同处根堆叠上下文,只看 z-index。
|
|
207
|
+
官方 composer 座位 z-index:7、设置弹窗遮罩 z-index:1000 —— 旧值 9999 把两者都盖住。火星只是正文的装饰,
|
|
208
|
+
必须待在「正文之上、界面之下」:取 6,正好压 composer 座位(7)下面一档。别再往上抬。 */
|
|
209
|
+
"[data-dshsf-spark]{position:fixed;left:0;top:0;display:block;pointer-events:none;z-index:6;will-change:transform}", // 固定层:位置由 JS 每帧摆;层级见上
|
|
212
210
|
/* 设置页 */
|
|
213
211
|
".dshsf-row{display:flex;align-items:flex-start;gap:16px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l1,rgba(127,127,127,.16))}",
|
|
214
212
|
".dshsf-row:last-child{border-bottom:none}",
|
|
@@ -240,7 +238,7 @@ const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全
|
|
|
240
238
|
".dshsf-actions{display:flex;gap:8px;margin-top:14px}",
|
|
241
239
|
".dshsf-btn{padding:5px 14px;border-radius:8px;font-size:12px;cursor:pointer;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));background:transparent;color:var(--dsw-alias-label-primary);transition:background-color .16s ease}",
|
|
242
240
|
".dshsf-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}",
|
|
243
|
-
"@media (prefers-reduced-motion: reduce){[data-dshsf-window],[data-dshsf-row],[data-dshsf-chip],[data-dshsf-jump],.dshsf-switch,.dshsf-switch:after{transition:none!important;animation:none!important}}",
|
|
241
|
+
"@media (prefers-reduced-motion: reduce){[data-dshsf-window],[data-dshsf-textwindow],[data-dshsf-row],[data-dshsf-chip],[data-dshsf-jump],.dshsf-switch,.dshsf-switch:after{transition:none!important;animation:none!important}}",
|
|
244
242
|
].join("");
|
|
245
243
|
|
|
246
244
|
function injectStyles() {
|
|
@@ -254,15 +252,46 @@ function injectStyles() {
|
|
|
254
252
|
document.head.appendChild(tag);
|
|
255
253
|
return () => { tag.remove(); };
|
|
256
254
|
}
|
|
255
|
+
/** task-30:官方档位 = **完全放手**:清我方全部内联视觉痕迹(小窗正文限高/变量/数据集、根变量、残余标记、按钮),
|
|
256
|
+
* 可逆 —— 切回「折叠」时 rootVars()/tagThinkWindow()/syncWindows() 会重新施加。不隐藏任何官方节点(含官方行)。 */
|
|
257
|
+
function releaseOurVisuals() {
|
|
258
|
+
const targets = document.querySelectorAll("[data-dshsf-window], [data-dshsf-textwindow]");
|
|
259
|
+
for (const el of targets) {
|
|
260
|
+
try {
|
|
261
|
+
el.style.maxHeight = "";
|
|
262
|
+
el.style.removeProperty("--dshsf-hw");
|
|
263
|
+
el.style.overflow = "";
|
|
264
|
+
} catch { /* ignore */ }
|
|
265
|
+
try { delete el.dataset.dshsfH; delete el.dataset.dshsfClosing; } catch { /* ignore */ }
|
|
266
|
+
winH.delete(el);
|
|
267
|
+
for (const attr of ["data-dshsf-window", "data-dshsf-textwindow", "data-dshsf-offbottom"]) el.removeAttribute(attr);
|
|
268
|
+
const jump = el.querySelector(":scope > [data-dshsf-jump]");
|
|
269
|
+
if (jump !== null) jump.removeAttribute("data-dshsf-visible");
|
|
270
|
+
}
|
|
271
|
+
// Ctrl+F 期间被我们藏起来的正文:只摘标记会把 hidden="until-found" 留在原地(内容永远看不见,且丢了归属标记)
|
|
272
|
+
// —— 必须走和"搜索命中"同一条收回路径(摘标记 + 摘 hidden + 回到每帧清单)。
|
|
273
|
+
clearSearchHidden(document);
|
|
274
|
+
for (const el of document.querySelectorAll("[data-dshsf-answer-sep], [data-dshsf-row], [data-dshsf-chip]")) {
|
|
275
|
+
for (const attr of ["data-dshsf-answer-sep", "data-dshsf-row", "data-dshsf-chip"]) el.removeAttribute(attr);
|
|
276
|
+
}
|
|
277
|
+
// 行级跳过标记(hidden="until-found",认 data-dshsf-hidden 归属)与动画/收尾标记:切回折叠时不许留残余过渡。
|
|
278
|
+
for (const el of document.querySelectorAll("[data-dshsf-hidden], [data-dshsf-reveal], [data-dshsf-closing]")) {
|
|
279
|
+
clearSkipHidden(el);
|
|
280
|
+
el.removeAttribute("data-dshsf-reveal");
|
|
281
|
+
el.removeAttribute("data-dshsf-closing");
|
|
282
|
+
}
|
|
283
|
+
const root = document.getElementById("root") || document.documentElement;
|
|
284
|
+
try { root.style.removeProperty("--dshsf-h"); } catch { /* ignore */ }
|
|
285
|
+
document.documentElement.removeAttribute("data-dshsf-jumpgate");
|
|
286
|
+
document.documentElement.removeAttribute("data-dshsf-anim");
|
|
287
|
+
for (const el of document.querySelectorAll("[data-dshsf-jump]")) el.removeAttribute("data-dshsf-visible");
|
|
288
|
+
hidePinBar(); // 置顶条是固定层、不在 flow 里,之前只由 tick 的官方档门收——这里一并收干净
|
|
289
|
+
}
|
|
257
290
|
function rootVars() {
|
|
258
291
|
const root = document.getElementById("root") || document.documentElement;
|
|
259
292
|
const h = Math.max(80, Number(state.windowHeight) || 260) + "px";
|
|
260
293
|
if (root.style.getPropertyValue("--dshsf-h") !== h) root.style.setProperty("--dshsf-h", h);
|
|
261
|
-
|
|
262
|
-
// 动效属性同样只在折叠模式挂。
|
|
263
|
-
const fold = state.mode === "fold";
|
|
264
|
-
document.documentElement.setAttribute("data-dshsf-mode", fold ? "fold" : "native");
|
|
265
|
-
document.documentElement.toggleAttribute("data-dshsf-anim", fold && state.animations !== false && allowAnim);
|
|
294
|
+
document.documentElement.toggleAttribute("data-dshsf-anim", state.animations !== false && allowAnim);
|
|
266
295
|
}
|
|
267
296
|
|
|
268
297
|
/* ------------------------------------------------------------- 常量/工具 -- */
|
|
@@ -276,12 +305,13 @@ const CTX_BODY = "[data-context-injection-body]"; // 上下文注入行
|
|
|
276
305
|
const TOOL_BODY = '[class*="_bodyScroll"], [class*="_bodyWrap"]';
|
|
277
306
|
// 一次遍历拿到"所有已挂载的正文",替代三次全量查询(思考/工具/上下文各一次)
|
|
278
307
|
const BODY_ANY = '[class*="_thinkBody"], [class*="_bodyScroll"], [class*="_bodyWrap"], [data-context-injection-body]';
|
|
308
|
+
/** A1:主容器**内部**的嵌套滚动体(思考小窗 / 工具正文 / 上下文注入正文)—— 在它们上面的滚轮不是"主容器读者接管"的意图。 */
|
|
309
|
+
const NESTED_SCROLL = "[data-dshsf-window], [data-dshsf-textwindow], " + TOOL_BODY + ", " + CTX_BODY;
|
|
279
310
|
// 运行中的标记:思考行 data-state=running、正文 data-streaming、工具卡 data-state=running|preparing
|
|
280
311
|
// (工具执行期间没有流式文本,只靠前两个会误判成"跑完了" → 运行中来一次全量折叠)
|
|
281
312
|
const RUNNING = '[data-state="running"], [data-state="preparing"], [data-streaming]';
|
|
282
313
|
const RUN_GRACE_MS = 700; // 运行标记消失多久才算真跑完(步骤/工具交接的空档不算)
|
|
283
314
|
let runningSticky = false;
|
|
284
|
-
let contractWarned = false; // 官方收尾标记的契约自检只报一次
|
|
285
315
|
let lastRunningAt = 0;
|
|
286
316
|
let runGraceTimer = 0;
|
|
287
317
|
const INDEPENDENT = new Set(["user", "steering", "system-prompt", "turn-trigger", "turn-error", "turn-max-tokens", "turn-tail", "turn-process", "compaction", "manual-compaction", "command", "request-prompt"]); // 前 8 个与官方 TURN_PROCESS_INDEPENDENT_KINDS 对齐(0.1.7 新增 turn-trigger)
|
|
@@ -305,27 +335,16 @@ function animateClose(el, done) {
|
|
|
305
335
|
el.style.maxHeight = el.getBoundingClientRect().height + "px";
|
|
306
336
|
void el.offsetHeight;
|
|
307
337
|
requestAnimationFrame(() => { el.style.maxHeight = "0px"; el.style.opacity = "0"; });
|
|
308
|
-
setTimeout(
|
|
309
|
-
}
|
|
310
|
-
/** 合成 click 没能让 React 收起时(控件被换掉/被拦下),把"正在收"的标记与内联样式还原 ——
|
|
311
|
-
* 否则这条正文会永久停在 inline max-height:0 + opacity:0,revealBody/帧循环都按 closing 早退、再也不碰它。 */
|
|
312
|
-
function finishClose(body) {
|
|
313
|
-
if (!body || !body.isConnected || body.dataset.dshsfClosing !== "1") return;
|
|
314
|
-
delete body.dataset.dshsfClosing;
|
|
315
|
-
body.style.maxHeight = ""; body.style.opacity = ""; body.style.overflow = "";
|
|
338
|
+
setTimeout(done, 200);
|
|
316
339
|
}
|
|
317
|
-
/** 每个模块实例一个接线令牌:热重载后新实例必须能重新挂自己的监听(见 wireDisclosure)。 */
|
|
318
|
-
const WIRE_TOKEN = "wire-" + Math.random().toString(36).slice(2, 8);
|
|
319
340
|
/** 用户点收起时先播动画再放行:捕获阶段拦下 React 的 onClick。 */
|
|
320
341
|
function wireDisclosure(root, isOpen, bodySel, onUserClose) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if (root.dataset.dshsfWired === WIRE_TOKEN) return;
|
|
324
|
-
root.dataset.dshsfWired = WIRE_TOKEN;
|
|
342
|
+
if (root.dataset.dshsfWired === "1") return;
|
|
343
|
+
root.dataset.dshsfWired = "1";
|
|
325
344
|
root.addEventListener("click", (event) => {
|
|
326
|
-
if (
|
|
345
|
+
if (synthetic || !isOpen(root)) return; // 我方事件 / 是展开动作 → 放行
|
|
327
346
|
if (event.target instanceof Element && event.target.closest(bodySel)) return; // 点在正文里 → 放行
|
|
328
|
-
if (state.
|
|
347
|
+
if (state.animations === false || !allowAnim) return; // 关了动效就直接放行
|
|
329
348
|
const body = bodyOf(root, bodySel);
|
|
330
349
|
if (!body || body.dataset.dshsfClosing === "1") return;
|
|
331
350
|
event.preventDefault();
|
|
@@ -373,16 +392,14 @@ function hasRowText(el) {
|
|
|
373
392
|
};
|
|
374
393
|
return walk(el);
|
|
375
394
|
}
|
|
376
|
-
let textCache = new WeakMap(); // 行 →
|
|
377
|
-
|
|
378
|
-
// (缓存说「没正文」时,承载正式回答的行会被当过程行折掉)。切会话时整表重建。
|
|
379
|
-
let hotRows = new WeakSet(); // 最近两轮的行:正文可能还在变 → 每次扫描重算
|
|
395
|
+
let textCache = new WeakMap(); // 行 → 有没有正文:跨扫描保留;定向失效由 MutationObserver 负责(A8,见 startController)
|
|
396
|
+
let hotRows = new WeakSet(); // 运行中的轮 + 最近 3 轮:正文可能还在变 → 每次扫描重算
|
|
380
397
|
/** assistant-step 行里除思考外是否真有正文。 */
|
|
381
398
|
function hasText(row) {
|
|
382
399
|
if (rowKind(row) !== "assistant-step") return false;
|
|
383
400
|
if (!hotRows.has(row)) {
|
|
384
401
|
const hit = textCache.get(row);
|
|
385
|
-
if (hit !== undefined
|
|
402
|
+
if (hit !== undefined) return hit; // 老轮次:正文不会再变,直接用上次结果(省掉整棵子树的文本遍历)
|
|
386
403
|
}
|
|
387
404
|
cost.counts.hasText = (cost.counts.hasText || 0) + 1;
|
|
388
405
|
const t = performance.now();
|
|
@@ -391,7 +408,7 @@ function hasText(row) {
|
|
|
391
408
|
// 跑完收尾时连回答一起折掉(就是"全量折叠")。
|
|
392
409
|
const result = hasRowText(row);
|
|
393
410
|
cost.phases.hasText = +((cost.phases.hasText || 0) + (performance.now() - t)).toFixed(2);
|
|
394
|
-
textCache.set(row,
|
|
411
|
+
textCache.set(row, result);
|
|
395
412
|
return result;
|
|
396
413
|
}
|
|
397
414
|
/** 行里承载「正式正文」的容器。0.1.5 是 row > markdown > body;0.1.7 多包了一层,
|
|
@@ -446,29 +463,18 @@ function collapse(root, isOpen, bodySel, atKey, triesKey) {
|
|
|
446
463
|
const target = disclosureTarget(root);
|
|
447
464
|
if (!target) return false;
|
|
448
465
|
const body = bodyOf(root, bodySel);
|
|
449
|
-
|
|
450
|
-
lastClose = { // 诊断:这次收起为什么播/没播动画(probe().anim.lastClose)
|
|
451
|
-
at: Math.round(performance.now()), anim: canAnim, kind: bodySel === THINK_BODY ? "think" : "tool",
|
|
452
|
-
setting: state.animations !== false, allowAnim, budget: animBudget, tries,
|
|
453
|
-
body: body ? 1 : 0, closing: body && body.dataset.dshsfClosing === "1" ? 1 : 0,
|
|
454
|
-
};
|
|
455
|
-
if (canAnim) {
|
|
466
|
+
if (body && state.animations !== false && allowAnim && animBudget > 0 && body.dataset.dshsfClosing !== "1") {
|
|
456
467
|
animBudget -= 1;
|
|
457
468
|
layoutReads += 1;
|
|
458
|
-
// 只写一处:dataset 落到 DOM 上就是 data-dshsf-closing="1"(CSS 按"属性在不在"匹配)。
|
|
459
|
-
// 再补一句 setAttribute(…, "") 会把值改成空串,下面所有 `=== "1"` 的守卫全部失效
|
|
460
|
-
// (tickFollow 不再给收起的窗让路、revealBody/wireDisclosure 也认不出"正在收")——
|
|
461
|
-
// 帧循环每帧把 max-height 写回内容高度,收起过渡被反复拽回去:自动折叠只剩淡出、高度不动。
|
|
462
469
|
body.dataset.dshsfClosing = "1";
|
|
470
|
+
body.setAttribute("data-dshsf-closing", "");
|
|
463
471
|
body.style.overflow = "hidden";
|
|
464
472
|
body.style.maxHeight = body.getBoundingClientRect().height + "px";
|
|
465
473
|
void body.offsetHeight;
|
|
466
474
|
requestAnimationFrame(() => { body.style.maxHeight = "0px"; body.style.opacity = "0"; });
|
|
467
475
|
setTimeout(() => {
|
|
468
|
-
if (disposed) return; // 卸载后别再派发合成 click(拦截器有守卫,这一次没有)
|
|
469
476
|
const late = disclosureTarget(root);
|
|
470
477
|
if (late) clickOnce(late);
|
|
471
|
-
finishClose(body); // 合成 click 没能让 React 收起时别把窗留在"透明且 0 高"
|
|
472
478
|
}, 200);
|
|
473
479
|
return true;
|
|
474
480
|
}
|
|
@@ -478,27 +484,24 @@ function closeThink(root) {
|
|
|
478
484
|
if (root.dataset.dshsfKeepOpen === "1") return false; // 用户点开的,永远不动
|
|
479
485
|
if (!thinkOpen(root)) { delete root.dataset.dshsfAuto; autoThinks.delete(root); return false; }
|
|
480
486
|
if (root.dataset.dshsfAuto !== "1") { root.dataset.dshsfKeepOpen = "1"; autoThinks.delete(root); return false; } // 非我方打开 = 用户
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
return ok;
|
|
487
|
+
autoThinks.delete(root);
|
|
488
|
+
return collapse(root, thinkOpen, THINK_BODY, "dshsfCloseAt", "dshsfCloseTries");
|
|
484
489
|
}
|
|
485
490
|
function closeTool(root) {
|
|
486
|
-
|
|
487
|
-
if (root
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
return ok;
|
|
491
|
+
autoTools.delete(root);
|
|
492
|
+
if (!toolOpen(root)) { delete root.dataset.dshsfToolAuto; return false; }
|
|
493
|
+
if (root.dataset.dshsfToolAuto !== "1") return false; // 用户自己开的,不动
|
|
494
|
+
return collapse(root, toolOpen, TOOL_BODY, "dshsfToolCloseAt", "dshsfToolCloseTries");
|
|
491
495
|
}
|
|
492
496
|
/** 展开后按实测高度播揭示动画:固定 2000px 上限对矮卡片等于"瞬间全开",只有量到真实高度才看得见展开。 */
|
|
493
497
|
let lastReveal = null;
|
|
494
498
|
let lastWindow = null;
|
|
495
|
-
let lastClose = null; // 最近一次"收起一行/一个窗"的动画决策(诊断用)
|
|
496
499
|
const ANIM_MAX_PX = 1200; // 超过这个高度就别逐帧算 max-height 了:一次展开要重排几百毫秒,肉眼看不出区别
|
|
497
500
|
function revealBody(body, preHeight) {
|
|
498
501
|
if (!body || body.dataset.dshsfReveal === "1" || body.dataset.dshsfNoReveal === "1" || body.dataset.dshsfClosing === "1") return;
|
|
499
502
|
if (state.animations === false || !allowAnim) return;
|
|
500
503
|
let height = Math.ceil(preHeight === undefined ? body.getBoundingClientRect().height : preHeight);
|
|
501
|
-
if (body.parentElement && body.parentElement.closest("[data-dshsf-window]")) {
|
|
504
|
+
if (body.parentElement && body.parentElement.closest("[data-dshsf-window], [data-dshsf-textwindow]")) {
|
|
502
505
|
height = Math.min(height, windowCap() + 40); // 窗外那部分本来就被裁掉,动画目标不必长到几千像素
|
|
503
506
|
} else if (height > ANIM_MAX_PX) {
|
|
504
507
|
body.dataset.dshsfNoReveal = "1"; // 太高:直接出现
|
|
@@ -520,7 +523,6 @@ function scheduleSupersedeClose(root, ms) {
|
|
|
520
523
|
if (running) clearTimeout(running);
|
|
521
524
|
root.dataset.dshsfCloseTimer = String(setTimeout(() => {
|
|
522
525
|
delete root.dataset.dshsfCloseTimer;
|
|
523
|
-
if (state.mode !== "fold") return; // 已切到官方档位:别再动用户的窗口
|
|
524
526
|
const flow = chatScope();
|
|
525
527
|
const row = root.closest(ROW);
|
|
526
528
|
if (!row || !thinkOpen(root)) return; // 已经不在了
|
|
@@ -528,7 +530,6 @@ function scheduleSupersedeClose(root, ms) {
|
|
|
528
530
|
if (root === newestInTurn(flow, THINK, row.getAttribute("data-chat-turn"))) return; // 又成最新了
|
|
529
531
|
if (closeBudget <= 0) { scheduleSupersedeClose(root, 300); return; } // 一次扫描的收起配额用完了,稍后再收
|
|
530
532
|
if (closeThink(root)) closeBudget -= 1;
|
|
531
|
-
else delete root.dataset.dshsfSupersededAt; // 这次没收起(控件被换掉/被节流):撤掉"已排定时器"标记,让下一轮扫描重排
|
|
532
533
|
}, Math.max(0, ms)));
|
|
533
534
|
}
|
|
534
535
|
function openTool(root) {
|
|
@@ -568,8 +569,10 @@ function ensureWindowJump(el) {
|
|
|
568
569
|
jump.addEventListener("click", (event) => {
|
|
569
570
|
event.preventDefault();
|
|
570
571
|
event.stopPropagation();
|
|
571
|
-
|
|
572
|
-
|
|
572
|
+
const s = winOf(el);
|
|
573
|
+
s.writer = "us"; // 我方导航:取回写权,绝不交出
|
|
574
|
+
s.nav = true; // 一次性回底:不设 cap,滑到窗尾后转为常规跟随
|
|
575
|
+
ensureTickLoop();
|
|
573
576
|
jump.removeAttribute("data-dshsf-visible");
|
|
574
577
|
});
|
|
575
578
|
}
|
|
@@ -581,36 +584,56 @@ function jumpButton() {
|
|
|
581
584
|
const btn = document.createElement("button");
|
|
582
585
|
btn.type = "button";
|
|
583
586
|
btn.dataset.dshsfJump = "";
|
|
584
|
-
btn.setAttribute("aria-label",
|
|
585
|
-
btn.title =
|
|
587
|
+
btn.setAttribute("aria-label", "回到底部");
|
|
588
|
+
btn.title = "回到底部";
|
|
586
589
|
btn.innerHTML = JUMP_ICON;
|
|
587
590
|
return btn;
|
|
588
591
|
}
|
|
589
592
|
const wired = new WeakSet();
|
|
590
593
|
const liveWindows = new Set();
|
|
591
|
-
|
|
594
|
+
/** 唯一允许"跟尾(触底吸附)"的小窗 = 本轮 running 的最新思考正文;null = 谁都不跟。
|
|
595
|
+
* 来源与 sparks 焦点同源(syncWindows 的 newestThink),与 state.sparks 开关无关:
|
|
596
|
+
* 被取代的旧窗 / 上一轮的窗 / 跑完的窗一律不跟、也不 win-snap——静态窗不为任何外部滚动回写。 */
|
|
597
|
+
let activeWindow = null;
|
|
598
|
+
const winH = new WeakMap(); // 上一帧实测窗高:want=null 的 CSS 过渡收缩没有 JS 写入,只能靠实测差补负 dh
|
|
599
|
+
/** 小窗的写权 + 速度状态(与主容器同一模型、同一个滑行律):{ writer, nav, vFeed, vChase, prev }。
|
|
600
|
+
* 小窗只有一个目标 = 窗尾,所以没有独立的 goal 量;点回底按钮那一次 nav=true(不设追赶上限)。 */
|
|
601
|
+
const winState = new WeakMap();
|
|
602
|
+
/** Ctrl+F 搜索期间小窗一律撒手(writer=reader、不贴底、不跟尾);新登记/重挂载的窗也按读者态起步。
|
|
603
|
+
* 浏览器不给"搜索关闭"事件 ⇒ 解除放在输入层:下一笔真实页面输入(滚轮/触摸/按键/点击)即视为回到页面。 */
|
|
604
|
+
let searchHold = false;
|
|
605
|
+
const winOf = (el) => { let s = winState.get(el); if (s === undefined) { s = { writer: searchHold ? "reader" : "us", nav: false, ...newVel() }; winState.set(el, s); } return s; };
|
|
606
|
+
const winAtFloor = (el) => el.scrollHeight - el.clientHeight - el.scrollTop <= 0.5;
|
|
607
|
+
/** Ctrl+F:主容器与小窗一起撒手(搜索期间小窗零程序化写)。取回写权仍走既有入口:
|
|
608
|
+
* 读者真实滚回窗尾 / 点该窗回底按钮 / 会话边界——不新造机制。 */
|
|
609
|
+
function releaseWindows() {
|
|
610
|
+
searchHold = true;
|
|
611
|
+
for (const el of liveWindows) { const s = winOf(el); s.writer = "reader"; s.nav = false; resetVel(s); }
|
|
612
|
+
}
|
|
592
613
|
|
|
593
|
-
function wireWindow(el
|
|
614
|
+
function wireWindow(el) {
|
|
594
615
|
ensureWindowJump(el);
|
|
595
616
|
liveWindows.add(el);
|
|
617
|
+
const fresh = !winState.has(el); // 首次登记:只贴底一次(已展开/已登记的窗不二次重贴)
|
|
618
|
+
const ws = winOf(el); // 空闲值显式:新窗 = 我方跟尾(搜索期间 = 读者态)
|
|
619
|
+
if (searchHold) { ws.writer = "reader"; ws.nav = false; resetVel(ws); } // 搜索期间接线/重挂载:读者态起步,不贴底
|
|
620
|
+
if (fresh && el.scrollHeight > el.clientHeight) {
|
|
621
|
+
if (!searchHold && el === activeWindow) { // 只给活动窗贴底:搜索闩置位、或不是本轮在跑的那个窗,都不 win-snap
|
|
622
|
+
const floor = el.scrollHeight - el.clientHeight;
|
|
623
|
+
writeTop(el, floor, undefined, "win-snap"); // 首帧直接贴底(不走滑行:5000px 正文不该从顶部爬 10s;仍走唯一写点)
|
|
624
|
+
ws.prev = floor; // 前馈别把这“跳”当成目标速度
|
|
625
|
+
ws.wantS = floor; // 小窗跟随低通状态也从窗尾起算
|
|
626
|
+
}
|
|
627
|
+
}
|
|
596
628
|
if (wired.has(el)) return;
|
|
597
629
|
wired.add(el);
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
for (const type of ["wheel", "touchstart", "touchmove"]) el.addEventListener(type, noteIntent, { passive: true });
|
|
602
|
-
el.addEventListener("pointerdown", (event) => { if (event.target === el) noteIntent(); }, { passive: true });
|
|
630
|
+
const toReader = () => { const s = winOf(el); s.writer = "reader"; s.nav = false; searchHold = false; }; // 真实输入 ⇒ 交出这个窗的写权(无时间窗)+ 解除搜索闩(用户回到页面)
|
|
631
|
+
for (const type of ["wheel", "touchstart", "touchmove"]) el.addEventListener(type, toReader, { passive: true });
|
|
632
|
+
el.addEventListener("pointerdown", (event) => { if (event.target === el) toReader(); }, { passive: true });
|
|
603
633
|
el.addEventListener("scroll", () => {
|
|
604
|
-
if (disposed) return;
|
|
605
634
|
ensureTickLoop(); // 收敛后帧循环是停的:读者一滚就得排一帧,否则按钮要等下一次 DOM 变化才冒出来
|
|
606
|
-
|
|
607
|
-
if (
|
|
608
|
-
// 位置被**往回拉**(小于我方最后写入值)= 只有读者会这么做(我们的追赶只增加 scrollTop);
|
|
609
|
-
// 这条不依赖"600ms 内有输入信号"——输入信号只在 wheel/touch/拖滚动条时才有,漏一种读者就被当布局变化,
|
|
610
|
-
// 跟随不收手 → 窗口被一直拽回底部 → 回底按钮永远不出现("流式中的小窗没有回底按钮"就是这么来的)。
|
|
611
|
-
const up = Number.isFinite(mine) && el.scrollTop < mine - 3;
|
|
612
|
-
const since = performance.now() - Number(el.dataset.dshsfIntentAt || 0);
|
|
613
|
-
if ((up || since <= USER_SCROLL_MS) && !atBottom(el)) el.dataset.dshsfFollow = "";
|
|
635
|
+
// 位置落到窗尾 ⇒ 取回写权、目标=尾(与主容器规则 4 同一条)
|
|
636
|
+
if (!searchHold && winOf(el).writer !== "us" && winAtFloor(el)) winOf(el).writer = "us"; // 搜索闩置位时几何不算读者意图(浏览器 find 落在窗尾也是几何;取回只认真实输入)
|
|
614
637
|
}, { passive: true });
|
|
615
638
|
}
|
|
616
639
|
|
|
@@ -618,23 +641,14 @@ function wireWindow(el, followBottom = true) {
|
|
|
618
641
|
function tagThinkWindow(body, preHeight) {
|
|
619
642
|
if (!body || body.getAttribute("hidden") === SKIP) return; // 为搜索藏起的正文不渲染:打小窗会强制布局,白花
|
|
620
643
|
if (body.dataset.dshsfWindow === "reasoning") { wireWindow(body); return; } // 已打过标:补登记(折叠期间被移出过清单)
|
|
621
|
-
if (body.parentElement && body.parentElement.closest("[data-dshsf-window]")) return;
|
|
644
|
+
if (body.parentElement && body.parentElement.closest("[data-dshsf-window], [data-dshsf-textwindow]")) return;
|
|
622
645
|
// 先按实测高度写 --dshsf-hw 再打标:固定 260px 上限对"刚起步只剩一两行"的新思考,
|
|
623
646
|
// 动画十几毫秒就跑完了,看着跟没动效一样;按真实高度播才有完整 0.26s 的展开过程。
|
|
624
647
|
const cap = Math.max(80, Number(state.windowHeight) || 260);
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
body.style.setProperty("--dshsf-hw", visible + "px");
|
|
629
|
-
body.dataset.dshsfH = String(visible);
|
|
648
|
+
const visible = Math.min(Math.round(preHeight === undefined ? body.getBoundingClientRect().height : preHeight), cap);
|
|
649
|
+
body.style.setProperty("--dshsf-hw", Math.max(16, visible) + "px");
|
|
650
|
+
body.dataset.dshsfH = String(Math.max(16, visible));
|
|
630
651
|
body.dataset.dshsfWindow = "reasoning";
|
|
631
|
-
// 入场动画期间渲染高度由 CSS 动画说了算(而它的目标已经被夹在余量内),这段时间不做底边补偿:否则会按我们的
|
|
632
|
-
// 目标值多滚一截、在输入框上方开出一条空隙。没开动效就不标,补偿立刻生效。
|
|
633
|
-
if (document.documentElement.hasAttribute("data-dshsf-anim")) {
|
|
634
|
-
body.dataset.dshsfEntering = "1";
|
|
635
|
-
body.dataset.dshsfEnterH = String(visible); // 动画的落点:帧里在动画跑完前不许越过它
|
|
636
|
-
setTimeout(() => { delete body.dataset.dshsfEntering; }, 320);
|
|
637
|
-
}
|
|
638
652
|
wireWindow(body);
|
|
639
653
|
const record = { h: Math.round(body.getBoundingClientRect().height), cap: visible };
|
|
640
654
|
lastWindow = record;
|
|
@@ -647,28 +661,79 @@ function tagThinkWindow(body, preHeight) {
|
|
|
647
661
|
/** 每帧一次:先只读、后集中写(滚动卡顿的主因是读写交错)。 */
|
|
648
662
|
const supersedeDelay = () => Math.min(60, Math.max(0, num(state.supersedeDelay, 2))); // 单位:秒
|
|
649
663
|
const windowCap = () => Math.max(80, Number(state.windowHeight) || 260);
|
|
650
|
-
|
|
651
|
-
const WINDOW_MIN_H = 16;
|
|
652
|
-
/** 会话可视区的下边界(视口坐标 y):官方把 composer 以 sticky bottom:0 盖在滚动口底部([data-composer-seat]),
|
|
653
|
-
* 它上沿以下就是输入框区域 —— 小窗的底边不许越过去,否则新窗一长就压到输入框上。拿不到座位 = 不限制。 */
|
|
654
|
-
let seatEl, seatFor;
|
|
655
|
-
function composerSeat(scroller) {
|
|
656
|
-
if (scroller !== seatFor) { seatFor = scroller; seatEl = undefined; } // 换容器 = 重新查一次(别每帧走 querySelector)
|
|
657
|
-
if (seatEl === undefined || (seatEl !== null && !seatEl.isConnected)) seatEl = scroller ? scroller.querySelector("[data-composer-seat]") : null;
|
|
658
|
-
return seatEl;
|
|
659
|
-
}
|
|
660
|
-
function lowerBound() {
|
|
661
|
-
const seat = composerSeat(followScroller || mainScroller());
|
|
662
|
-
return seat ? seat.getBoundingClientRect().top : null;
|
|
663
|
-
}
|
|
664
|
-
/** 这个窗在「底边不越过下边界」的前提下还能长多高;bound 为 null(拿不到下边界)就不限制。 */
|
|
665
|
-
const roomAbove = (top, bound) => (bound === null ? Infinity : Math.max(WINDOW_MIN_H, bound - top));
|
|
664
|
+
const WIN_TAU_MS = 150; // 小窗跟随目标的一阶低通常量:与主容器 TAIL_TAU_MS 同值(用户要求小窗与主窗口手感一致;小窗只有 ~260px 高,滞后会比主窗口更显眼)
|
|
666
665
|
const num = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback);
|
|
667
666
|
const smoothGrow = () => Math.min(0.9, Math.max(0.05, num(state.smoothGrow, 0.25))); // 每帧吃掉的差距比例
|
|
668
|
-
|
|
669
|
-
|
|
667
|
+
/** 时间常数(τ_smooth):校准到「60Hz 单帧推进 = 旧线性式在 60Hz 的推进」。K = state.smoothGrow(DEFAULTS 0.15,函数里的 0.25 只是缺值兜底)
|
|
668
|
+
* ⇒ 默认 tau = -16.667 / ln(1 - 0.15·16.667/16) ≈ **98.0973ms**(实测每帧 alpha 0.1562、稳态 gap = v·tau)。 */
|
|
669
|
+
const smoothTau = () => -16.667 / Math.log(1 - Math.min(0.9, smoothGrow() * 16.667 / 16));
|
|
670
|
+
/** 指数律的精确单帧位移:dy·(1−e^(−dt/τ))——任意 dt 序列下 gap(T)=gap0·e^(−T/τ),与刷新率/帧数无关(不再有 minStep 下限)。 */
|
|
671
|
+
const expStep = (dy, dt) => dy * (1 - Math.exp(-dt / smoothTau()));
|
|
672
|
+
const FEED_TAU = 150; // 前馈 EMA 时间常数(ms):"目标自身速度"的慢估计
|
|
673
|
+
/** 每写者一份速度状态:vFeed=前馈(目标自己在长的速度,不夹上限);vChase=追赶速度(受加速度与速度上限约束)。
|
|
674
|
+
* 唯一状态量;换目标/重开帧循环必须 resetVel()。 */
|
|
675
|
+
const newVel = () => ({ vFeed: 0, vChase: 0, prev: null });
|
|
676
|
+
const resetVel = (st) => { st.vFeed = 0; st.vChase = 0; st.prev = null; };
|
|
677
|
+
/**
|
|
678
|
+
* **唯一滑行律**(主容器跟随 / 一次性导航 / 小窗跟随·回底共用)。每帧重算目标 want。
|
|
679
|
+
* ① 前馈 vFeed = 目标自身速度的慢 EMA(clamp ≥0,不吃 vMax:内容是自己在长,我们的义务是跟上)
|
|
680
|
+
* ② 追赶 vWant = 指数律精确复合 /(dt/1000);③ 加速度受限;④ vMax 只夹追赶那一段;v = vFeed + vChase
|
|
681
|
+
* ⑤ |dy|≤0.5 精确落点并清零 vChase。vMax<=0 = 不设追赶上限(一次性导航)。
|
|
682
|
+
* 退化性:a→∞ 且 vFeed=0 ⇒ step = min(vMax·dt/1000, 指数律)(旧律)。
|
|
683
|
+
* 返回是否还在动。写点唯一:writeTop。
|
|
684
|
+
*/
|
|
685
|
+
function slide(el, want, dt, vMax, st, curHint, tag, maxHint) {
|
|
686
|
+
const t = tag || "slide";
|
|
687
|
+
const cur = curHint === undefined ? el.scrollTop : curHint; // task-22:调用方已知(计划里读过)就别再读一次
|
|
688
|
+
const curPx = snapPx(cur), wantPx = snapPx(want);
|
|
689
|
+
if (curPx === wantPx) { // ⑤ 到位(物理像素意义):不留亚像素残差,也不继续排帧
|
|
690
|
+
st.vChase = 0; st.prev = want;
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
const dtS = dt / 1000;
|
|
694
|
+
if (Math.abs(wantPx - curPx) <= 1 / pxDpr() + 1e-9) { // 目标像素最多差一格:直接落到目标像素(否则速度永远凑不出一步 ⇒ 180 帧卡死)
|
|
695
|
+
st.vChase = 0; st.prev = want;
|
|
696
|
+
writeTop(el, wantPx, cur, t);
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
const dy = want - cur;
|
|
700
|
+
if (Math.abs(dy) <= 0.5) { // ⑤ 精确落点:不留残差
|
|
701
|
+
if (dy !== 0) writeTop(el, want, cur, t);
|
|
702
|
+
st.vChase = 0;
|
|
703
|
+
st.prev = want;
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
if (st.prev === null) st.vFeed = 0; // 目标刚换人:前馈从 0 起(不携带陈旧速度)
|
|
707
|
+
else st.vFeed += (Math.max(0, (want - st.prev) / dtS) - st.vFeed) * (1 - Math.exp(-dt / FEED_TAU));
|
|
708
|
+
st.prev = want;
|
|
709
|
+
const vWant = expStep(dy, dt) / dtS; // ② 指数律的精确复合(不是线性式)
|
|
710
|
+
const dv = accelValue() * dtS; // ③ 加速度受限(dt 归一)
|
|
711
|
+
st.vChase += Math.max(-dv, Math.min(dv, vWant - st.vChase));
|
|
712
|
+
if (vMax > 0) st.vChase = Math.max(-vMax, Math.min(vMax, st.vChase)); // ④ 上限只作用于追赶
|
|
713
|
+
let step = (st.vFeed + st.vChase) * dtS;
|
|
714
|
+
if (Math.abs(step) > Math.abs(dy)) step = dy;
|
|
715
|
+
if (!(Math.abs(step) > 1e-6)) { st.vChase = 0; return false; }
|
|
716
|
+
const wrote = writeTop(el, cur + step, cur, t);
|
|
717
|
+
if (!wrote) { // task-25:不写成功时用解析预测判"是否已到位",**不回读 scrollTop**(那是写后同帧读 ⇒ 强制布局)
|
|
718
|
+
const predicted = maxHint === undefined ? el.scrollTop : Math.min(maxHint, Math.max(0, snapPx(cur + step)));
|
|
719
|
+
if (snapPx(predicted) === wantPx) { st.vChase = 0; st.prev = want; return false; }
|
|
720
|
+
}
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
670
723
|
// 自身耗时统计:只统计"我们的 JS",不含浏览器的布局/绘制(那部分不在我们手里)
|
|
671
724
|
const cost = { frames: 0, tickMs: 0, scans: 0, scanMs: 0, lastMs: 0, maxMs: 0, phases: {}, counts: {}, worst: null, lastError: null, pinMs: 0, pinFrames: 0 };
|
|
725
|
+
/* task-19 ②:tick 内分阶段计时(plan=读尺寸+低通 / main=主容器推进 / win=小窗写)。尖峰归谁看这里。 */
|
|
726
|
+
const tickPhase = {}; // 各相位累计(probe 里除以 n)
|
|
727
|
+
const phaseMs = { plan: 0, main: 0, win: 0, winStyle: 0, winSlide: 0, winJump: 0, sparks: 0, pin: 0, announce: 0, absorb: 0, mainPlan: 0, mainSlide: 0, mainGate: 0 };
|
|
728
|
+
let pinRects = 0; // task-25:pinTick 里的 getBoundingClientRect 调用次数(diagnostic)
|
|
729
|
+
const phaseAcc = (k, t0) => { phaseMs[k] += performance.now() - t0; };
|
|
730
|
+
const tickSamples = []; // 最近 200 帧的 tickMs(p50/p95/max)
|
|
731
|
+
let phPlanMs = 0, phMainMs = 0, phWinMs = 0; // tickFollowInner 写、tickFollow 取(分阶段计时)
|
|
732
|
+
let readCount = 0; // 诊断:本帧的强制布局读次数(scrollHeight/clientHeight)
|
|
733
|
+
let frameTop = 0, frameSh = 0, frameCh = 0; // task-19 ②:本帧主容器几何的一次读结果(全帧复用;frameTop 为写后值)
|
|
734
|
+
let frameToken = 0, vtStamp = -1, vtRect = null; // task-19 ②:viewTop 的帧内容器 rect 缓存(仅 tick 帧内有效,帧外走原式)
|
|
735
|
+
/** task-20:显式 scrollTop 读的统一入口(口径(S2-2)计数的一部分);调用方已知值请传 curHint,不要多读。 */
|
|
736
|
+
const readTop = (el) => { readCount += 1; return el.scrollTop; };
|
|
672
737
|
/** 分位数:单次采样会被外部卡顿(远程桌面/后台节流/GC)污染,中位数才反映我们自己的成本。 */
|
|
673
738
|
function percentile(list, q) {
|
|
674
739
|
if (!list.length) return 0;
|
|
@@ -684,125 +749,175 @@ function topPhases(phases) {
|
|
|
684
749
|
.slice(0, 5);
|
|
685
750
|
}
|
|
686
751
|
function resetCost() { scanSamples.length = 0; cost.frames = 0; cost.tickMs = 0; cost.scans = 0; cost.scanMs = 0; cost.lastMs = 0; cost.maxMs = 0; cost.phases = {}; cost.counts = {}; cost.worst = null; cost.lastError = null; cost.pinMs = 0; cost.pinFrames = 0; sparks.resetCost(); }
|
|
687
|
-
let followTickAt = 0;
|
|
688
752
|
let ticking = false;
|
|
689
753
|
let rafId = 0; // 帧循环句柄(dispose 要能停掉它)
|
|
690
754
|
let disposed = false; // 实例已收摊:所有异步入口早退(热重载后旧实例不许再动 DOM)
|
|
691
755
|
/** 未收敛就自己接着排帧:只靠 MutationObserver 的话,两次输出之间的那段就冻住不动了。 */
|
|
692
|
-
|
|
693
|
-
let
|
|
694
|
-
|
|
756
|
+
let lastTickTs = 0;
|
|
757
|
+
let gutterSet = false; // H16:稳定滚动条槽(bootOver 之后才设;三因子隔离证明静态版单独无判据影响,但仍推迟以避开初始化大步)
|
|
758
|
+
const TICK_MIN_MS = 5; // H16:推进循环最小间隔(320Hz 隔帧 ~160Hz;必要参数:每次 scrollHeight 都是强制重排)
|
|
759
|
+
function tickLoop(timestamp) {
|
|
695
760
|
if (disposed) { ticking = false; return; }
|
|
696
|
-
const now = performance.now()
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
761
|
+
const ts = timestamp || performance.now(); // rAF 回调时间戳 = 上一帧渲染结束时刻(不用 performance.now())
|
|
762
|
+
const prevTs = lastTickTs;
|
|
763
|
+
if (prevTs !== 0 && ts - prevTs < TICK_MIN_MS) { rafId = requestAnimationFrame(tickLoop); return; }
|
|
764
|
+
const dt = prevTs === 0 ? 16.667 : Math.min(500, Math.max(1, ts - prevTs)); // A14:首帧按一个 60Hz 帧记(内层本就以 16.667 兜底;trace 不再记 ts-ts=0);解析式对任意 dt 精确 → 只夹极端值防巨跳
|
|
765
|
+
lastTickTs = ts;
|
|
766
|
+
if (!tickFollow(dt)) {
|
|
767
|
+
ticking = false;
|
|
768
|
+
if (tickRetry && !disposed) { tickRetry = false; ensureTickLoop(); } // 异常后立即重排:外围不能因一行异常整条停摆
|
|
769
|
+
return;
|
|
700
770
|
}
|
|
701
771
|
rafId = requestAnimationFrame(tickLoop);
|
|
702
772
|
}
|
|
703
773
|
function ensureTickLoop() {
|
|
704
774
|
if (ticking || disposed) return;
|
|
705
775
|
ticking = true;
|
|
776
|
+
lastTickTs = 0; // 帧循环重启:首帧 dt 从 0 起算(不把停摆期间的时间差当一帧)
|
|
777
|
+
resetVel(mainVel); // 重启时速度复位(不携带陈旧前馈/追赶速度)
|
|
778
|
+
for (const el of liveWindows) { const s = winState.get(el); if (s !== undefined) resetVel(s); }
|
|
706
779
|
rafId = requestAnimationFrame(tickLoop);
|
|
707
780
|
}
|
|
708
|
-
|
|
781
|
+
let tickErrors = 0; // 帧循环体内异常次数(诊断):一行异常不得再打死整条循环
|
|
782
|
+
let lastTickError = null; // 最近一次帧循环异常 {message, stack, at}
|
|
783
|
+
let tickRetry = false; // 本轮循环体抛过异常 ⇒ 不认"已收敛",由 tickLoop 收尾处立即重排重试
|
|
784
|
+
function tickFollow(dt) {
|
|
709
785
|
const t0 = performance.now();
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
786
|
+
const w0 = writeCount, a0 = announceCount, r0 = readCount, g0 = followScroller === null ? null : frameTop; // task-19:g0 = 上一帧写后位置(不再回读)
|
|
787
|
+
const ph0 = { ...phaseMs };
|
|
788
|
+
for (const k in writeTally) delete writeTally[k]; // task-22:本帧写来源从零开始记
|
|
789
|
+
let moving = false;
|
|
790
|
+
try {
|
|
791
|
+
moving = tickFollowInner(dt);
|
|
792
|
+
const tickMs = performance.now() - t0;
|
|
793
|
+
if (moving || writeCount !== w0 || announceCount !== a0) { // 空闲不记:这一帧什么都没发生就不进轨迹
|
|
794
|
+
const sc = followScroller;
|
|
795
|
+
const fl = sc === null ? 0 : Math.max(0, frameSh - frameCh); // task-19:复用本帧几何
|
|
796
|
+
if (tickSamples.length >= 200) tickSamples.shift();
|
|
797
|
+
tickSamples.push(+tickMs.toFixed(2));
|
|
798
|
+
const tickEnd = t0 + tickMs; // 分阶段:plan=建计划(读尺寸+低通) / main=主容器推进 / win=小窗写
|
|
799
|
+
const phPlan = phMainMs - phPlanMs, phMain = phWinMs - phMainMs, phWin = tickEnd - phWinMs;
|
|
800
|
+
const phD = { plan: +phPlan.toFixed(2), main: +phMain.toFixed(2), win: +phWin.toFixed(2) };
|
|
801
|
+
for (const k in phaseMs) if (k !== "plan" && k !== "main" && k !== "win") phD[k] = +(phaseMs[k] - ph0[k]).toFixed(2);
|
|
802
|
+
phD.other = +Math.max(0, tickMs - (phPlan + phMain + phWin + (phaseMs.sparks - ph0.sparks))).toFixed(2);
|
|
803
|
+
for (const k in phD) tickPhase[k] = (tickPhase[k] || 0) + phD[k];
|
|
804
|
+
tickPhase.n = (tickPhase.n || 0) + 1;
|
|
805
|
+
traceFrame({
|
|
806
|
+
dt: +dt.toFixed(2), writes: writeCount - w0, announces: announceCount - a0,
|
|
807
|
+
tickMs: +tickMs.toFixed(2), reads: readCount - r0,
|
|
808
|
+
ph: phD,
|
|
809
|
+
wsrc: Object.keys(writeTally).length ? { ...writeTally } : undefined,
|
|
810
|
+
step: sc === null || g0 === null ? 0 : +(frameTop - g0).toFixed(2),
|
|
811
|
+
gap: sc === null ? 0 : +(fl - frameTop).toFixed(2), writer, goal: goalKind, // task-19:复用本帧写后位置
|
|
812
|
+
scanMs: +scanMsSinceFrame.toFixed(2), scans: scanCountSinceFrame, // 归因 dt 尖峰:这一帧我们自己扫了多久
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
} catch (err) {
|
|
816
|
+
tickErrors += 1;
|
|
817
|
+
lastTickError = { message: String((err && err.message) || err), stack: String((err && err.stack) || "").split("\n")[1] || "", at: Math.round(performance.now()) };
|
|
818
|
+
ticking = false; // 解死锁:异常路径曾把 ticking 永久留在 true ⇒ ensureTickLoop() 永久早退
|
|
819
|
+
tickRetry = true; // 重排交给 tickLoop 收尾处(避免与它重复排帧 ⇒ 双链)
|
|
820
|
+
} finally {
|
|
821
|
+
cost.frames += 1;
|
|
822
|
+
cost.tickMs += performance.now() - t0;
|
|
823
|
+
scanMsSinceFrame = 0; scanCountSinceFrame = 0; // 每帧都清零:这帧没记进轨迹也不许把账留给下一帧
|
|
824
|
+
}
|
|
713
825
|
return moving;
|
|
714
826
|
}
|
|
715
|
-
function tickFollowInner() {
|
|
827
|
+
function tickFollowInner(dt) {
|
|
716
828
|
tickCount += 1;
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
if (sparks.tick(dt)) moving = true; // 火星:模块自带"该不该跑",熄灭且不发光时返回 false(空闲零成本)
|
|
722
|
-
if (liveWindows.size === 0) { if (moving) ensureTickLoop(); return moving; }
|
|
723
|
-
const scroller = mainScroller();
|
|
724
|
-
const bound = lowerBound(); // 每帧只读一次:所有小窗共用同一条下边界
|
|
725
|
-
const scrollAt = scroller ? scroller.scrollTop : 0; // 读阶段读一次:写阶段要拿它做"底边补偿",不能在写完高度后再读
|
|
726
|
-
// 跟随中位置归我们管:小窗长出来的量用同量的滚动补掉(底边钉住、窗往上长),既不会伸进输入框,
|
|
727
|
-
// 也不会因为"看不见余量"而永远长不大。读者接管了位置就不是我们的:那时只能夹高度。
|
|
728
|
-
const pinBottom = bound !== null && scroller !== null && followOn && state.autoFollow !== false;
|
|
829
|
+
if (!(dt > 0)) dt = 16.667; // 同 mainFollowTick:首帧 dt=0 的兜底(小窗跟随/回底也不能白跑一帧)
|
|
830
|
+
if (gateOfficialMode()) return false; // 官方档位:小窗也不写、按钮收起(整个运动层退出)
|
|
831
|
+
// 小窗计划先只读建好、只算不写(A-noA4:不再把窗的剩余目标折进主吸附)
|
|
832
|
+
phPlanMs = performance.now();
|
|
729
833
|
const plan = [];
|
|
730
834
|
for (const el of liveWindows) {
|
|
731
|
-
if (!el.isConnected) { liveWindows.delete(el); continue; }
|
|
732
|
-
|
|
835
|
+
if (!el.isConnected) { liveWindows.delete(el); continue; } // React 卸载:只退登记
|
|
836
|
+
const ch = el.clientHeight;
|
|
837
|
+
if (ch === 0) continue;
|
|
733
838
|
const sh = el.scrollHeight; // 每帧只读一次:读尺寸在 DOM 脏的时候会强制重排
|
|
734
|
-
const max = sh -
|
|
735
|
-
const
|
|
839
|
+
const max = sh - ch;
|
|
840
|
+
const st = winOf(el);
|
|
841
|
+
if (st.snapped !== true) { // 小窗首次跟随:直接贴底(5000px 正文不该从顶部爬 10s;只做一次)
|
|
842
|
+
st.snapped = true;
|
|
843
|
+
if (st.writer === "us" && el === activeWindow && max > 0.5 && el.scrollTop < max - 0.5) { writeTop(el, max, undefined, "win-snap"); resetVel(st); st.wantS = max; } // 只有活动窗首帧贴底
|
|
844
|
+
}
|
|
845
|
+
const top0 = el.scrollTop; // task-22:本帧该窗只读这一次 scrollTop(原 gap/plan 各读一次)
|
|
846
|
+
const gap = max - top0;
|
|
736
847
|
const bottom = gap <= 24;
|
|
737
|
-
|
|
848
|
+
if (!searchHold && st.writer !== "us" && gap <= 0.5) { st.writer = "us"; st.nav = false; resetVel(st); st.wantS = undefined; } // 规则 4:读者落到窗尾 ⇒ 取回写权(速度状态复位 + 低通重新对齐);搜索闩置位时不认几何
|
|
849
|
+
// 小窗跟随目标独立低通(task-18 ④):一次性回底(nav)不走低通,首次/收缩直接对齐
|
|
850
|
+
if (st.nav || st.wantS === undefined || st.wantS > max + 1) st.wantS = max;
|
|
851
|
+
else st.wantS += (max - st.wantS) * (1 - Math.exp(-dt / WIN_TAU_MS));
|
|
852
|
+
const winWant = st.nav ? max : st.wantS;
|
|
853
|
+
// 跟尾 = 写权在我方 ∧ 这还是本轮在跑的那个活动窗;st.nav 是用户点回底的一次性请求(不算吸附)。
|
|
854
|
+
const follow = st.writer === "us" && (el === activeWindow || st.nav);
|
|
738
855
|
// 流式输出是一行一行来的:max-height 跟住内容高度(长出来而不是跳一格)
|
|
739
|
-
const
|
|
740
|
-
const
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
//
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
plan.push({ el, jump, bottom, gap, follow, cur
|
|
751
|
-
}
|
|
752
|
-
|
|
856
|
+
const want = el.dataset.dshsfClosing === "1" ? null : Math.min(sh, windowCap());
|
|
857
|
+
const known = el.dataset.dshsfH;
|
|
858
|
+
const cur = known === undefined ? want : Number(known);
|
|
859
|
+
const h0 = winH.get(el); winH.set(el, ch); // 只读计划里记下实测高:收窗帧的缩量由它给出
|
|
860
|
+
const dh = want === null ? (h0 === undefined ? 0 : ch - h0) // CSS 过渡收窗:没有 JS 写入,缩量 = 实测高差
|
|
861
|
+
: (Math.abs(want - cur) <= 0.5 ? 0 : expStep(want - cur, dt));
|
|
862
|
+
let jump = jumpOf.get(el); // task-20 A10:帧路径优先信自己的登记(DOM 挪动/多实例都不会认错),缺失才查询并登记
|
|
863
|
+
if (jump === undefined) {
|
|
864
|
+
jump = el.querySelector(":scope > [data-dshsf-jump]");
|
|
865
|
+
if (jump !== null && jump !== undefined) jumpOf.set(el, jump);
|
|
866
|
+
}
|
|
867
|
+
plan.push({ el, jump, bottom, gap, follow, cur, want, dh, known, top: top0, max, sh, nav: st.nav, st, winWant });
|
|
868
|
+
}
|
|
869
|
+
phMainMs = performance.now();
|
|
870
|
+
let moving = mainFollowTick(dt);
|
|
871
|
+
// task-19 例外:这一句被 static-check 逐字钉住("每帧必须同步几何基线"),且几何基线语义上就是"此刻真实几何";
|
|
872
|
+
// 保留这 2 次读。滑行帧其余重复读已复用 frameTop/frameSh/frameCh 同一份。
|
|
873
|
+
vtStamp = -1; // task-19 ②:帧结束 ⇒ viewTop 缓存失效(帧外调用回原式)
|
|
874
|
+
if (followScroller !== null) { baseSt = followScroller.scrollTop; baseSh = followScroller.scrollHeight; } // 帧基线:下一笔外来写的"同期布局变化"基准
|
|
875
|
+
{ const tS = performance.now(); if (sparks.tick(dt)) moving = true; phaseAcc("sparks", tS); } // 火星:模块自带"该不该跑",熄灭且不发光时返回 false(空闲零成本)
|
|
876
|
+
phWinMs = performance.now();
|
|
877
|
+
if (plan.length === 0) { if (moving) ensureTickLoop(); return moving; }
|
|
753
878
|
for (const item of plan) {
|
|
879
|
+
readCount += 2; // 每窗:clientHeight + scrollHeight 两次强制布局读
|
|
880
|
+
item.topAfter = item.top; // task-25:写 maxHeight 之后的预测位置(默认不变)
|
|
881
|
+
item.maxAfter = item.max; // 写 maxHeight 之后的可滚动上限(= sh - newHeight)
|
|
882
|
+
let nextH = null; // task-27:本轮要写的高度(先算出来,但**样式最后写**)
|
|
754
883
|
if (item.want !== null) {
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const next = (cur + Math.sign(gap) * step).toFixed(1);
|
|
761
|
-
item.el.dataset.dshsfH = next;
|
|
762
|
-
item.el.style.setProperty("--dshsf-hw", next + "px"); // 入场动画还在跑时,动画目标也跟着长(避免交接跳一下)
|
|
763
|
-
item.el.style.maxHeight = next + "px";
|
|
884
|
+
if (item.known === undefined) item.el.dataset.dshsfH = String(item.want);
|
|
885
|
+
if (item.dh !== 0) { // dh 带符号,来自只读计划里的同一个指数律:与主吸附本帧推进同速
|
|
886
|
+
nextH = (item.cur + item.dh).toFixed(1);
|
|
887
|
+
item.maxAfter = Math.max(0, item.sh - Number(nextH)); // 目标必须按**变更后的 extent** 算(否则长高那几帧会被旧 max 截断)
|
|
888
|
+
item.topAfter = Math.min(item.top, item.maxAfter);
|
|
764
889
|
moving = true;
|
|
765
|
-
// 只补"这一帧新长出来、而且会加深越界"的那部分:已经越界的旧滞后仍归跟随公式慢慢收,否则等于把平滑跟随一键瞬移掉。
|
|
766
|
-
// 用实测渲染高度当基准(不是 dataset 的目标值):入场动画期间渲染高度和我们的目标值不是一回事。
|
|
767
|
-
if (pinBottom && !item.entering) over = Math.max(over, Math.min(Number(next) - item.renderedH, item.top + Number(next) - bound));
|
|
768
|
-
} else {
|
|
769
|
-
if (known === undefined) item.el.dataset.dshsfH = String(item.want);
|
|
770
|
-
// 自愈:内联 max-height 可能停在旧值或被 React 重渲染抹掉(dataset 记的是目标值)—— 只比字符串、
|
|
771
|
-
// 不读布局。不补这一刀,窗口就会一直停在旧高度(用户截图里那种"半空的玻璃面板")。
|
|
772
|
-
const target = item.want + "px";
|
|
773
|
-
if (item.el.style.maxHeight !== target) { item.el.style.maxHeight = target; moving = true; }
|
|
774
890
|
}
|
|
775
891
|
}
|
|
776
892
|
// 跟随中:朝底部"追"过去,而不是直接跳到 max —— 内容才有顺流而下的感觉
|
|
777
|
-
if (item.follow
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
moving = true;
|
|
893
|
+
if (item.follow) {
|
|
894
|
+
// 与主容器同一个滑行律(slide):跟随 step=min(cap, rate);回底按钮发起的那一次不设 cap。
|
|
895
|
+
// 不给 gap 设门槛:|dy|≤0.5 的精确落点在 slide 内部完成(门槛会让窗永远差 <0.5px 停住)。
|
|
896
|
+
const vMaxW = (() => { const v = Number(state.followMaxSpeed); return Number.isFinite(v) && v > 0 ? v : DEFAULTS.followMaxSpeed; })(); // 单一真源(px/s)
|
|
897
|
+
const tSli = performance.now();
|
|
898
|
+
if (slide(item.el, item.winWant, dt, item.nav ? 0 : vMaxW, item.st, item.topAfter, "win", item.maxAfter)) moving = true; // task-27:**先写 scrollTop**(cur/max 用预测的变更后 extent)
|
|
899
|
+
phaseAcc("winSlide", tSli);
|
|
900
|
+
}
|
|
901
|
+
if (nextH !== null) { // task-27:样式写放到最后(帧内不再出现"写完布局→再写滚动"的栅栏)
|
|
902
|
+
const tSty = performance.now();
|
|
903
|
+
item.el.dataset.dshsfH = nextH;
|
|
904
|
+
item.el.style.setProperty("--dshsf-hw", nextH + "px"); // 入场动画还在跑时,动画目标也跟着长(避免交接跳一下)
|
|
905
|
+
item.el.style.maxHeight = nextH + "px";
|
|
906
|
+
phaseAcc("winStyle", tSty);
|
|
783
907
|
}
|
|
784
|
-
if (item.
|
|
908
|
+
if (item.nav && item.topAfter >= item.max - 0.5) winOf(item.el).nav = false; // task-25:用预测值判到位(不读 scrollTop)
|
|
785
909
|
item.el.toggleAttribute("data-dshsf-offbottom", item.gap > 24);
|
|
786
910
|
if (!item.jump) item.jump = ensureWindowJump(item.el); // 自愈:登记过的窗任何时候都必须有按钮
|
|
787
911
|
if (item.jump) {
|
|
788
|
-
// 读者接管了 → 给按钮;跟随中只有"落后很多"才给(追赶时的轻微落后不该让按钮一闪一闪)
|
|
789
912
|
// 显隐带滞回:点亮要"读者接管或落后很多",熄灭只在真正回到贴底(gap<12)——
|
|
790
913
|
// 跟随状态/差距在阈值附近抖动时按钮不该跟着闪。
|
|
914
|
+
const tJm = performance.now();
|
|
791
915
|
if (state.showJumpButton === false) item.jump.removeAttribute("data-dshsf-visible");
|
|
792
916
|
else if (item.jump.hasAttribute("data-dshsf-visible")) { if (item.gap < 12) item.jump.removeAttribute("data-dshsf-visible"); }
|
|
793
917
|
else if (item.gap > 24 && (!item.follow || item.gap > 120)) item.jump.setAttribute("data-dshsf-visible", "");
|
|
918
|
+
phaseAcc("winJump", tJm);
|
|
794
919
|
}
|
|
795
920
|
}
|
|
796
|
-
if (over > 0) {
|
|
797
|
-
// 窗长多少,视图就跟着走多少:底边不越过下边界。
|
|
798
|
-
// ⚠️ 这一笔必须登记进 followWrote(和 mainFollowTick 里一样),否则下一帧的"官方抢跑回补"会把它当成别人的写入
|
|
799
|
-
// 拽回旧点:窗掉回输入框下面(补偿白做),而且每帧"补/拽"来回 = 主窗口抖动。
|
|
800
|
-
const next = scrollAt + over;
|
|
801
|
-
writeTop(scroller, next);
|
|
802
|
-
followWrote = next;
|
|
803
|
-
moveCount += 1;
|
|
804
|
-
moving = true;
|
|
805
|
-
}
|
|
806
921
|
if (moving) ensureTickLoop();
|
|
807
922
|
return moving;
|
|
808
923
|
}
|
|
@@ -815,36 +930,33 @@ const manualState = new Map(); // turn -> "open" | "fold"
|
|
|
815
930
|
* 会话 id 由官方挂在对话根上([data-conversation-session])。 */
|
|
816
931
|
let sessionKey = null;
|
|
817
932
|
function syncSession() {
|
|
818
|
-
|
|
819
|
-
// (那会让切主会话不触发复位,或反过来把主会话的手动展开状态清掉)。
|
|
820
|
-
const scope = chatScope();
|
|
821
|
-
// 官方把 data-conversation-session 挂在滚动容器的**父层**(div.body[data-conversation-content]),
|
|
822
|
-
// 在 scroll 元素内部 querySelector 恒为 null —— 必须从自己往上找,否则退化成 document 序第一个,
|
|
823
|
-
// 右栏子代理会话排在前面时「切主会话」根本不复位。
|
|
824
|
-
const holder = (scope && scope.closest ? scope.closest("[data-conversation-session]") : null)
|
|
825
|
-
|| scope.querySelector("[data-conversation-session]")
|
|
826
|
-
|| document.querySelector("[data-conversation-session]");
|
|
933
|
+
const holder = document.querySelector("[data-conversation-session]");
|
|
827
934
|
const key = holder ? holder.getAttribute("data-conversation-session") : null;
|
|
828
935
|
if (key === null || key === "") return; // 当前视图没有会话信息(设置页/嵌入态):状态不动
|
|
829
936
|
if (sessionKey === null) { sessionKey = key; return; }
|
|
830
937
|
if (key === sessionKey) return;
|
|
831
938
|
sessionKey = key;
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
announcedHeight = -1;
|
|
939
|
+
clearSettle("session"); // 会话边界:协调作废(跨会话不许留任何进行中的状态)
|
|
940
|
+
lastUserKey = null; // 会话边界:回尾判据重新开始记录(换会话不许误判成"你刚发了消息")
|
|
941
|
+
// 会话边界:复位运动模型自己的状态量(陈旧坐标不许跨会话参与写回;公式与机制一行不动)
|
|
942
|
+
if (followScroller) { followScroller.removeEventListener("scroll", onMainScroll); followScroller.removeEventListener("pointerdown", onScrollerPointerDown); }
|
|
943
|
+
followScroller = null; // 强制重挂监听 + takeOverSnap
|
|
944
|
+
followWrote = null; // 陈旧坐标不许参与吸收时的恢复
|
|
839
945
|
followHeight = -1;
|
|
946
|
+
announcedTop = -1; announcedHeight = -1; lastAnnounceAt = 0; // 切换期停发合成 scroll 事件
|
|
947
|
+
lastStep = null;
|
|
948
|
+
// 会话边界 = 交出写权的两个入口之一(另一个是读者真实输入)。只钉不跟:接回跟尾由
|
|
949
|
+
// 「换容器语义判定」与 tick 的「落末端」规则负责(不依赖任何标志,与 tick 的先后顺序无关)。
|
|
950
|
+
release("reset"); // 已是 reader 时只记原因(release 里已清 navIntent)
|
|
951
|
+
goalKind = "none"; goalEl = null; navActive = false;
|
|
952
|
+
resetVel(mainVel); // 会话边界:速度复位
|
|
953
|
+
for (const el of liveWindows) { const s = winOf(el); s.writer = "us"; s.nav = false; } // 小窗写权同样归零
|
|
954
|
+
activeWindow = null; // 会话边界:跟尾对象作废(新会话由扫描重新认领)
|
|
955
|
+
manualState.clear();
|
|
956
|
+
qCardTurns.clear(); // A4:提问卡"已展开"按轮号记 —— 跨会话必须先清,否则新会话同轮号不再自动展开
|
|
840
957
|
wasRunning = false;
|
|
841
958
|
lastRunningAt = 0;
|
|
842
959
|
runningSticky = false;
|
|
843
|
-
// 换会话必须把上一会话的「收尾折叠 hold」清掉:data-chat-turn 每会话都从 1 起,
|
|
844
|
-
// 留着它会让新会话里同号的历史轮被当成"刚跑完那轮"——那不是不折,是主动展开(走 toUnfoldAll)。
|
|
845
|
-
settleHoldTurn = null;
|
|
846
|
-
settleHoldUntil = 0;
|
|
847
|
-
textCache = new WeakMap(); // 换会话:行节点会被复用,正文缓存整表作废
|
|
848
960
|
clearTimeout(runGraceTimer);
|
|
849
961
|
for (const chip of document.querySelectorAll("[data-dshsf-chip]")) { try { chip.remove(); } catch { /* ignore */ } }
|
|
850
962
|
}
|
|
@@ -866,7 +978,7 @@ function skipSoon(row) {
|
|
|
866
978
|
setTimeout(() => {
|
|
867
979
|
if (!row.hasAttribute("data-dshsf-folded")) return;
|
|
868
980
|
setSkipHidden(row);
|
|
869
|
-
for (const el of row.querySelectorAll("[data-dshsf-window]")) liveWindows.delete(el); // 不渲染的窗不必每帧跟进
|
|
981
|
+
for (const el of row.querySelectorAll("[data-dshsf-window], [data-dshsf-textwindow]")) liveWindows.delete(el); // 不渲染的窗不必每帧跟进
|
|
870
982
|
}, 320);
|
|
871
983
|
}
|
|
872
984
|
function applyFold(row, fold, animate) {
|
|
@@ -877,13 +989,13 @@ function applyFold(row, fold, animate) {
|
|
|
877
989
|
if (fold && !isSkipped(row)) skipSoon(row); // 已经是折的状态(热重载/旧状态)也补挂
|
|
878
990
|
return;
|
|
879
991
|
}
|
|
880
|
-
if (!animate ||
|
|
992
|
+
if (!animate || animBudget <= 0) { // 配额用完 → 直接切(一次要动几十个元素时,动画本来也看不清)
|
|
881
993
|
// 瞬时路径:不读、不 flush,一次布局都不做
|
|
882
994
|
row.style.maxHeight = "";
|
|
883
995
|
row.style.overflow = "";
|
|
884
996
|
row.toggleAttribute("data-dshsf-folded", fold);
|
|
885
997
|
if (fold) setSkipHidden(row);
|
|
886
|
-
else
|
|
998
|
+
else clearSkipHidden(row);
|
|
887
999
|
return;
|
|
888
1000
|
}
|
|
889
1001
|
animBudget -= 1;
|
|
@@ -897,13 +1009,16 @@ function applyFold(row, fold, animate) {
|
|
|
897
1009
|
skipSoon(row);
|
|
898
1010
|
return;
|
|
899
1011
|
}
|
|
1012
|
+
unfoldRowAnimated(row);
|
|
1013
|
+
}
|
|
1014
|
+
/** task-22:单行动画展开(**不消耗 animBudget**)—— 供"从上往下每 3 行"的错峰路径调用。
|
|
1015
|
+
* 与 applyFold 的展开分支同一语义:摘 skip → 去 folded → 量高 → 起始 0 → rAF 到高度 → 340ms 清。 */
|
|
1016
|
+
function unfoldRowAnimated(row) {
|
|
1017
|
+
if (!row.hasAttribute("data-dshsf-row")) row.setAttribute("data-dshsf-row", "");
|
|
900
1018
|
clearSkipHidden(row); // 先摘掉(只摘我们挂的那份),否则量到的高度是 0(内容根本没渲染)
|
|
901
|
-
// 非点击展开(Ctrl+F 命中 / 程序化展开 / 关掉「折叠历史轮次」)也要把窗接回每帧清单:
|
|
902
|
-
// 否则它停在折叠前的内联 max-height,不回底、没阴影 —— 就是「半空的玻璃面板」。
|
|
903
|
-
if (!disposed) for (const el of row.querySelectorAll("[data-dshsf-window]")) wireWindow(el, el.dataset.dshsfFollow !== "");
|
|
904
1019
|
row.removeAttribute("data-dshsf-folded");
|
|
905
|
-
delete row.dataset.dshsfCards;
|
|
906
1020
|
row.style.maxHeight = "";
|
|
1021
|
+
row.style.overflow = "hidden";
|
|
907
1022
|
layoutReads += 1;
|
|
908
1023
|
const height = row.getBoundingClientRect().height;
|
|
909
1024
|
if (height > ANIM_MAX_PX) { row.style.overflow = ""; return; } // 太高的行直接展开,省掉 0.3s 的逐帧重排
|
|
@@ -917,14 +1032,14 @@ function applyFold(row, fold, animate) {
|
|
|
917
1032
|
* 逐个"量一个写一个"会让每次量都强制一次整树重排:一次折叠几十行就是几十次重排
|
|
918
1033
|
* (实测这台 i5-2300 上一次扫描 885ms,全在 syncTurns 里)。
|
|
919
1034
|
*/
|
|
920
|
-
function
|
|
1035
|
+
function clearFoldTimers() { for (const id of foldTimers) clearTimeout(id); foldTimers.length = 0; }
|
|
1036
|
+
/** 批量收起:先全部量一次高、写起始 max-height、一次 flush;然后**从最下面往上一组 3 行**错峰收
|
|
1037
|
+
* (用户指定节奏)。错峰靠定时器推进;新一轮决策(有行要折/要展开)由调用方 clearFoldTimers() 作废。
|
|
1038
|
+
* 注意:行不是共同外框(按 data-chat-turn 属性分组的兄弟节点),所以"整块"只能靠这种节奏表达。 */
|
|
1039
|
+
function foldBatch(rows, animate) {
|
|
921
1040
|
const targets = rows.filter((row) => !row.hasAttribute("data-dshsf-folded"));
|
|
922
1041
|
if (targets.length === 0) return;
|
|
923
|
-
|
|
924
|
-
// 收起来必须看得见动效,不能因为目标多就瞬时切。
|
|
925
|
-
const flat = !animate || (!force && targets.length > ANIM_BUDGET && !scanAnimateAll);
|
|
926
|
-
noteFold({ at: Math.round(performance.now()), rows: targets.length, animate: !!animate, flat, force: !!force, budget: ANIM_BUDGET, all: scanAnimateAll, hold: settleHoldTurn, gliding: glide !== null });
|
|
927
|
-
if (flat) { for (const row of targets) applyFold(row, true, false); return; }
|
|
1042
|
+
if (!animate || foldIsInstant(targets)) { for (const row of targets) applyFold(row, true, false); return; }
|
|
928
1043
|
layoutReads += 1;
|
|
929
1044
|
const heights = targets.map((row) => row.getBoundingClientRect().height); // ① 只读
|
|
930
1045
|
for (let i = 0; i < targets.length; i += 1) { // ② 写起始值
|
|
@@ -934,14 +1049,28 @@ function foldBatch(rows, animate, force) {
|
|
|
934
1049
|
}
|
|
935
1050
|
layoutReads += 1;
|
|
936
1051
|
void targets[0].offsetHeight; // ③ 一次 flush,让过渡有起点
|
|
937
|
-
for (
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1052
|
+
for (let end = targets.length; end > 0; end -= FOLD_CHUNK) { // ④ 从最下面一组,往上一组组收
|
|
1053
|
+
const chunk = targets.slice(Math.max(0, end - FOLD_CHUNK), end);
|
|
1054
|
+
const delay = Math.floor((targets.length - end) / FOLD_CHUNK) * FOLD_CHUNK_MS;
|
|
1055
|
+
const run = () => {
|
|
1056
|
+
for (const row of chunk) {
|
|
1057
|
+
row.setAttribute("data-dshsf-folded", "");
|
|
1058
|
+
row.style.maxHeight = "0px";
|
|
1059
|
+
skipSoon(row);
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
if (delay === 0) run();
|
|
1063
|
+
else foldTimers.push(setTimeout(run, delay));
|
|
941
1064
|
}
|
|
942
1065
|
}
|
|
943
|
-
|
|
1066
|
+
const cardCountCache = new WeakMap(); // task-19 A9:row → { kids, thinks, tools };子节点数变化或 observer 定向失效即重算
|
|
1067
|
+
/** 数一段子树里的匹配元素:嵌在同类里的(工具卡套工具卡)只算最外层那一个。
|
|
1068
|
+
* task-19 A9:同一行的 THINK/TOOL_CARD 计数跨扫描复用(只在该行子树结构变化时重算)。 */
|
|
944
1069
|
function countCards(row, selector) {
|
|
1070
|
+
const key = selector === THINK ? "thinks" : "tools";
|
|
1071
|
+
let e = cardCountCache.get(row);
|
|
1072
|
+
if (e === undefined || e.kids !== row.children.length) { e = { kids: row.children.length }; cardCountCache.set(row, e); }
|
|
1073
|
+
if (e[key] !== undefined) return e[key];
|
|
945
1074
|
cost.counts.cards = (cost.counts.cards || 0) + 1;
|
|
946
1075
|
const t = performance.now();
|
|
947
1076
|
let n = 0;
|
|
@@ -951,32 +1080,13 @@ function countCards(row, selector) {
|
|
|
951
1080
|
n += 1;
|
|
952
1081
|
}
|
|
953
1082
|
cost.phases.cards = +((cost.phases.cards || 0) + (performance.now() - t)).toFixed(2);
|
|
1083
|
+
e[key] = n;
|
|
954
1084
|
return n;
|
|
955
1085
|
}
|
|
956
|
-
/** 折叠条文案要的两个计数。冷行(不在最近两轮)折叠后子树不会再变 → 缓存进 dataset:
|
|
957
|
-
* 否则每次扫描都要对**每个折叠行**各走两遍子树查询(长会话流式期间每 300ms 一次)。
|
|
958
|
-
* 键里带轮号:React 跨会话可能复用同一个 DOM 节点,只比 dataset 会读到上一轮的计数。
|
|
959
|
-
* 行一被展开就必须删缓存(下面 applyFold/unfoldAll 各删一次),否则再次折叠会沿用旧数字。 */
|
|
960
|
-
function cardsOf(row, turn) {
|
|
961
|
-
const cached = row.dataset.dshsfCards;
|
|
962
|
-
if (cached !== undefined && !hotRows.has(row)) {
|
|
963
|
-
const cut = cached.indexOf("|");
|
|
964
|
-
if (cut > 0 && cached.slice(0, cut) === turn) {
|
|
965
|
-
const parts = cached.slice(cut + 1).split(",");
|
|
966
|
-
return [Number(parts[0]) || 0, Number(parts[1]) || 0];
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
const thinks = countCards(row, THINK);
|
|
970
|
-
const tools = countCards(row, TOOL_CARD);
|
|
971
|
-
if (!hotRows.has(row)) row.dataset.dshsfCards = turn + "|" + thinks + "," + tools;
|
|
972
|
-
return [thinks, tools];
|
|
973
|
-
}
|
|
974
1086
|
function unfoldAll(container) {
|
|
975
1087
|
for (const row of container.querySelectorAll("[data-dshsf-row]")) {
|
|
976
1088
|
clearSkipHidden(row);
|
|
977
|
-
if (!disposed) for (const el of row.querySelectorAll("[data-dshsf-window]")) wireWindow(el, el.dataset.dshsfFollow !== ""); // 同上:展开后接回每帧清单(卸载中不接线)
|
|
978
1089
|
row.removeAttribute("data-dshsf-folded");
|
|
979
|
-
delete row.dataset.dshsfCards;
|
|
980
1090
|
row.style.maxHeight = "";
|
|
981
1091
|
row.style.overflow = "";
|
|
982
1092
|
}
|
|
@@ -993,10 +1103,9 @@ function clearChips(container) {
|
|
|
993
1103
|
* 工具卡的正文同理,但一次要渲染几百个大块,代价太高,这里只补思考。
|
|
994
1104
|
*/
|
|
995
1105
|
let searchPrepRunning = false;
|
|
996
|
-
let searchPrepTimer = 0; // 分片挂载的定时器句柄:卸载/热重载要能停掉,否则剩余分片继续展开+藏正文
|
|
997
1106
|
function hideForSearch(body) {
|
|
998
|
-
if (!body || body.getAttribute("hidden") === SKIP) return;
|
|
999
|
-
|
|
1107
|
+
if (!body || body.getAttribute("hidden") === SKIP) return;
|
|
1108
|
+
body.setAttribute("hidden", SKIP);
|
|
1000
1109
|
body.dataset.dshsfSearch = "1";
|
|
1001
1110
|
liveWindows.delete(body); // 不渲染的窗不必每帧跟进(与折叠行同一套处理)
|
|
1002
1111
|
}
|
|
@@ -1005,21 +1114,18 @@ function clearSearchHidden(scope) {
|
|
|
1005
1114
|
if (!scope || !scope.querySelectorAll) return;
|
|
1006
1115
|
for (const body of scope.querySelectorAll("[data-dshsf-search]")) {
|
|
1007
1116
|
body.removeAttribute("data-dshsf-search");
|
|
1008
|
-
|
|
1009
|
-
|
|
1117
|
+
if (body.getAttribute("hidden") === SKIP) body.removeAttribute("hidden");
|
|
1010
1118
|
wireWindow(body); // 重新回到每帧清单(它会从 16px 平滑长到窗口上限)
|
|
1011
1119
|
}
|
|
1012
1120
|
}
|
|
1013
1121
|
function searchPrep() {
|
|
1014
|
-
if (
|
|
1122
|
+
if (searchPrepRunning) return;
|
|
1015
1123
|
const flow = chatScope();
|
|
1016
1124
|
const roots = Array.from(flow.querySelectorAll(THINK)).filter((root) => !thinkOpen(root));
|
|
1017
1125
|
if (roots.length === 0) return;
|
|
1018
1126
|
searchPrepRunning = true;
|
|
1019
1127
|
let cursor = 0;
|
|
1020
1128
|
const chunk = () => {
|
|
1021
|
-
searchPrepTimer = 0;
|
|
1022
|
-
if (disposed) { searchPrepRunning = false; return; } // 卸载/热重载:剩余分片不许再动 DOM
|
|
1023
1129
|
let done = 0;
|
|
1024
1130
|
while (cursor < roots.length && done < 12) { // 分片挂载:别让 Ctrl+F 卡住主线程
|
|
1025
1131
|
const root = roots[cursor++];
|
|
@@ -1030,7 +1136,7 @@ function searchPrep() {
|
|
|
1030
1136
|
hideForSearch(root.querySelector(THINK_BODY)); // 挂上就藏回去,界面外观不变(隐藏的前提是 until-found 所以照样能搜)
|
|
1031
1137
|
// 行整行藏着(折叠轮次)时不必再藏正文,但正文同样挂上了 —— 展开那一轮时它才现形
|
|
1032
1138
|
}
|
|
1033
|
-
if (cursor < roots.length)
|
|
1139
|
+
if (cursor < roots.length) setTimeout(chunk, 0);
|
|
1034
1140
|
else searchPrepRunning = false;
|
|
1035
1141
|
};
|
|
1036
1142
|
chunk();
|
|
@@ -1040,7 +1146,11 @@ function searchPrep() {
|
|
|
1040
1146
|
* 0.1.5:行是 `[data-chat-flow]` 的直接子节点;0.1.7 把每个「过程组」各自包了一层
|
|
1041
1147
|
* `[data-chat-flow]`(组内还有自己的滚动体),按 children 取只能看到一个组 —— 所以在
|
|
1042
1148
|
* 主滚动容器里按属性收,两个版本都对。 */
|
|
1149
|
+
let scanRows = null; // task-19 A9:一次扫描内共用的行列表(null = 扫描外,语义不变)
|
|
1150
|
+
let scanRunningMark = null; // task-19 A9:扫描开头取一次 RUNNING 标记
|
|
1151
|
+
let scanRunningKnown = false;
|
|
1043
1152
|
function chatRows() {
|
|
1153
|
+
if (scanRows !== null) return scanRows; // task-19 A9:扫描内复用同一次全树查询
|
|
1044
1154
|
const scope = mainScroller() || document;
|
|
1045
1155
|
const rows = Array.from(scope.querySelectorAll("[data-chat-flow-kind]"));
|
|
1046
1156
|
if (rows.length) lastRowSeen = rows[rows.length - 1]; // 供帧路径做缓存校验:省掉每帧一次全文档查询
|
|
@@ -1052,16 +1162,19 @@ function chatRows() {
|
|
|
1052
1162
|
* 0.1.7 的 tail 行:<div data-turn-tail="<轮次>" data-actions-reveal="always|hover">。 */
|
|
1053
1163
|
function turnClosed(turn) {
|
|
1054
1164
|
if (turn === null || turn === undefined || turn === "") return false;
|
|
1055
|
-
|
|
1056
|
-
// variant:"embedded" 走同一套骨架),而轮号两边都从 1 开始撞号 —— document 上查到的那条 tail
|
|
1057
|
-
// 未必属于本会话(会让"正在跑的这一轮"被误判成已收尾 → 运行中折一次)。
|
|
1058
|
-
const tail = chatScope().querySelector('[data-turn-tail="' + turn + '"]');
|
|
1165
|
+
const tail = document.querySelector('[data-turn-tail="' + turn + '"]');
|
|
1059
1166
|
return tail !== null && tail.hasAttribute("data-actions-reveal");
|
|
1060
1167
|
}
|
|
1061
1168
|
/** 本轮正式回答行 = 最后一个「有正文」的 assistant-step 行(没有正文行才退回最后一个 assistant-step)。
|
|
1062
1169
|
* 0.1.7 会把一个 step 拆成 reasoning / response 两个行,只看"最后一个"未必落在正文上。
|
|
1063
1170
|
* 按轮次 memo:一次扫描里 syncTurns 与 syncTextWindows 都要用,避免双倍整树遍历。 */
|
|
1064
1171
|
let answerRowCache = new Map();
|
|
1172
|
+
const assistantRowsCache = new WeakMap(); // task-20 A11:turnRows 数组(每扫描一份)→ assistant-step 行;syncTurns 与 syncTextWindows 共用,避免重复整轮 filter
|
|
1173
|
+
function assistantRowsOf(turnRows) {
|
|
1174
|
+
let v = assistantRowsCache.get(turnRows);
|
|
1175
|
+
if (v === undefined) { v = turnRows.filter((row) => rowKind(row) === "assistant-step"); assistantRowsCache.set(turnRows, v); }
|
|
1176
|
+
return v;
|
|
1177
|
+
}
|
|
1065
1178
|
function answerRowOf(turnRows) {
|
|
1066
1179
|
const key = turnRows.length ? turnRows[0].getAttribute("data-chat-turn") : null;
|
|
1067
1180
|
if (key !== null && answerRowCache.has(key)) return answerRowCache.get(key);
|
|
@@ -1081,7 +1194,7 @@ function collectTurns() {
|
|
|
1081
1194
|
}
|
|
1082
1195
|
// 运行中那一轮 = 带运行标记的那一行所属的轮次。比"最后一行"可靠:0.1.7 会往末尾补
|
|
1083
1196
|
// 独立的 tail / 收尾行,它们的 data-chat-turn 未必等于正在跑的那一轮。
|
|
1084
|
-
const mark = chatScope().querySelector(RUNNING);
|
|
1197
|
+
const mark = scanRunningKnown ? scanRunningMark : chatScope().querySelector(RUNNING); // task-19 A9:扫描内复用开头那次 RUNNING 查询
|
|
1085
1198
|
const markRow = mark && mark.closest ? mark.closest(ROW) : null;
|
|
1086
1199
|
const runningTurn = markRow ? markRow.getAttribute("data-chat-turn") : null;
|
|
1087
1200
|
return { rows, turns, lastTurn: rows.length ? rows[rows.length - 1].getAttribute("data-chat-turn") : null, runningTurn };
|
|
@@ -1093,7 +1206,6 @@ function syncTurns(flow, running, collected) {
|
|
|
1093
1206
|
// 批量折叠收集到"整个扫描"这一层:foldBatch 每次要"读一遍 + flush 一遍"布局,
|
|
1094
1207
|
// 逐轮调用 = 每轮两遍(18 轮就 36 遍):单次扫描的主要成本都在这里
|
|
1095
1208
|
const toFoldAll = [];
|
|
1096
|
-
const toFoldForce = []; // 刚跑完那一轮的过程行:强制播动画(视野外也要有动效)
|
|
1097
1209
|
const toUnfoldAll = [];
|
|
1098
1210
|
const thinkFoldsAll = [];
|
|
1099
1211
|
let tFilter = 0; let tRows = 0; let tChip = 0;
|
|
@@ -1102,7 +1214,7 @@ function syncTurns(flow, running, collected) {
|
|
|
1102
1214
|
const tF = performance.now();
|
|
1103
1215
|
const isRunningTurn = running && turn === (runningTurn === null || runningTurn === undefined ? lastTurn : runningTurn);
|
|
1104
1216
|
const manual = manualState.get(turn);
|
|
1105
|
-
const assistantRows = turnRows
|
|
1217
|
+
const assistantRows = assistantRowsOf(turnRows); // task-20 A11:同一轮的 assistant 行本扫描只 filter 一次
|
|
1106
1218
|
const answerRow = answerRowOf(turnRows);
|
|
1107
1219
|
// 锻打锚点用**整行**(flowItem 在一轮里是稳定的),不用会随 React 重渲染被换掉的段落:
|
|
1108
1220
|
// 段落一换,火星就得等下一次扫描才回得来(看着像闪);行稳定则挂上去的画布一直有效。
|
|
@@ -1110,15 +1222,14 @@ function syncTurns(flow, running, collected) {
|
|
|
1110
1222
|
// 可折叠候选:过程行(答案行 / 独立行除外;按设置保留穿插正文行)
|
|
1111
1223
|
const foldableSet = new Set();
|
|
1112
1224
|
const foldable = turnRows.filter((row) => {
|
|
1113
|
-
|
|
1225
|
+
const qKind = rowKind(row); // 提问开关覆盖两个节点:提问工具卡(tool-call + data-tool) 与 回复节点(kind question-reply / user-question-reply)
|
|
1226
|
+
if (row === answerRow || INDEPENDENT.has(qKind) || (state.keepUserQuestions === true && (qKind === "question-reply" || qKind === "user-question-reply" || row.querySelector('[data-tool="ask_user_question"],[data-tool="request_user_input"]')))) return false;
|
|
1114
1227
|
if (state.keepInterleavedText !== false && rowKind(row) === "assistant-step" && hasText(row)) return false;
|
|
1115
1228
|
foldableSet.add(row);
|
|
1116
1229
|
return true;
|
|
1117
1230
|
});
|
|
1118
|
-
|
|
1119
|
-
const
|
|
1120
|
-
const forceAnim = justFinishedTurn !== null && String(turn) === justFinishedTurn;
|
|
1121
|
-
const shouldFold = !heldForSettle && (manual === "fold"
|
|
1231
|
+
const settling = settleTurn !== null && String(turn) === String(settleTurn); // 正在协调的那一轮:折叠压到滑完
|
|
1232
|
+
const shouldFold = !settling && (manual === "fold"
|
|
1122
1233
|
|| (manual !== "open" && !isRunningTurn && (state.foldHistory !== false || running)));
|
|
1123
1234
|
tFilter += performance.now() - tF;
|
|
1124
1235
|
const tR = performance.now();
|
|
@@ -1131,18 +1242,15 @@ function syncTurns(flow, running, collected) {
|
|
|
1131
1242
|
if (fold === isFolded) {
|
|
1132
1243
|
if (fold && !isSkipped(row)) skipSoon(row);
|
|
1133
1244
|
if (!row.hasAttribute("data-dshsf-row")) row.setAttribute("data-dshsf-row", "");
|
|
1134
|
-
} else if (fold)
|
|
1245
|
+
} else if (fold) toFoldAll.push(row);
|
|
1135
1246
|
else toUnfoldAll.push(row);
|
|
1136
1247
|
if (fold) {
|
|
1137
1248
|
folded += 1;
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
tools += cards[1];
|
|
1249
|
+
thinks += countCards(row, THINK);
|
|
1250
|
+
tools += countCards(row, TOOL_CARD);
|
|
1141
1251
|
}
|
|
1142
|
-
//
|
|
1143
|
-
|
|
1144
|
-
// (实测 18 轮时每扫描 37 次 querySelectorAll(THINK) ≈ 2N+1,长会话下是 phases.turnRows 的主要成分。)
|
|
1145
|
-
if (!isRunningTurn && rowKind(row) === "assistant-step" && !(fold && isFolded && !hotRows.has(row))) {
|
|
1252
|
+
// 非运行中的轮次:思考回到一行摘要(我只收自己展开的那些)
|
|
1253
|
+
if (!isRunningTurn && rowKind(row) === "assistant-step") {
|
|
1146
1254
|
for (const root of row.querySelectorAll(THINK)) {
|
|
1147
1255
|
// 行没被折掉(答行、保留的穿插正文行)时,它里面的思考也得跟着收:
|
|
1148
1256
|
// 折叠状态下连"一行摘要"都不该露头;用户手动展开本轮(manual=open)时还原成可点摘要
|
|
@@ -1172,34 +1280,31 @@ function syncTurns(flow, running, collected) {
|
|
|
1172
1280
|
chip.dataset.dshsfChip = turn;
|
|
1173
1281
|
chip.addEventListener("click", () => {
|
|
1174
1282
|
const anyFolded = flow.querySelector('[data-chat-turn="' + turn + '"][data-dshsf-folded]') !== null;
|
|
1175
|
-
|
|
1283
|
+
setManual(turn, anyFolded ? "open" : "fold");
|
|
1176
1284
|
allowAnim = true;
|
|
1177
|
-
|
|
1178
|
-
releaseSettleHold(); // 用户的点击压过「刚跑完那轮先别折」的 hold:否则这一下会被吞掉一次扫描
|
|
1179
|
-
followOn = false; // 先撒手再展开:跟随会把刚展开的内容拖回底部,按钮就飞了
|
|
1285
|
+
hold(); // 我方定位:取回写权(官方贴底写不再拖我们),位置交给 anchorDuring
|
|
1180
1286
|
const before = chip.getBoundingClientRect().top;
|
|
1181
|
-
scan();
|
|
1287
|
+
try { scan(); } catch (err) { scanErrors += 1; lastScanError = String((err && err.message) || err).slice(0, 200); } // F4:调用点兜底
|
|
1182
1288
|
anchorDuring(chip, before, 500);
|
|
1183
1289
|
});
|
|
1184
1290
|
}
|
|
1185
1291
|
const anchor = turnRows.find((row) => !INDEPENDENT.has(rowKind(row))) || turnRows[0];
|
|
1186
1292
|
if (anchor && chip.nextElementSibling !== anchor && anchor.parentElement) anchor.parentElement.insertBefore(chip, anchor);
|
|
1187
1293
|
const summary = [];
|
|
1188
|
-
|
|
1189
|
-
if (
|
|
1190
|
-
if (tools > 0) summary.push(uiText(tools + " 次工具调用", tools + " tool call" + (tools > 1 ? "s" : "")));
|
|
1294
|
+
if (thinks > 0) summary.push(thinks + " 轮思考");
|
|
1295
|
+
if (tools > 0) summary.push(tools + " 次工具调用");
|
|
1191
1296
|
const label = folded > 0
|
|
1192
|
-
?
|
|
1193
|
-
|
|
1194
|
-
: uiText("收起本轮过程", "Collapse this turn");
|
|
1297
|
+
? "已折叠 " + (summary.length ? summary.join(" · ") : folded + " 行") + " · 点击展开"
|
|
1298
|
+
: "收起本轮过程";
|
|
1195
1299
|
if (chip.textContent !== label) chip.textContent = label; // 没变就别写:textContent 赋值是一次真实 DOM 变更
|
|
1196
1300
|
tChip += performance.now() - tC;
|
|
1197
1301
|
}
|
|
1198
1302
|
sparks.forge(forgeBody); // 只有运行中才有写头;跑完自动撒手(火星烧完即摘画布)
|
|
1199
1303
|
const tB = performance.now();
|
|
1200
|
-
|
|
1201
|
-
foldBatch(toFoldAll
|
|
1202
|
-
|
|
1304
|
+
if (toFoldAll.length > 0 || thinkFoldsAll.length > 0 || toUnfoldAll.length > 0) clearFoldTimers(); // 新一轮决策 ⇒ 作废未播完的批次
|
|
1305
|
+
foldBatch(toFoldAll, animate); // 行:读一遍 + 一次 flush,然后从下往上每 3 行错峰收
|
|
1306
|
+
foldBatch(thinkFoldsAll, animate); // 行内思考摘要:同一套节奏(单独一组)
|
|
1307
|
+
unfoldBatch(toUnfoldAll, animate); // 行:从上往下每 3 行错峰展(跨 ≥2 轮或超护栏才瞬时)
|
|
1203
1308
|
cost.phases.turnFilter = +tFilter.toFixed(2);
|
|
1204
1309
|
cost.phases.turnRows = +tRows.toFixed(2);
|
|
1205
1310
|
cost.phases.turnChip = +tChip.toFixed(2);
|
|
@@ -1226,25 +1331,25 @@ function anchorStop() {
|
|
|
1226
1331
|
anchorCtl = null;
|
|
1227
1332
|
}
|
|
1228
1333
|
function anchorSync() {
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
anchorCtl.announced
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1334
|
+
try {
|
|
1335
|
+
if (!anchorCtl) return false;
|
|
1336
|
+
const { el, scroller } = anchorCtl;
|
|
1337
|
+
if (performance.now() > anchorCtl.until || !el.isConnected) { anchorStop(); return false; }
|
|
1338
|
+
if (navActive) return false; // 我方一次性导航期间位置由滑行律独占:锚定补偿不插写
|
|
1339
|
+
const delta = el.getBoundingClientRect().top - anchorCtl.top;
|
|
1340
|
+
if (delta === 0) return false;
|
|
1341
|
+
writeTop(scroller, scroller.scrollTop + delta, undefined, "anchor"); // 锚定补偿是我方写入(ownWrite)⇒ 吸收器放行
|
|
1342
|
+
anchorCtl.top = el.getBoundingClientRect().top;
|
|
1343
|
+
if (!anchorCtl.announced) {
|
|
1344
|
+
anchorCtl.announced = true;
|
|
1345
|
+
try { scroller.dispatchEvent(new Event("scroll", { bubbles: true })); } catch { /* ignore */ }
|
|
1346
|
+
}
|
|
1347
|
+
return true;
|
|
1348
|
+
} catch (err) { ioErrors.anchor += 1; ioErrors.last = "anchor: " + String((err && err.message) || err); return false; }
|
|
1242
1349
|
}
|
|
1243
1350
|
function anchorDuring(node, before, ms) {
|
|
1244
1351
|
const scroller = mainScroller();
|
|
1245
1352
|
if (!scroller) return;
|
|
1246
|
-
glide = null; // 点开/收起 = "我要读这一处":一次性滑行必须让位(否则它每帧把视角拽向旧目标)
|
|
1247
|
-
releaseSettleHold();
|
|
1248
1353
|
anchorStop();
|
|
1249
1354
|
anchorCtl = { el: node, top: before, until: performance.now() + ms, scroller, prevAnchor: scroller.style.overflowAnchor, announced: false };
|
|
1250
1355
|
try { scroller.style.overflowAnchor = "none"; } catch { /* ignore */ }
|
|
@@ -1271,22 +1376,50 @@ function anchorDuring(node, before, ms) {
|
|
|
1271
1376
|
* 我们只在自己写过的位置上继续贴底,位置被人动过(你上滚)就立刻撒手。
|
|
1272
1377
|
*/
|
|
1273
1378
|
const ANIM_BUDGET = 3; // 一次扫描最多播几个"高度动画":加载/切会话/收尾这类批量场景直接切,播放反而看不清
|
|
1379
|
+
const FOLD_CHUNK = 3; // 节奏:一组 3 行(收起从最下往上一组;展开从最上往下一组,用户指定对称)
|
|
1380
|
+
const FOLD_CHUNK_MS = 70; // 每组之间的错峰间隔:让"滚着收/滚着展"看得出来
|
|
1381
|
+
const FOLD_STAGGER_BUDGET_MS = 2000; // **护栏**(只对单轮):ceil(rows/3)*FOLD_CHUNK_MS 超过它就瞬时 —— 防极端大单轮拖太久,不是主判据
|
|
1382
|
+
/** task-22:批量收/展的"瞬时"判据从行数改为**场景** —— 统计本批覆盖的不同轮次(data-chat-turn 去重):
|
|
1383
|
+
* 跨 ≥2 轮 ⇒ 加载/切会话/批量场景(错峰看不清)⇒ 瞬时;单轮 ⇒ 正常收起/展开 ⇒ 一律错峰(哪怕 40+ 行)。
|
|
1384
|
+
* 单轮只在超过时长护栏时瞬时。think 摘要批用 closest(ROW) 归属到所属轮次,同一规则。 */
|
|
1385
|
+
function foldIsInstant(targets) {
|
|
1386
|
+
const turns = new Set();
|
|
1387
|
+
for (const el of targets) {
|
|
1388
|
+
const owner = el.closest && el.closest(ROW) ? el.closest(ROW) : el;
|
|
1389
|
+
const t = owner.getAttribute ? owner.getAttribute("data-chat-turn") : null;
|
|
1390
|
+
turns.add(t === null || t === undefined ? "0" : t);
|
|
1391
|
+
if (turns.size >= 2) return true;
|
|
1392
|
+
}
|
|
1393
|
+
return Math.ceil(targets.length / FOLD_CHUNK) * FOLD_CHUNK_MS > FOLD_STAGGER_BUDGET_MS;
|
|
1394
|
+
}
|
|
1395
|
+
/** task-22:批量展开 —— 与 foldBatch 对称,但**从上往下一组 3 行**(用户指定方向,与收起相反)。
|
|
1396
|
+
* 错峰路径每行走 unfoldRowAnimated(自写起始/终值),**不消耗 animBudget**:否则一波展开会被
|
|
1397
|
+
* "一次扫描最多 3 个动画"的配额截断(foldBatch 的错峰路径同样不消耗)。新一轮决策由调用方 clearFoldTimers() 作废。 */
|
|
1398
|
+
function unfoldBatch(rows, animate) {
|
|
1399
|
+
const targets = rows.filter((row) => row.hasAttribute("data-dshsf-folded"));
|
|
1400
|
+
if (targets.length === 0) return;
|
|
1401
|
+
if (!animate || foldIsInstant(targets)) { for (const row of targets) applyFold(row, false, animate); return; }
|
|
1402
|
+
for (let start = 0; start < targets.length; start += FOLD_CHUNK) { // 从上往下一组组展
|
|
1403
|
+
const chunk = targets.slice(start, start + FOLD_CHUNK);
|
|
1404
|
+
const delay = Math.floor(start / FOLD_CHUNK) * FOLD_CHUNK_MS;
|
|
1405
|
+
const run = () => { for (const row of chunk) unfoldRowAnimated(row); };
|
|
1406
|
+
if (delay === 0) run();
|
|
1407
|
+
else foldTimers.push(setTimeout(run, delay));
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
let foldTimers = []; // 未播完的错峰收起定时器(新一轮决策时作废)
|
|
1411
|
+
let scanMsSinceFrame = 0; // 自上一帧以来扫描累计耗时:dt 尖峰归因(是我们在扫,还是官方在渲染)
|
|
1412
|
+
let scanCountSinceFrame = 0;
|
|
1274
1413
|
const scanSamples = []; // 最近 N 次扫描的墙钟耗时(算分位数用;max 会被外部卡顿污染)
|
|
1275
1414
|
const SCAN_SAMPLES = 40;
|
|
1276
1415
|
let animBudget = ANIM_BUDGET;
|
|
1277
|
-
// 本次扫描是否属于「用户正看着的折叠」(点折叠条 / 跑完后的收尾折叠):这类即使目标多也要播动画;
|
|
1278
|
-
// 加载/切会话那种批量收尾仍走瞬时(见 ANIM_BUDGET 的注释)。
|
|
1279
|
-
let scanAnimateAll = false;
|
|
1280
1416
|
const autoThinks = new Set(); // 我方自动展开过的思考根(只有它们需要"被取代后折回")
|
|
1281
1417
|
const autoTools = new Set(); // 同上,工具卡
|
|
1282
1418
|
let lastAnnounceAt = 0; // 上次补发 scroll 事件的时刻(压官方吸底用)
|
|
1283
1419
|
let announcedHeight = -1; // 上次补发时的内容高度:长高那一帧必须补,等不了 400ms
|
|
1284
1420
|
let announcedTop = -1; // 上次补发时的位置:位移太小时补发会踩到官方"读者没动过"那条分支
|
|
1285
|
-
let stealCount = 0; // 诊断:官方吸底抢跑、把我们写下的位置一把抹平的次数(正常应为 0)
|
|
1286
|
-
let undoCount = 0; // 诊断:抢跑在帧循环里被回补的次数(提交阶段直接贴底那条路)
|
|
1287
|
-
let guardCount = 0; // 诊断:抢跑在自家 ResizeObserver 里被回补的次数(官方吸底那条路)
|
|
1288
1421
|
let moveCount = 0; // 诊断:帧循环真正推进过多少帧(流式期间应持续增长)
|
|
1289
|
-
const followFlips = []; //
|
|
1422
|
+
const followFlips = []; // 诊断:写权最近几次翻转(按钮闪 = 跟随在抖 → 看这里)
|
|
1290
1423
|
let lastFollowSeen = true;
|
|
1291
1424
|
/** 记一次跟随翻转。why=谁翻的、top/wrote=翻转瞬间的位置与我方最后写入值(不相等 = 有别人动过)。 */
|
|
1292
1425
|
function noteFlip(on, gap, info) {
|
|
@@ -1294,178 +1427,394 @@ function noteFlip(on, gap, info) {
|
|
|
1294
1427
|
followFlips.push({ t: Math.round(performance.now()), on, gap: Math.round(gap), ...info });
|
|
1295
1428
|
if (followFlips.length > 10) followFlips.shift();
|
|
1296
1429
|
}
|
|
1297
|
-
let stealMax = 0; // 诊断:单次抢跑最多把我们往前推了多少像素
|
|
1298
|
-
const stealTail = []; // 诊断:最近几次抢跑的像素数(判断是"小幅抖动"还是"整段抹平")
|
|
1299
1430
|
let layoutReads = 0; // 扫描里"读布局"的次数:DOM 脏的时候每读一次就强制一遍重排
|
|
1300
1431
|
let tickCount = 0; // 帧循环跑过多少次(诊断:看优化前后量级)
|
|
1301
|
-
let announceCount = 0; //
|
|
1302
|
-
const ANNOUNCE_MS = 400;
|
|
1303
|
-
|
|
1304
|
-
|
|
1432
|
+
let announceCount = 0; // 已废弃:合成 scroll 已删除(task-22),恒 0(保留字段给 probe/审计 hook 兼容)
|
|
1433
|
+
const ANNOUNCE_MS = 400; // 已废弃(合成 scroll 已删除):保留常量仅为审计 hook/probe 字段兼容
|
|
1434
|
+
/* ---- 写权 / 目标:两个独立状态量(本次重建的核心)--------------------------------------
|
|
1435
|
+
* writer = 谁写 scrollTop:us = 我方(官方贴底写被当"意图"吸收);reader = 读者接管(官方写原样放行,我方零程序化写)。
|
|
1436
|
+
* goal = 我方目标:tail = 每帧重算为 floor(跟内容增长)| element | position | none。
|
|
1437
|
+
* 我方导航(置顶条 / 回底 / 跑完回提问 / 展开收起 / Ctrl+F / 折叠条)只换目标、**绝不交出写权**;
|
|
1438
|
+
* 交出写权只有两个入口:读者真实输入、别的程序导航(非贴底写);再武装(取回写权、目标=尾)只有三个入口:
|
|
1439
|
+
* 读者落到末端 / 点回底 / 会话边界。空闲值全部显式:writer=us、goal=tail。 */
|
|
1440
|
+
const WRITER_US = "us";
|
|
1441
|
+
const WRITER_READER = "reader";
|
|
1442
|
+
let writer = WRITER_US;
|
|
1443
|
+
let goalKind = "tail"; // "tail" | "element" | "position" | "none"
|
|
1444
|
+
let goalEl = null; // element 目标(节点)
|
|
1445
|
+
let goalPos = 0; // position 目标(px)
|
|
1446
|
+
const mainVel = newVel(); // 主容器的速度状态(vFeed/vChase/prev 各一份,唯一状态量)
|
|
1447
|
+
let navActive = false; // 一次性导航进行中:不设追赶速度上限,SETTLE_MAX_MS 兜底
|
|
1448
|
+
let navUntil = 0;
|
|
1449
|
+
let navT0 = 0;
|
|
1450
|
+
let navWatchdog = 0; // task-12:一次性导航的有界终止看门狗(帧循环已停时也能收口;不新增 rAF 空转)
|
|
1451
|
+
let navProgressAt = 0; // 最近一次"导航确实推进"的时刻(performance.now)
|
|
1452
|
+
let navArmedAt = 0; // 诊断:最近一次导航武装看门狗的时刻 / 预算(区分"卡住终止"与"settle 超时")
|
|
1453
|
+
let navBudgetMs = 0;
|
|
1454
|
+
let navAbortCount = 0; // 诊断:看门狗有界终止次数
|
|
1455
|
+
let lastNavAbort = null; // 诊断:{why, armedAt, at, budgetMs, top}
|
|
1456
|
+
let navRecheck = null; // S1-4:element 到位后的有界观察窗口 { el, until, left, lastAt }
|
|
1457
|
+
const NAV_STALL_MS = 1200; // 看门狗判定"期间零进展"的宽限(有推进就续期;取 1.2s 以免慢帧/GC 误杀正常滑行)
|
|
1458
|
+
// S1-4/task-17:element 到位后的有界观察窗口。取 2s 的理由:覆盖到位后常见的"迟到落版"(图片/代码块、上一轮摘要行、
|
|
1459
|
+
// 官方补行、折叠收尾);窗口内以帧驱动观察(≈120 帧 @60Hz),到点立即退出 ⇒ 之后零 rAF/timer。
|
|
1460
|
+
// 上限 4 次 + 最小间隔 100ms 同时挡住"每帧都在变"的持续漂移;纠正复用既有 navigate({kind:"element"}),不新增 mover。
|
|
1461
|
+
const NAV_WATCH_MS = 2000;
|
|
1462
|
+
const NAV_WATCH_MAX = 4;
|
|
1463
|
+
const NAV_WATCH_MIN_GAP_MS = 100;
|
|
1464
|
+
// task-19:观察降频 —— 窗口期不是每帧都要读一次目标 rect;每 2 帧复算一次(检测延迟 ≤1 帧,min-gap 仍 100ms),
|
|
1465
|
+
// 窗口期 rect 读减半、窗口保活/到期零残留语义不变。
|
|
1466
|
+
const NAV_OBSERVE_EVERY = 2;
|
|
1467
|
+
let followWrote = null; // 我方最后一次写下的位置:吸收时恢复它,也用来分辨 scroll 是不是我造成的
|
|
1305
1468
|
let followScroller = null;
|
|
1306
|
-
let
|
|
1307
|
-
let
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
*/
|
|
1312
|
-
const
|
|
1313
|
-
|
|
1314
|
-
|
|
1469
|
+
let releaseReason = null;
|
|
1470
|
+
let lastReleaseAt = 0; // 诊断:最近一次交出写权的时刻
|
|
1471
|
+
let followHeight = -1; // 上一次 tick 时的内容高度(补发判据用)
|
|
1472
|
+
let lastStep = null; // 探针:上一帧 {writer, goal, gap, base, rate, cap, dt, moving, nav}
|
|
1473
|
+
let lastNav = null; // 探针:最近一次一次性导航 {kind, why, to, ms}
|
|
1474
|
+
/* ---- 帧轨迹(task-18 ⑥):最近 240 帧;只在这一帧真的动过/写过/补发过时记一条,空闲零开销 ---- */
|
|
1475
|
+
const TRACE_MAX = 240;
|
|
1476
|
+
const trace = [];
|
|
1477
|
+
const traceFrame = (rec) => { if (trace.length >= TRACE_MAX) trace.shift(); trace.push(rec); };
|
|
1478
|
+
let passedOfficial = 0; // 诊断:reader 态被原样放行的官方贴底写
|
|
1479
|
+
/* ---- navIntent:真实输入给"下一笔外来写"的单笔分类位(task-13)-----------------------
|
|
1480
|
+
* 置位:捕获阶段收到真实输入(click / keydown,我方合成分发、输入控件里的不算)。
|
|
1481
|
+
* 消费:被**下一笔外来写**消费即清除(贴底写也算一笔)。
|
|
1482
|
+
* 过期:未被消费则在**其后两帧内**过期(用帧计数 tickCount + 2,避免久置;设位时叫醒帧循环让它真的走两帧)。
|
|
1483
|
+
* 只用于"给单笔外来写分类"(导航 vs 保位),**不参与"是否跟随"的门控**。 */
|
|
1484
|
+
let navIntentUntil = -1;
|
|
1485
|
+
/* ---- 几何信号(task-14):这笔写是否只是在补偿"同期布局变化"--------------------------------
|
|
1486
|
+
* 一次滚动赋值不可能同步改变 scrollHeight,所以在写点"落地前"现读 sh0 必然是 0 变化(信号退化)。
|
|
1487
|
+
* 因此基线取**上一帧观察到的 (st, sh)**(本任务开始前、布局变化之前的世界):
|
|
1488
|
+
* st0/sh0 = 上一帧基线(没有基线时退化为写点前的值)
|
|
1489
|
+
* st1/sh1 = 落地后
|
|
1490
|
+
* compensated = |(st1-st0) - (sh1-sh0)| <= 2 // 位置变化完全由内容高度变化解释 ⇒ 视口内容没动
|
|
1491
|
+
* 与输入位取或:nav = hasNavIntent() || !compensated。 */
|
|
1492
|
+
let baseSt = -1;
|
|
1493
|
+
let baseSh = -1;
|
|
1494
|
+
const hasNavIntent = () => tickCount <= navIntentUntil;
|
|
1495
|
+
const clearNavIntent = () => { navIntentUntil = -1; };
|
|
1496
|
+
function noteNavIntent() { navIntentUntil = tickCount + 2; ensureTickLoop(); }
|
|
1497
|
+
function onNavIntentInput(event) {
|
|
1498
|
+
try {
|
|
1499
|
+
if (synthetic || !(event && (event.type === "click" || event.type === "keydown"))) return;
|
|
1315
1500
|
const target = event.target;
|
|
1316
1501
|
if (target instanceof Element && target.closest("input, textarea, [contenteditable]")) return;
|
|
1317
|
-
|
|
1502
|
+
// 真实点击/按键 ⇒ 搜索闩解除:放在"输入分类"之后,且排除 Ctrl+F 自身(与监听器注册顺序无关)
|
|
1503
|
+
if (!((event.ctrlKey || event.metaKey) && (event.key === "f" || event.key === "F"))) searchHold = false;
|
|
1504
|
+
noteNavIntent();
|
|
1505
|
+
} catch (err) { ioErrors.input += 1; ioErrors.last = "navIntent: " + String((err && err.message) || err); }
|
|
1506
|
+
}
|
|
1507
|
+
const ioErrors = { input: 0, absorb: 0, anchor: 0, last: null }; // 输入/写层/锚定里被吞掉的异常(不许空 catch,要能被 probe 看见)
|
|
1508
|
+
const prefersReduced = () => { try { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch { return false; } };
|
|
1509
|
+
/** 唯一交出写权出口(读者真实输入 / 会话边界各一个调用点)。 */
|
|
1510
|
+
function release(reason) {
|
|
1511
|
+
navRecheck = null; // S1-4:读者接管 ⇒ 未决复算作废
|
|
1512
|
+
clearSettle("reader"); // 读者接管:协调作废(不许把折叠压在别人的滚动上)
|
|
1513
|
+
clearNavIntent(); // 交权:这笔输入由读者接手,不再算导航意图
|
|
1514
|
+
tailWant = -1; // 跟随目标低通作废(下次从当前 floor 重新对齐)
|
|
1515
|
+
if (releaseBy[reason] === undefined) releaseBy[reason] = 0;
|
|
1516
|
+
releaseBy[reason] += 1; // 计数按"调用"记(已经是读者时也要留痕)
|
|
1517
|
+
if (writer === WRITER_READER) { releaseReason = reason; return; } // 已经是读者的位置,不重复翻
|
|
1518
|
+
writer = WRITER_READER;
|
|
1519
|
+
goalKind = "none"; goalEl = null; navActive = false;
|
|
1520
|
+
releaseReason = reason; lastReleaseAt = performance.now();
|
|
1521
|
+
const sc = mainScroller();
|
|
1522
|
+
noteFlip(false, sc === null ? 0 : Math.round(sc.scrollHeight - sc.clientHeight - sc.scrollTop), { why: reason });
|
|
1523
|
+
}
|
|
1524
|
+
/** 唯一取回写权出口(读者落到末端 / 点回底 / 会话边界):目标回到尾。 */
|
|
1525
|
+
function rearm() {
|
|
1526
|
+
if (writer === WRITER_US && goalKind === "tail") return;
|
|
1527
|
+
clearNavIntent();
|
|
1528
|
+
tailWant = -1;
|
|
1529
|
+
const sc = mainScroller();
|
|
1530
|
+
writer = WRITER_US; goalKind = "tail"; goalEl = null; navActive = false; releaseReason = null;
|
|
1531
|
+
navRecheck = null; // S1-4:接回跟尾 ⇒ 未决复算作废
|
|
1532
|
+
followWrote = sc === null ? null : sc.scrollTop; // 基准 = 当前位置:陈旧坐标不许参与吸收时的恢复(B2)
|
|
1533
|
+
resetVel(mainVel); // 目标换人:前馈/追赶速度复位
|
|
1534
|
+
rearmCount += 1;
|
|
1535
|
+
noteFlip(true, sc === null ? 0 : Math.round(sc.scrollHeight - sc.clientHeight - sc.scrollTop), { why: "rearm" });
|
|
1536
|
+
}
|
|
1537
|
+
/** 我方导航:取回写权 + 只换目标,绝不交出写权。spec = { kind: "tail"|"element"|"position", el?, px?, instant? }。
|
|
1538
|
+
* instant=true 只给"用户发起"的一次性导航(置顶条/回底):命中 prefers-reduced-motion 时真瞬跳(写点仍是 writeTop)。 */
|
|
1539
|
+
function navigate(spec) {
|
|
1540
|
+
navRecheck = null; // S1-4:显式新导航作废未决复算(不抢用户/新导航)
|
|
1541
|
+
writer = WRITER_US;
|
|
1542
|
+
{ const sc = mainScroller(); followWrote = sc === null ? null : sc.scrollTop; } // 基准 = 当前位置(与 rearm 同一条不变式:陈旧坐标不许参与吸收恢复)
|
|
1543
|
+
clearNavIntent(); // 我方已自己完成这次导航:输入意图用掉了
|
|
1544
|
+
if (settleTurn !== null) clearSettle("nav"); // task-32:滑行途中被新导航取代 ⇒ 当帧作废协调(该轮立刻折)
|
|
1545
|
+
tailWant = -1; // 一次性导航:目标静止,不走低通
|
|
1546
|
+
resetVel(mainVel); // 目标换人:前馈/追赶速度复位
|
|
1547
|
+
goalKind = spec && spec.kind ? spec.kind : "tail";
|
|
1548
|
+
goalEl = spec && spec.el ? spec.el : null;
|
|
1549
|
+
goalPos = spec && Number.isFinite(spec.px) ? spec.px : 0;
|
|
1550
|
+
navActive = true;
|
|
1551
|
+
navT0 = performance.now();
|
|
1552
|
+
navUntil = 0; // 0 = 还没武装:第一帧按真实剩余距离算预算(见 mainFollowTick)
|
|
1553
|
+
releaseReason = null;
|
|
1554
|
+
if (goalKind === "tail") { try { document.documentElement.removeAttribute("data-dshsf-jumpgate"); } catch { /* ignore */ } }
|
|
1555
|
+
if (spec && spec.instant && prefersReduced()) {
|
|
1556
|
+
const sc = mainScroller();
|
|
1557
|
+
const floor = sc === null ? 0 : Math.max(0, sc.scrollHeight - sc.clientHeight);
|
|
1558
|
+
let want = null;
|
|
1559
|
+
if (goalKind === "tail") want = floor;
|
|
1560
|
+
else if (goalKind === "position") want = Math.max(0, Math.min(floor, goalPos));
|
|
1561
|
+
else if (goalKind === "element" && goalEl !== null && goalEl.isConnected) want = Math.max(0, Math.min(floor, viewTop(sc, goalEl) - SETTLE_PAD));
|
|
1562
|
+
if (sc !== null && want !== null) { // 无障碍:不做多帧运动,直接到位
|
|
1563
|
+
writeTop(sc, want, undefined, "nav-instant");
|
|
1564
|
+
navActive = false;
|
|
1565
|
+
lastNav = { kind: goalKind, why: "reduced-motion", to: Math.round(sc.scrollTop), ms: 0 };
|
|
1566
|
+
if (goalKind === "element" || goalKind === "position") { goalEl = null; goalKind = "none"; }
|
|
1567
|
+
else tailWant = -1; // 一次性回底到位 ⇒ 跟随目标从当前 floor 重新对齐
|
|
1568
|
+
}
|
|
1318
1569
|
}
|
|
1319
|
-
|
|
1570
|
+
if (navActive) { // task-12:给一次性导航装有界看门狗(零步停机时由它收口)
|
|
1571
|
+
navProgressAt = performance.now();
|
|
1572
|
+
const sc = mainScroller();
|
|
1573
|
+
const floor = sc === null ? 0 : Math.max(0, sc.scrollHeight - sc.clientHeight);
|
|
1574
|
+
let dist = 0;
|
|
1575
|
+
if (sc !== null) {
|
|
1576
|
+
if (goalKind === "tail") dist = Math.abs(floor - sc.scrollTop);
|
|
1577
|
+
else if (goalKind === "position") dist = Math.abs(Math.max(0, Math.min(floor, goalPos)) - sc.scrollTop);
|
|
1578
|
+
else if (goalKind === "element" && goalEl !== null && goalEl.isConnected) dist = Math.abs(Math.max(0, Math.min(floor, viewTop(sc, goalEl) - SETTLE_PAD)) - sc.scrollTop);
|
|
1579
|
+
}
|
|
1580
|
+
navArmedAt = performance.now(); navBudgetMs = navBudget(dist) + NAV_STALL_MS;
|
|
1581
|
+
armNavWatchdog(navBudgetMs);
|
|
1582
|
+
} else { clearTimeout(navWatchdog); navWatchdog = 0; }
|
|
1583
|
+
ensureTickLoop();
|
|
1584
|
+
}
|
|
1585
|
+
/** 我方定位、但位置由锚定补偿自己写(展开/收起 / Ctrl+F):取回写权、不设我方目标。 */
|
|
1586
|
+
function hold() {
|
|
1587
|
+
writer = WRITER_US; goalKind = "none"; goalEl = null; navActive = false; releaseReason = null;
|
|
1588
|
+
navRecheck = null; // S1-4:锚定期间未决复算作废
|
|
1589
|
+
{ const sc = mainScroller(); followWrote = sc === null ? null : sc.scrollTop; } // 基准 = 当前位置(与 rearm 同一条不变式)
|
|
1590
|
+
clearNavIntent();
|
|
1591
|
+
tailWant = -1;
|
|
1592
|
+
resetVel(mainVel); // 目标换人:速度复位
|
|
1593
|
+
ensureTickLoop();
|
|
1594
|
+
}
|
|
1595
|
+
/** reader 释放的唯一入口:真实输入那一刻(滚轮/触摸/翻页键/滚动条)。没有意图窗、没有超时。
|
|
1596
|
+
* 翻页键不要求事件目标在容器内(焦点常在 body)——只按"焦点不在输入控件 + 键命中"判(M2)。 */
|
|
1597
|
+
function noteScrollIntent(event) {
|
|
1598
|
+
try {
|
|
1599
|
+
if (event && event.type === "keydown") {
|
|
1600
|
+
const target = event.target;
|
|
1601
|
+
if (target instanceof Element && target.closest("input, textarea, [contenteditable]")) return;
|
|
1602
|
+
if (!/^(ArrowUp|ArrowDown|PageUp|PageDown|Home|End| )$/.test(event.key)) return;
|
|
1603
|
+
} else if (event && event.target instanceof Node) {
|
|
1604
|
+
const target = event.target;
|
|
1605
|
+
// delta 全 0 的 wheel 不是滚动(真实 WheelEvent 恒带数值 deltaX/deltaY);字段缺失的合成事件无法判定,沿用旧行为
|
|
1606
|
+
const dy = Number(event.deltaY), dx = Number(event.deltaX);
|
|
1607
|
+
const hasDelta = Number.isFinite(dy) && Number.isFinite(dx);
|
|
1608
|
+
if (event.type === "wheel" && hasDelta && !(dy || dx)) return;
|
|
1609
|
+
// A1(task-12 边界感知):主容器**内部**的嵌套滚动体(小窗/工具正文/上下文注入正文)在滚轮方向上**还能滚** ⇒ 不是主容器意图;
|
|
1610
|
+
// 已到该方向边界 ⇒ 不早退,落到下面的输入层 reader 释放 —— 浏览器会把链式滚动交给父容器,用户得以借此上/下滚主对话。
|
|
1611
|
+
// touch / 无数值 delta / 纯横向 wheel:方向不可知,维持 A1 一律早退。
|
|
1612
|
+
const nested = target instanceof Element ? target.closest(NESTED_SCROLL) : null;
|
|
1613
|
+
if (nested !== null) {
|
|
1614
|
+
const box = nestedScrollBox(target, nested); // task-14:命中的是不可滚滚的包装层 ⇒ 不是主容器意图(维持早退)
|
|
1615
|
+
if (box === null) return;
|
|
1616
|
+
const canScroll = event.type === "wheel" && hasDelta && dy !== 0
|
|
1617
|
+
&& (dy < 0 ? box.scrollTop > 0.5 : box.scrollHeight - box.clientHeight - box.scrollTop > 0.5);
|
|
1618
|
+
if (canScroll || event.type !== "wheel" || !hasDelta || dy === 0) return;
|
|
1619
|
+
}
|
|
1620
|
+
const sc = mainScroller();
|
|
1621
|
+
if (sc !== null && target !== sc && !sc.contains(target)) return; // 主容器之外的(侧栏等)不是它
|
|
1622
|
+
}
|
|
1623
|
+
searchHold = false; // 通过了输入分类 ⇒ 真实页面输入(滚轮/触摸/翻页键):搜索闩解除;非翻页键/输入控件/别处滚动都不解
|
|
1624
|
+
if (mainScroller() === null) return;
|
|
1625
|
+
release("reader");
|
|
1626
|
+
ensureTickLoop();
|
|
1627
|
+
} catch (err) { ioErrors.input += 1; ioErrors.last = "input: " + String((err && err.message) || err); }
|
|
1320
1628
|
}
|
|
1321
1629
|
/** 点在滚动条上时事件目标就是滚动容器自身(点内容不会)。 */
|
|
1322
1630
|
function onScrollerPointerDown(event) { if (event.target === event.currentTarget) noteScrollIntent(event); }
|
|
1323
|
-
/**
|
|
1324
|
-
*
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
for (const el of document.querySelectorAll("[data-dshsf-answer-sep]")) el.removeAttribute("data-dshsf-answer-sep");
|
|
1333
|
-
for (const el of document.querySelectorAll("[data-dshsf-search]")) { el.removeAttribute("data-dshsf-search"); clearSkipHidden(el); } // 归属感知:别把官方挂的那份也摘了、也别留僵尸标记
|
|
1334
|
-
for (const el of document.querySelectorAll("[data-dshsf-jump]")) { try { el.remove(); } catch { /* ignore */ } }
|
|
1335
|
-
liveWindows.clear(); // 不再每帧跟这些小窗(autoThinks/autoTools 留着:切回折叠时还要靠它们收旧窗)
|
|
1336
|
-
pinRows = []; pinRow = null; pinPick = -1;
|
|
1337
|
-
hidePinBar();
|
|
1338
|
-
releaseTakeover();
|
|
1339
|
-
sparks.focus(null);
|
|
1340
|
-
sparks.forge(null);
|
|
1341
|
-
}
|
|
1342
|
-
/** 归还滚动接管:官方档位下 scrollTop 归官方(toBottom / followRef),回底按钮也由官方自己管。 */
|
|
1343
|
-
function releaseTakeover() {
|
|
1344
|
-
snapOwn = false; // 位置所有权一起还回去(否则切回官方档后官方贴底仍被挡)
|
|
1345
|
-
if (snapTaken !== null) { try { delete snapTaken.el.scrollTop; } catch { /* ignore */ } snapTaken = null; }
|
|
1346
|
-
if (document.documentElement.hasAttribute("data-dshsf-jumpgate")) document.documentElement.removeAttribute("data-dshsf-jumpgate");
|
|
1347
|
-
glide = null;
|
|
1348
|
-
settleHoldTurn = null; settleHoldUntil = 0; // 上一轮的收尾 hold 一起作废(同「换会话」:轮号各会话都从 1 起,会撞)
|
|
1631
|
+
/** A1/task-14:从 el 到嵌套区域根 root(含)之间,最近的"真能滚动"的元素(computed overflowY ∈ auto|scroll);
|
|
1632
|
+
* 没有这样的元素 ⇒ 命中的只是不可滚的包装层,不按"已到边界"处理。 */
|
|
1633
|
+
function nestedScrollBox(el, root) {
|
|
1634
|
+
for (let node = el; node !== null; node = node.parentElement) {
|
|
1635
|
+
const oy = getComputedStyle(node).overflowY;
|
|
1636
|
+
if (oy === "auto" || oy === "scroll") return node;
|
|
1637
|
+
if (node === root) break;
|
|
1638
|
+
}
|
|
1639
|
+
return null;
|
|
1349
1640
|
}
|
|
1641
|
+
/** 追赶速度上限(px/s):设置非法就回落默认值(单一真源)。 */
|
|
1642
|
+
function speedValue() {
|
|
1643
|
+
const v = Number(state.followMaxSpeed);
|
|
1644
|
+
if (!Number.isFinite(v) || v <= 0) { capRejected = { raw: String(state.followMaxSpeed), at: Math.round(performance.now()) }; return DEFAULTS.followMaxSpeed; }
|
|
1645
|
+
return v;
|
|
1646
|
+
}
|
|
1647
|
+
/** 加速度上限(px/s²):设置非法就回落默认值(单一真源)。 */
|
|
1648
|
+
function accelValue() {
|
|
1649
|
+
const v = Number(state.followMaxAccel);
|
|
1650
|
+
if (!Number.isFinite(v) || v <= 0) { accelRejected = { raw: String(state.followMaxAccel), at: Math.round(performance.now()) }; return DEFAULTS.followMaxAccel; }
|
|
1651
|
+
return v;
|
|
1652
|
+
}
|
|
1653
|
+
/** task-22:合成 scroll 已停用 —— 原实现用 dispatchEvent 同步重入官方 onScroll(真机 11.9~17.5ms/次,
|
|
1654
|
+
* 160Hz 一帧 6.25ms ⇒ 直接掉 2 帧)。官方贴底写现在由 absorbTop / afterForeignWrite 显式吸收或放行,
|
|
1655
|
+
* 不再依赖官方 atBottom 判定 ⇒ 这笔派发没有理由。保留**空实现**仅为审计 hook / 诊断字段兼容。 */
|
|
1656
|
+
function announce() { /* 无操作(task-22 停用) */ }
|
|
1657
|
+
/* task-22:合成 scroll(announce)已删除 ----------------------------------------------------------
|
|
1658
|
+
* 它当年是为了"压官方吸底"(让官方认为我们在底部)。现在官方贴底写由 absorbTop / afterForeignWrite
|
|
1659
|
+
* 显式吸收或放行,写权交接也不依赖官方的 atBottom 判定 ⇒ 这笔同步 dispatchEvent 只会让我们在自己的
|
|
1660
|
+
* tick 里重入官方 onScroll 处理器(真机实测 11.9~17.5ms/次,160Hz 一帧 6.25ms ⇒ 直接掉 2 帧)。
|
|
1661
|
+
* ANNOUNCE_MS / announceCount / announcedTop 等字段保留为诊断(恒 0),不参与任何行为。 */
|
|
1662
|
+
/** 每帧一次的主窗口推进:唯一的写点在这条路上(吸收器只吸收/放行官方写,不推进)。目标每帧重算。 */
|
|
1350
1663
|
function mainFollowTick(dt) {
|
|
1664
|
+
const tGate = performance.now();
|
|
1665
|
+
if (!(dt > 0)) dt = 16.667; // 帧循环首帧 dt=0(prevTs 从 0 起算):律在 dt=0 时不推进 ⇒ 一次性导航会被当场判成"到位"。按一个 60Hz 帧算。
|
|
1666
|
+
if (gateOfficialMode()) { phaseAcc("mainGate", tGate); return false; } // 选官方档位:零写、置顶/回底收起、速度复位 —— 滚动完全交给官方
|
|
1351
1667
|
const scroller = mainScroller();
|
|
1352
|
-
if (!scroller) { hidePinBar(); return false; }
|
|
1353
|
-
//
|
|
1354
|
-
if (state.mode !== "fold") { releaseTakeover(); hidePinBar(); return false; }
|
|
1668
|
+
if (!scroller) { hidePinBar(); phaseAcc("mainGate", tGate); return false; }
|
|
1669
|
+
phaseAcc("mainGate", tGate); // 不在会话视图(设置页/别的面板):body 上的固定层不能留着
|
|
1355
1670
|
if (scroller !== followScroller) {
|
|
1356
1671
|
if (followScroller) {
|
|
1357
1672
|
followScroller.removeEventListener("scroll", onMainScroll);
|
|
1358
1673
|
followScroller.removeEventListener("pointerdown", onScrollerPointerDown);
|
|
1359
1674
|
}
|
|
1675
|
+
if (followScroller !== null) gutterSet = false; // G1:换容器(切会话)后重新加 stable(gutterSet 是"本容器"的一次性闩)
|
|
1360
1676
|
followScroller = scroller;
|
|
1361
|
-
|
|
1677
|
+
bootAt = performance.now();
|
|
1678
|
+
takeOverSnap(scroller);
|
|
1362
1679
|
followScroller.addEventListener("scroll", onMainScroll, { passive: true });
|
|
1363
1680
|
followScroller.addEventListener("pointerdown", onScrollerPointerDown, { passive: true });
|
|
1681
|
+
followWrote = null;
|
|
1682
|
+
baseSt = -1; baseSh = -1; // 换容器:几何基线作废(下一帧重新同步)
|
|
1683
|
+
tailWant = -1; // 换容器:跟随目标低通作废
|
|
1684
|
+
resetVel(mainVel); // 换容器:速度复位
|
|
1685
|
+
// 换容器 = 会话边界。**无条件语义判定**(不依赖标志、与 syncSession 的先后顺序无关):
|
|
1686
|
+
// 新容器已经在末端 ⇒ rearm()(接回跟尾,本来就在底、不会滑);
|
|
1687
|
+
// 否则 ⇒ 交出写权、只钉不跟(别把新会话从顶部一路滑到底);
|
|
1688
|
+
// 等官方/读者真把它放到末端,再由 onMainScroll 与 tick 的「落末端」规则接回。
|
|
1689
|
+
if (scroller.scrollHeight > scroller.clientHeight && scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop <= 0.5) rearm();
|
|
1690
|
+
else { release("reset"); goalKind = "none"; goalEl = null; navActive = false; }
|
|
1364
1691
|
}
|
|
1365
|
-
takeOverSnap(scroller); // 幂等(同实例直接返回):从官方档切回来时要把 setter 重新装上
|
|
1366
1692
|
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
const
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
//
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
if (
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
if (
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
const top = scroller.scrollTop; // 回补可能改过位置 → 重新读一次,后面全部复用
|
|
1394
|
-
const gap = floor - top;
|
|
1395
|
-
// 恢复跟随要分清"这个位置是谁放上去的":只有真贴底(gap<=2),或位置就是我方上一次写入,
|
|
1396
|
-
// 才算"贴底恢复"。读者自己滚到离底 25px 以内时若直接 followOn=true,下一帧就把人拽回底部 ——
|
|
1397
|
-
// 那正是"滚动跟触底吸附打架"。不恢复也没关系:滚到真贴底会自动接上,点回底按钮也随时能接。
|
|
1398
|
-
const mine = followWrote !== null && Math.abs(top - followWrote) <= 2;
|
|
1399
|
-
// 滑行期间(回提问处 / 点置顶条)**不许**把跟随重新打开:滑行第一帧位置还在底部、mine 也成立,
|
|
1400
|
-
// 会把刚取消的 followOn 又顶回 true —— 等滑到提问处,跟随立刻把人拽回底部,看着就是「回提问处失效」。
|
|
1401
|
-
// 所以「先取消触底吸附」必须连这个再武装口一起关掉。
|
|
1402
|
-
if (!gliding && gap <= 25 && (gap <= 2 || mine)) followOn = true;
|
|
1403
|
-
if (followOn !== lastFollowSeen) noteFlip(followOn, gap, { why: followOn ? "tick/贴底恢复" : "tick/其他", wrote: followWrote === null ? null : Math.round(followWrote) });
|
|
1404
|
-
if (!gliding) updateJumpGate(gap, followOn);
|
|
1405
|
-
if (gliding) return true;
|
|
1406
|
-
if (!followOn || gap <= 0.5) return false;
|
|
1407
|
-
// 主会话也走同一套平滑:每帧吃掉一部分差距,而不是直接跳到 floor
|
|
1408
|
-
// 与小窗完全同一套:纯比例追赶(不再有"最大滞后"兜底步,避免一帧跳几十像素)
|
|
1409
|
-
const step = Math.min(gap, Math.max(minStep(dt), gap * smoothGrow() * (dt / 16)));
|
|
1410
|
-
const next = scroller.scrollTop + step;
|
|
1411
|
-
writeTop(scroller, next);
|
|
1412
|
-
followWrote = next; // 写后读回会逼一次布局:目标值本来就算好了(step ≤ gap,不会被钳)
|
|
1413
|
-
moveCount += 1;
|
|
1414
|
-
// 接管官方吸底:它只在"不在滚动采样期"时才写 scrollTop,而采样标记由 scroll 事件置位。
|
|
1415
|
-
// 同一帧里 rAF 回调早于 ResizeObserver 回调,所以这里补一个 scroll 事件,它那一帧就会认为自己在采样
|
|
1416
|
-
// → 不吸底;位置又始终在它 25px 判定内 → 它的 atBottom 保持为真,自带的"回到底部"按钮也不会冒出来。
|
|
1417
|
-
// 它的采样标记由 sample() 在"本批第一个 scroll 事件之后 500ms"清一次(后续事件不续期),所以补发必须
|
|
1418
|
-
// **赶上内容长高那一帧**——那是官方 ResizeObserver 唯一会触发的时机,也是唯一会吸底的时机。
|
|
1419
|
-
// 固定周期补发会在"被清掉之后、下次补发之前"留一段空档,那几帧的吸底会把我们的滞后一次抹平 = 跳一格。
|
|
1420
|
-
// 位移不足 0.6px 时不补:官方会把它当成"读者没动过"而当场 sample(含一次 React 重渲染),
|
|
1421
|
-
// 而这点滞后本来也看不见。
|
|
1422
|
-
const now = performance.now();
|
|
1423
|
-
const moved = Math.abs(scroller.scrollTop - announcedTop) > 0.6;
|
|
1424
|
-
if ((followHeight !== announcedHeight && moved) || now - lastAnnounceAt >= ANNOUNCE_MS) {
|
|
1425
|
-
lastAnnounceAt = now;
|
|
1426
|
-
announcedHeight = followHeight;
|
|
1427
|
-
announcedTop = scroller.scrollTop;
|
|
1428
|
-
announceCount += 1;
|
|
1429
|
-
try { scroller.dispatchEvent(new Event("scroll")); } catch { /* ignore */ }
|
|
1693
|
+
// task-19 ②:主容器几何每帧只读一次(sh/ch/top),此后全帧复用;写后位置用唯一一次回读 top1 兜住浏览器 floor 钳位。
|
|
1694
|
+
const sh0 = scroller.scrollHeight;
|
|
1695
|
+
const ch0 = scroller.clientHeight;
|
|
1696
|
+
const top0 = readTop(scroller);
|
|
1697
|
+
frameSh = sh0; frameCh = ch0; frameTop = top0;
|
|
1698
|
+
followHeight = sh0;
|
|
1699
|
+
{ const tPin = performance.now(); pinTick(scroller); phaseAcc("pin", tPin); } // 置顶条:读一遍行/容器位置并摆好(固定层,不改会话布局)
|
|
1700
|
+
const tPlan = performance.now();
|
|
1701
|
+
readCount += 2; // 本帧主容器的两次强制布局读(scrollHeight/clientHeight)
|
|
1702
|
+
// 口径(S2-2):readCount = 插件主动发起的强制布局读。尺寸读 = 上面这行 + 每窗那行;scrollTop 读统一走 readTop 自增
|
|
1703
|
+
// (含写后回读)。getBoundingClientRect 与事件侧 onMainScroll/pinTick 不计(量纲不同),勿据此判断"读很少"。
|
|
1704
|
+
const floor = Math.max(0, sh0 - ch0);
|
|
1705
|
+
frameToken += 1; vtStamp = frameToken; vtRect = null; // task-19 ②:viewTop 帧内 rect 缓存只在本次 tick 内有效
|
|
1706
|
+
const mark = (goal, gap, rateV, capV, moving, baseTop) => {
|
|
1707
|
+
lastStep = { writer, goal, gap: +gap.toFixed(2), base: +(baseTop === undefined ? scroller.scrollTop : baseTop).toFixed(2), rate: +rateV.toFixed(3), vMax: capV,
|
|
1708
|
+
vFeed: +mainVel.vFeed.toFixed(2), vChase: +mainVel.vChase.toFixed(2), a: accelValue(),
|
|
1709
|
+
dt: +dt.toFixed(2), moving, nav: navActive ? 1 : 0, floor: +floor.toFixed(1), releaseReason, rearmCount };
|
|
1710
|
+
};
|
|
1711
|
+
if (writer === WRITER_READER) { // 读者接管 / 会话恢复期:零程序化写
|
|
1712
|
+
// 接回跟尾的唯一语义信号:文档可滚动、且视图确实在末端(官方恢复到位 / 读者滚到底 / 换容器时已在底)
|
|
1713
|
+
if (scroller.scrollHeight > scroller.clientHeight && floor - scroller.scrollTop <= 0.5) rearm(); // 契约:reader 态"落末端"接回(static-check 字面量;仅读者分支,不影响滑行帧)
|
|
1714
|
+
else if (floor === 0 && releaseReason !== "reset") rearm(); // A6:内容不足一屏 ⇒ floor=0 本身就是贴底(短会话滚一次不再永久停跟);刚换容器(reset)仍交给官方恢复
|
|
1715
|
+
navRecheck = null; // 读者接管:未决复算作废(不抢输入)
|
|
1716
|
+
updateJumpGate(floor - top0, false);
|
|
1717
|
+
mark("none", floor - top0, 0, 0, false, top0);
|
|
1718
|
+
return false;
|
|
1430
1719
|
}
|
|
1431
|
-
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
if (performance.now()
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1720
|
+
// S1-4/task-17:element 到位后的**有界观察窗口**。窗口内只观察,|offset|>2px 才纠正;
|
|
1721
|
+
// 纠正走既有 navigate({kind:"element"})(唯一移动律);两次纠正间隔 ≥ NAV_WATCH_MIN_GAP_MS,上限 NAV_WATCH_MAX。
|
|
1722
|
+
// 退出:窗口到期 / 次数用完 / 目标断开 / writer!==us / 新导航(navigate 清)/ 读者(release)/rearm/hold/换会话/官方档/dispose。
|
|
1723
|
+
if (navRecheck !== null && !navActive) {
|
|
1724
|
+
const rc = navRecheck;
|
|
1725
|
+
if (writer !== WRITER_US || !rc.el.isConnected || disposed) navRecheck = null;
|
|
1726
|
+
else if (performance.now() > rc.until || rc.left <= 0) navRecheck = null; // 窗口结束:不再写、不再排帧(尾部自然停)
|
|
1727
|
+
else {
|
|
1728
|
+
rc.tick = (rc.tick === undefined ? 0 : rc.tick) + 1;
|
|
1729
|
+
if (rc.tick % NAV_OBSERVE_EVERY === 0) { // task-19:观察降频(窗口期 rect 读减到 1/NAV_OBSERVE_EVERY)
|
|
1730
|
+
const wantRe = Math.max(0, Math.min(floor, viewTop(scroller, rc.el) - SETTLE_PAD));
|
|
1731
|
+
const off = Math.abs(wantRe - top0);
|
|
1732
|
+
if (off > 2 && performance.now() - rc.lastAt >= NAV_WATCH_MIN_GAP_MS) {
|
|
1733
|
+
rc.left -= 1; rc.lastAt = performance.now();
|
|
1734
|
+
navigate({ kind: "element", el: rc.el }); // navigate 内部会清 navRecheck —— 放回同一窗口/剩余次数
|
|
1735
|
+
navRecheck = rc;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1445
1738
|
}
|
|
1446
|
-
// 其余:展开/折叠让内容长高、浏览器锚定顺手改 scrollTop —— 不是读者,保持贴底
|
|
1447
1739
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1740
|
+
let want = null; // 每帧重算:tail 跟着 floor 长
|
|
1741
|
+
if (goalKind === "element") {
|
|
1742
|
+
if (goalEl === null || !goalEl.isConnected) { // task-32:目标丢了 ⇒ 协调作废(立刻折)
|
|
1743
|
+
const retry = settleTurn !== null ? lastUserRow() : null; // S1-2:本轮提问行被重挂载 ⇒ 不放弃导航,按 lastUserRow 重试一次
|
|
1744
|
+
goalEl = null; goalKind = "none"; navActive = false;
|
|
1745
|
+
if (settleTurn !== null) clearSettle("lost");
|
|
1746
|
+
if (retry !== null && retry.isConnected) navigate({ kind: "element", el: retry });
|
|
1747
|
+
}
|
|
1748
|
+
else want = Math.max(0, Math.min(floor, viewTop(scroller, goalEl) - SETTLE_PAD));
|
|
1749
|
+
} else if (goalKind === "position") want = Math.max(0, Math.min(floor, goalPos));
|
|
1750
|
+
else if (goalKind === "tail") want = navActive ? floor : smoothTail(floor, dt); // 一次性回底导航不走低通(目标静止);跟随才低通
|
|
1751
|
+
if (want === null) { // goal=none:我方停在一个位置(导航到位/锚定中),不写
|
|
1752
|
+
updateJumpGate(floor - top0, false);
|
|
1753
|
+
mark("none", floor - top0, 0, 0, false, top0);
|
|
1754
|
+
// S1-4/task-17:观察窗口开启时 goal 已是 none,但必须继续排帧(否则窗口只能被动等外部唤醒)
|
|
1755
|
+
return navRecheck !== null;
|
|
1756
|
+
}
|
|
1757
|
+
const gap = want - top0;
|
|
1758
|
+
// 一次性导航的安全网:首帧按真实距离武装;只有真到点才续期(旧版的固定 1400ms 会把远跳当超时 → 中途停/掉回限速)
|
|
1759
|
+
if (navActive) {
|
|
1760
|
+
if (navUntil === 0) navUntil = performance.now() + navBudget(Math.abs(gap));
|
|
1761
|
+
else if (performance.now() > navUntil) {
|
|
1762
|
+
navUntil = performance.now() + navBudget(Math.abs(gap));
|
|
1763
|
+
lastNav = { kind: goalKind, why: "renew", to: Math.round(top0), ms: +(performance.now() - navT0).toFixed(1) };
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
const vMax = navActive ? 0 : (goalKind === "tail" ? speedValue() : 0); // 一次性导航不设追赶上限;跟随用 followMaxSpeed(px/s)
|
|
1450
1767
|
|
|
1768
|
+
const bootOver = moveCount > 20 || performance.now() - bootAt >= 300;
|
|
1769
|
+
if (bootOver && !gutterSet) { gutterSet = true; try { scroller.style.scrollbarGutter = "stable"; } catch { /* ignore */ } }
|
|
1770
|
+
const tSlide = performance.now();
|
|
1771
|
+
const moving = slide(scroller, want, dt, vMax, mainVel, top0, "main", floor); // task-19:cur 用本帧已读值;maxHint=floor 让"未写成"的预测不再回读
|
|
1772
|
+
if (navActive && moving) navProgressAt = performance.now(); // task-12:有推进就给看门狗续期
|
|
1773
|
+
phaseAcc("mainSlide", tSlide);
|
|
1774
|
+
phaseAcc("mainPlan", tPlan);
|
|
1775
|
+
if (performance.now() > settleUntil) settleTimeout(); // 兜底:超时即清(滑行没到位也不许把折叠永久压住)
|
|
1776
|
+
const top1 = readTop(scroller); // task-19:全帧唯一一次写后回读(浏览器按 floor 钳位,预测值不足以还原真实落点)
|
|
1777
|
+
frameTop = top1;
|
|
1778
|
+
if (navActive && Math.abs(want - top1) <= 0.5) { // 一次性导航到位(按落点判,不看 moving:dt=0 那帧不推进):tail 保留目标(转为跟随),element/position 撤目标
|
|
1779
|
+
const arrivedEl = goalKind === "element" ? goalEl : null;
|
|
1780
|
+
navActive = false;
|
|
1781
|
+
lastNav = { kind: goalKind, why: "done", to: Math.round(top1), ms: +(performance.now() - navT0).toFixed(1) };
|
|
1782
|
+
if (goalKind === "element" || goalKind === "position") { goalEl = null; goalKind = "none"; }
|
|
1783
|
+
clearTimeout(navWatchdog); navWatchdog = 0; // task-12:到位 ⇒ 看门狗撤掉
|
|
1784
|
+
// S1-4/task-17:element 到位 ⇒ 开启/延续有界观察窗口(同一目标延续 until/left/lastAt,不被自己的纠正重置)
|
|
1785
|
+
navRecheck = arrivedEl !== null && arrivedEl.isConnected
|
|
1786
|
+
? (navRecheck !== null && navRecheck.el === arrivedEl
|
|
1787
|
+
? { el: arrivedEl, until: navRecheck.until, left: navRecheck.left, lastAt: navRecheck.lastAt, tick: navRecheck.tick }
|
|
1788
|
+
: { el: arrivedEl, until: performance.now() + NAV_WATCH_MS, left: NAV_WATCH_MAX, lastAt: 0, tick: 0 })
|
|
1789
|
+
: null;
|
|
1790
|
+
if (settleTurn !== null) clearSettle("done"); // 滑到提问处 ⇒ 收工:该轮折叠交给下一次扫描(task-21)
|
|
1791
|
+
}
|
|
1792
|
+
mark(goalKind, gap, expStep(gap, dt), vMax, moving, top1);
|
|
1793
|
+
updateJumpGate(floor - top1, goalKind === "tail");
|
|
1794
|
+
if (moving) ensureTickLoop(); // task-22:不再派发合成 scroll(原 announce 每帧重入官方 handler 11~17ms)
|
|
1795
|
+
// task-12:不得在这里用 if (moving || navActive) 续帧 —— 此刻 ticking 仍为 true,ensureTickLoop 会早退(空操作);
|
|
1796
|
+
// 真正续帧会变成不可达目标下的永久 rAF 空转。卡住的一次性导航改由上面的看门狗有界终止(清 navActive/goal + clearSettle)。
|
|
1797
|
+
// S1-4/task-17:窗口开启期间由帧驱动观察(有界 2s);窗口到期或退出条件命中后 navRecheck=null,循环自然停、零残留。
|
|
1798
|
+
return moving || navRecheck !== null;
|
|
1799
|
+
}
|
|
1800
|
+
/** scroll 事件只做两件事:reader 态下"位置真的落到末端"(事实 ⇒ 取回写权),以及叫醒帧循环维护按钮显隐。
|
|
1801
|
+
* 释放判定不在这里:在输入层(真实输入)与写层(非贴底写 = 别的程序在导航)。 */
|
|
1802
|
+
function onMainScroll(event) {
|
|
1803
|
+
const el = event.currentTarget;
|
|
1804
|
+
const floor = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
1805
|
+
if (writer === WRITER_READER && floor - el.scrollTop <= 0.5) rearm(); // 规则 4:读者落到末端 ⇒ 取回写权、目标=尾
|
|
1806
|
+
ensureTickLoop();
|
|
1807
|
+
}
|
|
1451
1808
|
let scrollerCache = null; // 滚动容器几乎不变,但找它要逐级 getComputedStyle(每帧都做就是每秒上百次样式解析)
|
|
1452
1809
|
let flowCache = null; // 会话根([data-chat-flow])与最后一行:帧路径每帧都要用,不能每帧全文档查
|
|
1453
1810
|
let lastRowSeen = null;
|
|
1454
1811
|
function chatFlow() {
|
|
1455
1812
|
if (flowCache && flowCache.isConnected) return flowCache;
|
|
1456
|
-
|
|
1457
|
-
// 文档序第一个 [data-chat-flow] 未必是主会话 —— 优先取不在 sidebar 里的那一个,一个都没有才退回第一个。
|
|
1458
|
-
const all = document.querySelectorAll(CHAT_FLOW);
|
|
1459
|
-
flowCache = null;
|
|
1460
|
-
for (const el of all) {
|
|
1461
|
-
if (el.closest("[data-sidebar-chat]") === null) { flowCache = el; break; }
|
|
1462
|
-
}
|
|
1463
|
-
if (flowCache === null) flowCache = all.length ? all[0] : null;
|
|
1813
|
+
flowCache = document.querySelector(CHAT_FLOW);
|
|
1464
1814
|
return flowCache;
|
|
1465
1815
|
}
|
|
1466
1816
|
function mainScroller() {
|
|
1467
1817
|
const flow = chatFlow();
|
|
1468
|
-
guardObserve(flow); // 顺带:这个函数每帧被调一次,是唯一稳定拿到 flow 的地方,guard 的 observer 就在这儿挂(缓存后不额外查 DOM)
|
|
1469
1818
|
const probe = lastRowSeen && lastRowSeen.isConnected ? lastRowSeen : flow;
|
|
1470
1819
|
if (scrollerCache && scrollerCache.isConnected && probe && scrollerCache.contains(probe)) return scrollerCache;
|
|
1471
1820
|
// 取「最外层」可滚动祖先:0.1.7 每个过程组自带一个内滚动体,从行往上第一个撞到的不是会话滚动条
|
|
@@ -1484,87 +1833,211 @@ function mainScroller() {
|
|
|
1484
1833
|
function chatScope() { return mainScroller() || document; }
|
|
1485
1834
|
|
|
1486
1835
|
/* ------------------------------------------------- 跑完一轮 → 移到提问处 -- */
|
|
1836
|
+
const TAIL_TAU_MS = 150; // 跟随目标(floor)的一阶低通时间常数:floor 是 React 按块提交的阶梯,低通后再喂唯一律
|
|
1837
|
+
let tailWant = -1; // 低通状态(仅 goalKind==="tail" 用;换目标/交权/换容器时作废)
|
|
1838
|
+
/** 跟随目标的低通:消掉"块间 gap→0 停住、块到跳一下"的目标锯齿。收缩/首次/换目标时直接对齐,不慢慢爬。 */
|
|
1839
|
+
function smoothTail(floor, dt) {
|
|
1840
|
+
if (tailWant < 0 || tailWant > floor + 1) { tailWant = floor; return floor; }
|
|
1841
|
+
tailWant += (floor - tailWant) * (1 - Math.exp(-dt / TAIL_TAU_MS));
|
|
1842
|
+
return tailWant;
|
|
1843
|
+
}
|
|
1487
1844
|
const SETTLE_PAD = 12; // 提问行停在视口顶端往下多少像素
|
|
1488
|
-
const SETTLE_MAX_MS = 1400; //
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
function
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
/**
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1845
|
+
const SETTLE_MAX_MS = 1400; // 一次性导航预算的下限(安全网,不是正常终止条件)
|
|
1846
|
+
/** 一次性导航的"安全网"预算(ms):新律受加速度上限约束,位移近似三角形 ⇒ 覆盖 x 约需 2·sqrt(2x/a)。
|
|
1847
|
+
* 取 2× 三角形时间 + 600ms 余量,且不小于 SETTLE_MAX_MS。到点只**按剩余距离续期**,绝不把它当终止条件
|
|
1848
|
+
* (正常终止只有两个:到位 |dy|≤0.5、目标节点消失)。 */
|
|
1849
|
+
function navBudget(dist) {
|
|
1850
|
+
const a = accelValue();
|
|
1851
|
+
const x = Math.max(0, Number(dist) || 0);
|
|
1852
|
+
return Math.max(SETTLE_MAX_MS, Math.ceil(2 * Math.sqrt(2 * x / a) * 1000) + 600);
|
|
1853
|
+
}
|
|
1854
|
+
/** task-12:有界终止卡住的一次性导航。目标不可达时 slide 零步 ⇒ 帧循环自行停(无 rAF),
|
|
1855
|
+
* 所以不能靠"每帧零进展计数";用一次性看门狗:预算到期且期间零进展 ⇒ 清 navActive/目标并走既有 timeout 清理。
|
|
1856
|
+
* 复用 "timeout" 而不新增 reason:static-check 钉死 clearSettle 恰好 9 个清理点(多一个即视为新闩锁)。 */
|
|
1857
|
+
function abortStuckNav() {
|
|
1858
|
+
navActive = false; navUntil = 0; goalEl = null; goalKind = "none"; navProgressAt = 0;
|
|
1859
|
+
clearTimeout(navWatchdog); navWatchdog = 0;
|
|
1860
|
+
const sc = mainScroller();
|
|
1861
|
+
navAbortCount += 1;
|
|
1862
|
+
lastNavAbort = { why: "stuck", armedAt: Math.round(navArmedAt), at: Math.round(performance.now()), budgetMs: Math.round(navBudgetMs), top: sc === null ? 0 : Math.round(sc.scrollTop) };
|
|
1863
|
+
settleTimeout(); // 复用既有 timeout 清理(不新增 clearSettle 字面量)
|
|
1864
|
+
updateJumpGate(sc === null ? 0 : Math.max(0, sc.scrollHeight - sc.clientHeight) - sc.scrollTop, false);
|
|
1865
|
+
}
|
|
1866
|
+
/** 超时/卡住兜底清理的共享出口:timeout 清理只保留这一处调用点(static-check 钉死恰好 9 个清理点)。 */
|
|
1867
|
+
function settleTimeout() { if (settleTurn !== null) clearSettle("timeout"); }
|
|
1868
|
+
function armNavWatchdog(delay) {
|
|
1869
|
+
clearTimeout(navWatchdog);
|
|
1870
|
+
navWatchdog = setTimeout(() => {
|
|
1871
|
+
navWatchdog = 0;
|
|
1872
|
+
if (disposed || !navActive) return;
|
|
1873
|
+
if (performance.now() - navProgressAt < NAV_STALL_MS) { armNavWatchdog(NAV_STALL_MS); return; } // 仍在推进:续期,不终止
|
|
1874
|
+
abortStuckNav();
|
|
1875
|
+
}, Math.max(NAV_STALL_MS, delay));
|
|
1503
1876
|
}
|
|
1504
1877
|
let wasRunning = false; // 上一次扫描时这一轮是否在跑(true→false = 刚跑完)
|
|
1505
|
-
|
|
1878
|
+
let lastUserKey = null; // 最近一次见到的"你最新那条消息"的行 key(回尾判据:key 变了 = 你刚发了新消息)
|
|
1879
|
+
/* ---- 一轮跑完的「先滑后折」协调(task-21):一次性标记,不是历史闩锁 ----------------
|
|
1880
|
+
* 起效时记下这一轮的轮号 ⇒ syncTurns 只压住**该轮**的折叠;滑行到位/超时/交权/会话边界/官方档位/dispose 即清。 */
|
|
1881
|
+
let settleTurn = null; // 正在为哪一轮协调(null = 没在协调)
|
|
1882
|
+
let settleUntil = 0; // 兜底超时(performance.now() 时刻)
|
|
1883
|
+
let lastSettle = null; // 诊断:最近一次协调 {turn, why, at}
|
|
1884
|
+
/** 元素顶端在滚动容器里的位置(绝对 scrollTop 坐标)。
|
|
1885
|
+
* task-19 ②:tick 帧内对主容器复用 frameTop + 一次容器 rect(vtRect),同一帧多次 viewTop 不再重复强制布局读;
|
|
1886
|
+
* 帧外(scan/navigate 等)走原式,语义与缓存无关。 */
|
|
1506
1887
|
function viewTop(scroller, el) {
|
|
1888
|
+
if (scroller === followScroller && vtStamp === frameToken) {
|
|
1889
|
+
if (vtRect === null) vtRect = scroller.getBoundingClientRect();
|
|
1890
|
+
return frameTop + el.getBoundingClientRect().top - vtRect.top;
|
|
1891
|
+
}
|
|
1507
1892
|
return scroller.scrollTop + el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
1508
1893
|
}
|
|
1894
|
+
/** 复刻 Element.scrollIntoView 的**纵向**目标(主容器坐标):block = start(默认)|center|end|nearest。
|
|
1895
|
+
* 只做主容器一个滚动上下文的对齐(祖先链由调用处保证:node 已在容器内);返回未 clamp 的目标,由调用方按 floor 收口。 */
|
|
1896
|
+
function scrollIntoViewTarget(sc, node, block) {
|
|
1897
|
+
const scRect = sc.getBoundingClientRect();
|
|
1898
|
+
const rect = node.getBoundingClientRect();
|
|
1899
|
+
const height = rect.height || (rect.bottom - rect.top);
|
|
1900
|
+
const top = sc.scrollTop + rect.top - scRect.top;
|
|
1901
|
+
const view = sc.clientHeight;
|
|
1902
|
+
const mode = block === "center" || block === "end" || block === "nearest" ? block : "start";
|
|
1903
|
+
if (mode === "center") return top - (view - height) / 2;
|
|
1904
|
+
if (mode === "end") return top - (view - height);
|
|
1905
|
+
if (mode === "nearest") {
|
|
1906
|
+
const cur = sc.scrollTop;
|
|
1907
|
+
if (top < cur) return top;
|
|
1908
|
+
if (top + height > cur + view) return top + height - view;
|
|
1909
|
+
return cur;
|
|
1910
|
+
}
|
|
1911
|
+
return top;
|
|
1912
|
+
}
|
|
1509
1913
|
/** 会话里所有用户发言行(文档顺序):置顶条与「跑完回到提问处」共用。 */
|
|
1510
1914
|
function userRows() {
|
|
1511
1915
|
return chatRows().filter((el) => el.getAttribute("data-chat-flow-kind") === "user");
|
|
1512
1916
|
}
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
const want = Math.min(floor, Math.max(0, viewTop(scroller, row) - SETTLE_PAD));
|
|
1535
|
-
followOn = false; // 主动离开底部:别让跟随再把我们拽回底部
|
|
1536
|
-
const turn = row.getAttribute("data-chat-turn");
|
|
1537
|
-
if (state.animations === false) { writeTop(scroller, want); return mark("jump", { from, want, floor, turn }); } // 动效关掉 → 直接到位
|
|
1538
|
-
glide = { el: row, until: performance.now() + SETTLE_MAX_MS, t0: performance.now(), from, want };
|
|
1539
|
-
if (turn !== null && turn !== "") { settleHoldTurn = String(turn); settleHoldUntil = performance.now() + SETTLE_MAX_MS + 200; } // 先回提问处,滑完再折
|
|
1540
|
-
ensureTickLoop();
|
|
1541
|
-
mark("glide", { from, want, floor, turn });
|
|
1917
|
+
const Q_TOOL_SEL = '[data-tool="ask_user_question"], [data-tool="request_user_input"]'; // 提问工具卡(当场回答渲染在卡里)
|
|
1918
|
+
const Q_ANSWER_SEL = "[data-answer], [data-question-answer], [data-tool-result], [class*='_answer'], [class*='_result']";
|
|
1919
|
+
const isQuestionCard = (el) => el.matches(Q_TOOL_SEL) || el.querySelector(Q_TOOL_SEL) !== null;
|
|
1920
|
+
/** 提问卡文案:卡里能取到**用户的回答** ⇒ "答: …";取不到 ⇒ 问题本身 "问: …";两条都取不到 ⇒ null(不进候选)。 */
|
|
1921
|
+
function questionCardText(row) {
|
|
1922
|
+
let answer = row.getAttribute("data-answer") || "";
|
|
1923
|
+
if (!answer) { const el = row.querySelector(Q_ANSWER_SEL); if (el !== null) answer = rowText(el, undefined, 200); }
|
|
1924
|
+
answer = String(answer).replace(/\s+/g, " ").trim();
|
|
1925
|
+
if (answer) return ("答: " + answer).slice(0, 200);
|
|
1926
|
+
const q = String(rowText(row, (n) => n.matches("[class*='_time'],[class*='_actions']"), 400)).replace(/\s+/g, " ").trim();
|
|
1927
|
+
return q ? ("问: " + q).slice(0, 200) : null;
|
|
1928
|
+
}
|
|
1929
|
+
/** H19f:置顶条数据源唯一定义(fold 与 native 共用) —— 提问行 ∪ 迟到回答行 ∪ **带提问工具卡的行**(当场回答)。 */
|
|
1930
|
+
function pinSource(rows) {
|
|
1931
|
+
return rows.filter((el) => {
|
|
1932
|
+
const k = el.getAttribute("data-chat-flow-kind");
|
|
1933
|
+
if (k === "user") return true;
|
|
1934
|
+
if (state.keepUserQuestions !== true) return false;
|
|
1935
|
+
if (k === "question-reply" || k === "user-question-reply" || el.querySelector("[data-question-reply]") !== null) return true;
|
|
1936
|
+
return isQuestionCard(el) && questionCardText(el) !== null; // 当场回答永远没有 question-reply 行,只能从工具卡取
|
|
1937
|
+
});
|
|
1542
1938
|
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1939
|
+
function lastUserRow() {
|
|
1940
|
+
const rows = userRows();
|
|
1941
|
+
return rows.length === 0 ? null : rows[rows.length - 1];
|
|
1942
|
+
}
|
|
1943
|
+
/** 结束「先滑后折」协调(一次性):清掉标记后立刻排一次扫描,让该轮按正常规则折叠(不留历史闩锁)。 */
|
|
1944
|
+
/** 记录"用户当场操作过的轮次"(manualState 的**唯一**写入口):值真的变了就立刻取消 settle 协调。
|
|
1945
|
+
* task-23:滑行途中用户手动折叠/展开该轮 ⇒ 当场按用户操作折,不再等滑行到位。 */
|
|
1946
|
+
function setManual(turn, value) {
|
|
1947
|
+
if (turn === null || turn === undefined) return;
|
|
1948
|
+
if (manualState.get(turn) === value) return;
|
|
1949
|
+
manualState.set(turn, value);
|
|
1950
|
+
clearSettle("manual");
|
|
1951
|
+
}
|
|
1952
|
+
/** 结束「先滑后折」协调(一次性):清掉标记后立刻排一次扫描,让该轮按正常规则折叠(不留历史闩锁)。 */
|
|
1953
|
+
function clearSettle(reason) {
|
|
1954
|
+
if (settleTurn === null) return;
|
|
1955
|
+
lastSettle = { turn: String(settleTurn), why: reason, at: Math.round(performance.now()) };
|
|
1956
|
+
settleTurn = null; settleUntil = 0;
|
|
1957
|
+
if (reason !== "dispose") { try { schedule(); } catch { /* ignore */ } } // 折叠由扫描执行:清完立刻排一次
|
|
1958
|
+
}
|
|
1959
|
+
/** 本轮跑完:滑到本轮提问处(我方导航)。**只在触底吸附**(writer=us ∧ goal=tail ∧ 非一次性导航)时起效;
|
|
1960
|
+
* 读者接管 / 停在中途(goal=none|element)/ 官方档位 / 开关关闭 ⇒ 一律不动。 */
|
|
1961
|
+
function settleToPrompt(flow) {
|
|
1962
|
+
if (state.settleToPrompt === false) return false;
|
|
1963
|
+
if (state.mode !== "fold") return false; // 官方档位:完全不接管
|
|
1964
|
+
if (writer !== WRITER_US || goalKind !== "tail" || navActive) return false; // 只在"视图钉在末端"时起效
|
|
1965
|
+
const row = lastUserRow();
|
|
1966
|
+
if (row === null) return false;
|
|
1967
|
+
navigate({ kind: "element", el: row });
|
|
1563
1968
|
return true;
|
|
1564
1969
|
}
|
|
1565
1970
|
|
|
1566
1971
|
/* ----------------------------------------------------- 置顶显示提问 -- */
|
|
1567
1972
|
let pinRows = []; // 所有用户发言行(扫描时更新,文档顺序)
|
|
1973
|
+
/* H19b 提问卡自动展开:登记与点击分离 —— 点击挪出扫描帧(不再把 React 同步提交的 ~30ms 压在我们的扫描上)。
|
|
1974
|
+
去重键 = 轮次(data-chat-turn);没有轮号的卡片退回按元素去重。 */
|
|
1975
|
+
const qCardTurns = new Set(); // 已自动展开过的提问卡所在轮次
|
|
1976
|
+
const qCardEls = new WeakSet(); // 没有轮号时的兜底
|
|
1977
|
+
const qCardQueued = new WeakSet(); // 已登记待点击(跨扫描不重复入队)
|
|
1978
|
+
let qCardQueue = [];
|
|
1979
|
+
let qCardRaf = 0;
|
|
1980
|
+
function qCardKey(card) {
|
|
1981
|
+
const row = card && typeof card.closest === "function" ? card.closest(ROW) : null;
|
|
1982
|
+
const turn = row === null ? null : row.getAttribute("data-chat-turn");
|
|
1983
|
+
return turn === null || turn === "" ? null : turn;
|
|
1984
|
+
}
|
|
1985
|
+
function qCardHandled(card) {
|
|
1986
|
+
const turn = qCardKey(card);
|
|
1987
|
+
return turn === null ? qCardEls.has(card) : qCardTurns.has(turn);
|
|
1988
|
+
}
|
|
1989
|
+
function markQCard(card) {
|
|
1990
|
+
const turn = qCardKey(card);
|
|
1991
|
+
if (turn === null) qCardEls.add(card); else qCardTurns.add(turn);
|
|
1992
|
+
try { card.dataset.dshsfKeepOpen = "1"; } catch { /* ignore */ }
|
|
1993
|
+
}
|
|
1994
|
+
/** 下一帧再点:扫描帧里不再背 React 提交的成本。 */
|
|
1995
|
+
function runQCardQueue() {
|
|
1996
|
+
qCardRaf = 0;
|
|
1997
|
+
const batch = qCardQueue; qCardQueue = [];
|
|
1998
|
+
for (const card of batch) {
|
|
1999
|
+
if (!card.isConnected || qCardHandled(card)) continue;
|
|
2000
|
+
const target = disclosureTarget(card);
|
|
2001
|
+
if (!target || target.getAttribute("aria-expanded") === "true") { markQCard(card); continue; }
|
|
2002
|
+
if (clickOnce(target)) markQCard(card);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
/* 思考/工具自动展开:与提问卡同一手法 —— 扫描只登记,合成点击挪到下一帧。
|
|
2006
|
+
它们的合成点击同样会触发 React 同步提交(真机 cost 里 openThink 单次最高 18ms),不该算进扫描成本;
|
|
2007
|
+
打标(tagThinkWindow)仍在点击后同一个 rAF 里做,避免"先闪一下没有小窗的样子"。 */
|
|
2008
|
+
const autoOpenQueued = new WeakSet();
|
|
2009
|
+
let autoOpenQueue = [];
|
|
2010
|
+
let autoOpenRaf = 0;
|
|
2011
|
+
function queueAutoOpen(kind, root) {
|
|
2012
|
+
if (autoOpenQueued.has(root)) return;
|
|
2013
|
+
autoOpenQueued.add(root);
|
|
2014
|
+
autoOpenQueue.push({ kind, root });
|
|
2015
|
+
if (autoOpenRaf === 0 && typeof requestAnimationFrame === "function") autoOpenRaf = requestAnimationFrame(runAutoOpenQueue);
|
|
2016
|
+
}
|
|
2017
|
+
function runAutoOpenQueue() {
|
|
2018
|
+
autoOpenRaf = 0;
|
|
2019
|
+
const batch = autoOpenQueue; autoOpenQueue = [];
|
|
2020
|
+
for (const item of batch) {
|
|
2021
|
+
const root = item.root;
|
|
2022
|
+
autoOpenQueued.delete(root);
|
|
2023
|
+
if (!root.isConnected || root.closest("[data-dshsf-folded]") !== null) continue;
|
|
2024
|
+
if (item.kind === "think") {
|
|
2025
|
+
if (state.autoExpandReasoning === false || thinkOpen(root)) continue;
|
|
2026
|
+
if (openThink(root)) sparks.focus(root.querySelector(THINK_BODY)); // 展开成功:火星马上挂到新窗
|
|
2027
|
+
} else {
|
|
2028
|
+
if (state.autoExpandTools === false || toolOpen(root)) continue;
|
|
2029
|
+
openTool(root);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
let capRejected = null;
|
|
2034
|
+
let accelRejected = null; // 跟随加速度上限非法时的 {raw, at}
|
|
2035
|
+
let foreignLanded = 0;
|
|
2036
|
+
const releaseBy = { reader: 0, external: 0, reset: 0 }; // 仪器:按 reason 分组的释放计数;external 为死字段(该释放入口已在 task-12 取消),仅兼容保留
|
|
2037
|
+
let rearmCount = 0; // 仪器:取回写权次数
|
|
2038
|
+
let lastForeign = null; // 仪器:最近一笔放行的官方非贴底写(别的程序在导航)
|
|
2039
|
+
let scanErrors = 0; // F4:扫描期异常计数(诊断)
|
|
2040
|
+
let lastScanError = null; // H19b:已经由我们展开过的提问卡(WeakSet;用户之后手动折回不再抢开)
|
|
1568
2041
|
let pinPick = -1; // 上一帧钉住的下标(下一帧先验它还成不成立,省掉二分)
|
|
1569
2042
|
let pinRow = null; // 当前置顶的那一行(帧里按"正在看哪一轮"选出来)
|
|
1570
2043
|
let pinBar = null; // 固定层上的置顶条
|
|
@@ -1578,11 +2051,8 @@ function ensurePinBar() {
|
|
|
1578
2051
|
bar.setAttribute("data-dshsf-pin", "");
|
|
1579
2052
|
bar.title = "回到本轮提问";
|
|
1580
2053
|
bar.addEventListener("click", () => {
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
followOn = false; // 主动离开底部:和「跑完回到提问处」同一套滑行
|
|
1584
|
-
glide = { el: pinRow, until: performance.now() + SETTLE_MAX_MS, t0: performance.now(), from: Math.round(el.scrollTop), want: null };
|
|
1585
|
-
ensureTickLoop();
|
|
2054
|
+
if (pinRow === null || !pinRow.isConnected) return;
|
|
2055
|
+
navigate({ kind: "element", el: pinRow, instant: true }); // 我方导航:取回写权、只换目标(用户发起 ⇒ reduce 下瞬跳);触底时点它也不会被官方贴底写拉回底部
|
|
1586
2056
|
});
|
|
1587
2057
|
(document.body || document.documentElement).appendChild(bar);
|
|
1588
2058
|
pinBar = bar;
|
|
@@ -1600,7 +2070,27 @@ function pinTick(scroller) {
|
|
|
1600
2070
|
}
|
|
1601
2071
|
}
|
|
1602
2072
|
function hidePinBar() { if (pinBar !== null && pinBar.hasAttribute("data-dshsf-visible")) pinBar.removeAttribute("data-dshsf-visible"); }
|
|
2073
|
+
/** 选官方档位(mode != fold):整个运动层退出滚动 —— 置顶条与回底按钮收起、零写、速度态复位。返回"是否已交出"。 */
|
|
2074
|
+
function gateOfficialMode() {
|
|
2075
|
+
if (state.mode === "fold") return false;
|
|
2076
|
+
clearSettle("official"); // 官方档位:协调作废(这一档我们完全不接管)
|
|
2077
|
+
clearNavIntent();
|
|
2078
|
+
tailWant = -1;
|
|
2079
|
+
hidePinBar(); // 置顶条收起
|
|
2080
|
+
try { document.documentElement.removeAttribute("data-dshsf-jumpgate"); } catch { /* ignore */ }
|
|
2081
|
+
navActive = false; // 一次性导航作废
|
|
2082
|
+
navRecheck = null; // S1-4:官方档位 ⇒ 观察窗口立即退出
|
|
2083
|
+
if (goalKind === "element" || goalKind === "position") { goalEl = null; goalKind = "none"; }
|
|
2084
|
+
resetVel(mainVel); // 速度态复位
|
|
2085
|
+
for (const el of liveWindows) { // 小窗回底按钮也收起
|
|
2086
|
+
const jump = el.querySelector(":scope > [data-dshsf-jump]");
|
|
2087
|
+
if (jump !== null) jump.removeAttribute("data-dshsf-visible");
|
|
2088
|
+
}
|
|
2089
|
+
lastStep = { writer, goal: "off", gap: 0, base: 0, rate: 0, vMax: 0, vFeed: 0, vChase: 0, a: 0, dt: 0, moving: false, nav: 0, floor: 0, releaseReason: "mode", rearmCount };
|
|
2090
|
+
return true;
|
|
2091
|
+
}
|
|
1603
2092
|
function pinTickInner(scroller) {
|
|
2093
|
+
pinRects += 2; // 该函数每帧固定两次 getBoundingClientRect 级别测量(行 + 容器)
|
|
1604
2094
|
const hide = hidePinBar;
|
|
1605
2095
|
if (state.pinPrompt !== true || pinRows.length === 0) { hide(); return; }
|
|
1606
2096
|
const bar = ensurePinBar();
|
|
@@ -1630,8 +2120,20 @@ function pinTickInner(scroller) {
|
|
|
1630
2120
|
if (bar.style.top !== top) bar.style.top = top;
|
|
1631
2121
|
if (bar.style.left !== left) bar.style.left = left;
|
|
1632
2122
|
if (bar.style.width !== width) bar.style.width = width;
|
|
1633
|
-
|
|
1634
|
-
|
|
2123
|
+
const cachedPin = pinTextOf.get(row); // A13/task-20:observer 定向失效为主(按 record 回溯到行删除);
|
|
2124
|
+
const rawPin = String(row.textContent || ""); // 原始文本变化做兜底(不是长度)⇒ 等长改写同样刷新,不再依赖 len
|
|
2125
|
+
let text;
|
|
2126
|
+
if (cachedPin !== undefined && cachedPin.raw === rawPin) text = cachedPin.text;
|
|
2127
|
+
else {
|
|
2128
|
+
if (isQuestionCard(row)) text = questionCardText(row) || ""; // task-23:提问卡 ⇒ "答: …"(取不到回答则 "问: …")
|
|
2129
|
+
else {
|
|
2130
|
+
text = promptText(row);
|
|
2131
|
+
// H19:回答行加「答:」前缀,与用户提问行区分(只描述作用)
|
|
2132
|
+
const rk = row.getAttribute("data-chat-flow-kind");
|
|
2133
|
+
if (rk === "question-reply" || rk === "user-question-reply" || row.querySelector("[data-question-reply]") !== null) text = "答: " + text;
|
|
2134
|
+
}
|
|
2135
|
+
pinTextOf.set(row, { text, raw: rawPin });
|
|
2136
|
+
}
|
|
1635
2137
|
if (bar.textContent !== text) bar.textContent = text;
|
|
1636
2138
|
if (show !== bar.hasAttribute("data-dshsf-visible")) {
|
|
1637
2139
|
if (show) bar.setAttribute("data-dshsf-visible", "");
|
|
@@ -1639,36 +2141,21 @@ function pinTickInner(scroller) {
|
|
|
1639
2141
|
}
|
|
1640
2142
|
}
|
|
1641
2143
|
|
|
1642
|
-
/**
|
|
1643
|
-
* 官方那颗回底按钮的**点击**:官方是 `toBottom → el.scrollTop = el.scrollHeight`(瞬跳,容器也没有
|
|
1644
|
-
* `scroll-behavior:smooth`)。捕获阶段截下这次点击,改成置 followOn + 排帧 —— 回底就交给与我们跟随完全
|
|
1645
|
-
* 同一条追赶公式,平滑滑下去。跟随/动效关掉时放行,退回官方那条瞬跳(否则点了没反应)。
|
|
1646
|
-
*/
|
|
2144
|
+
/** 官方那颗回底按钮的**点击**:不阻止官方那笔贴底写(会被吸收器吞掉),只发我方导航意图。 */
|
|
1647
2145
|
function onToBottomClick(event) {
|
|
1648
2146
|
if (synthetic || !(event.target instanceof Element)) return;
|
|
1649
2147
|
const btn = event.target.closest("button[class*='_toBottom']"); // 图标在按钮里,取最近的那个按钮
|
|
1650
|
-
if (btn === null || btn.closest("[data-dshsf-window]") !== null) return; // 小窗里的按钮不是它
|
|
1651
|
-
//
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
event.stopPropagation(); // React 的 onClick 在根上冒泡阶段,捕获阶段拦得住
|
|
1655
|
-
followOn = true;
|
|
1656
|
-
glide = null; // 点了回底就是要到底:别让没跑完的滑行继续往旧目标带
|
|
1657
|
-
releaseSettleHold();
|
|
1658
|
-
document.documentElement.removeAttribute("data-dshsf-jumpgate"); // 按钮立刻收起:我们已经在往回走了
|
|
1659
|
-
ensureTickLoop();
|
|
2148
|
+
if (btn === null || btn.closest("[data-dshsf-window], [data-dshsf-textwindow]") !== null) return; // 小窗里的按钮不是它
|
|
2149
|
+
// 不阻止官方那笔贴底写(会被吸收器吞掉):取回写权 + 目标=尾,一次性滑行不设 cap。
|
|
2150
|
+
// 不能按 animations 早退:writer=us / goal=none(停在中途)时官方那笔写会被吸收,点了会"没反应"。
|
|
2151
|
+
navigate({ kind: "tail", instant: true }); // 用户发起 ⇒ reduce 下瞬跳(跟随不看 reduced-motion)
|
|
1660
2152
|
}
|
|
1661
2153
|
|
|
1662
2154
|
/**
|
|
1663
|
-
*
|
|
1664
|
-
* 我们只接管"它什么时候出现"——CSS 默认把它藏起来,判定要显示时才给 html 挂
|
|
1665
|
-
*
|
|
1666
|
-
* 点亮 = 读者接管(!followOn)且确实离底(gap>24);熄灭 = 回到贴底(gap<12)/ 关掉开关。
|
|
1667
|
-
* 元素在不在 DOM 里仍由官方决定,但那只影响"能不能显示";闪不闪由我们说了算。
|
|
2155
|
+
* 主窗口的回底按钮**不自绘**:外观、位置、点击行为都用官方那颗([class*='_toBottomSlot'] 里的按钮),
|
|
2156
|
+
* 我们只接管"它什么时候出现"——CSS 默认把它藏起来,判定要显示时才给 html 挂 data-dshsf-jumpgate。
|
|
2157
|
+
* 点亮 = 我方目标不是尾(读者接管 / 停在中途)且确实离底(gap>24);熄灭 = 回到贴底(gap<12)或关掉开关。
|
|
1668
2158
|
*/
|
|
1669
|
-
/** 官方那颗按钮的显隐(带滞回;状态只有一处=html 上的 data-dshsf-jumpgate)。
|
|
1670
|
-
* 点亮 = 读者接管(!followOn)且确实离底(gap>24);熄灭 = 回到贴底(gap<12)或关掉开关。
|
|
1671
|
-
* 单独抽出来是因为"跟随关掉"那条早退路径也必须维护它——否则 CSS 默认藏着的官方按钮永远不会露面。 */
|
|
1672
2159
|
function updateJumpGate(gap, follow) {
|
|
1673
2160
|
const root = document.documentElement;
|
|
1674
2161
|
if (state.showJumpButton === false) { root.removeAttribute("data-dshsf-jumpgate"); return; }
|
|
@@ -1677,98 +2164,204 @@ function updateJumpGate(gap, follow) {
|
|
|
1677
2164
|
}
|
|
1678
2165
|
|
|
1679
2166
|
/**
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
1684
|
-
* 附带好处:官方记录的 observedTop 停在 floor,我们回补后的位置与它差着一段追赶距离,
|
|
1685
|
-
* 它下一次 onScroll 会判成"读者动过"→ 不再立刻 sample,pending 也就守住了。
|
|
1686
|
-
*/
|
|
1687
|
-
const SNAP_UNDO_MAX = 600; // 一次最多回补多少像素:超过这个数就不是吸底抢跑,是有人在跳转
|
|
1688
|
-
let guardRO = null;
|
|
1689
|
-
let guardFlow = null;
|
|
1690
|
-
let guardComposer; // 官方那条 RO 还 observe 了 composer:它一变也会吸底,所以我们也得盯(undefined=还没查过,null=查过没有)
|
|
1691
|
-
/**
|
|
1692
|
-
* 官方吸底的最后一条路是**直接赋值** `el.scrollTop = el.scrollHeight`(toBottom 与 followRef 都这么写),
|
|
1693
|
-
* 采样标记压不住它,只能事后回补——而回补总有"晚一帧"的窗口(官方在别的绘制阶段落地那一跳就画出去了,
|
|
1694
|
-
* 看着就是闪)。所以在滚动容器实例上装一个 setter:**只要位置还归我们管(snapOwn),就把这种"一步贴到底"
|
|
1695
|
-
* 的写入直接丢掉**,位置从此只有我们一个写入者;读者/导航/换会话的写入照旧放行。
|
|
1696
|
-
* 判据必须是"状态"而不是"我方最近写过":停在底部时帧循环本来就停了 —— 正是这段没写的空档里,输出一恢复,
|
|
1697
|
-
* 官方那条 toBottom 就把一整块新内容瞬移到位(用户看到的就是"新内容一出现就被吸到底")。
|
|
1698
|
-
* 撒手(读者接管 / 切官方档 / 换会话 / 放行官方按钮)时所有权立刻归还,官方行为原样恢复。
|
|
2167
|
+
* 写层:官方/第三方对 scrollTop 的写入只有两种意图(不再有旧的"距离门槛"、不再有 800ms 让路窗)——
|
|
2168
|
+
* 目标 == floor ⇒ 贴底写:writer=us 时吸收(恢复我方位置,由我们的节奏落屏);writer=reader 时原样放行。
|
|
2169
|
+
* 目标 ≠ floor ⇒ 原样放行 + 重设基准(followWrote = 落地值;导航中同步重设兜底窗):**不交权、不改 goal**。
|
|
2170
|
+
* (Lead 裁决的规则 2 改写:release 只剩"读者真实输入"与"会话边界"两个入口,release("external") 取消。)
|
|
1699
2171
|
*/
|
|
1700
2172
|
let ownWrite = false; // 我方写入:setter 直接放行
|
|
1701
|
-
let snapOwn = false; // 贴底位置归不归我们管。不能用"最近 800ms 写过"当判据:停在底部时帧循环本来就停了,
|
|
1702
|
-
// 这一停就把接管判据晃没了——输出一停顿,下一个 chunk 到达时官方的瞬跳正好从空档漏过去。
|
|
1703
2173
|
let snapTaken = null; // 已接管的容器:{ el }
|
|
1704
|
-
let snapBlocked = 0; //
|
|
1705
|
-
|
|
2174
|
+
let snapBlocked = 0; // 诊断:被吸收(丢掉)的官方贴底写入次数
|
|
2175
|
+
let extWrites = 0; // 诊断:观测到的 scrollBy/scrollIntoView 写入次数
|
|
2176
|
+
let lastExtWrite = null; // 诊断:最近一笔外部写 {which, from, landed, floor, stack}
|
|
2177
|
+
let protoIntoView = null; // Element.prototype.scrollIntoView 的原始实现(装一次;dispose 还原)
|
|
2178
|
+
let bootAt = 0; // 启动豁免起点(接管时置为 performance.now())
|
|
2179
|
+
const backfillLanded = (sc, from) => { requestAnimationFrame(() => { if (lastExtWrite !== null && lastExtWrite.from === from) lastExtWrite.landed = Math.round(sc.scrollTop); }); };
|
|
2180
|
+
/** 物理像素对齐(Windows 150% 缩放 dpr=1.5:1 物理像素 = 1/dpr 布局像素;浏览器把 scrollTop 吸附到物理像素)。 */
|
|
2181
|
+
const pxDpr = () => { const v = Number(window.devicePixelRatio); return Number.isFinite(v) && v > 0 ? v : 1; };
|
|
2182
|
+
const snapPx = (v) => { const d = pxDpr(); return d === 1 ? v : Math.round(v * d) / d; };
|
|
2183
|
+
let writeCount = 0; // 诊断:真实落地的 scrollTop 写次数(trace 的 writes 用它,不再用"推进帧数")
|
|
2184
|
+
/** 唯一写点:只有它改 scrollTop;followWrote 只在写主容器时更新。
|
|
2185
|
+
* task-19:写入值先按物理像素对齐;对齐后与当前物理像素相同 ⇒ **不写**(余量留在 want-cur 上,下一帧自然累积)。 */
|
|
2186
|
+
const writeTally = {}; // task-22 诊断:本帧每笔写是谁发的(trace/probe 用)
|
|
2187
|
+
function writeTop(el, value, curHint, src) {
|
|
2188
|
+
const cur = curHint === undefined ? readTop(el) : curHint; // task-22:调用方已知当前值就别再读(每次读都会强制布局)
|
|
2189
|
+
const snapped = snapPx(value);
|
|
2190
|
+
if (src !== undefined) writeTally[src] = (writeTally[src] || 0) + 1;
|
|
2191
|
+
if (snapped === snapPx(cur)) return false; // 物理像素没变:空帧零成本(读回值可能未对齐,不能直接比数值)
|
|
1706
2192
|
ownWrite = true;
|
|
1707
|
-
try { el.scrollTop =
|
|
1708
|
-
|
|
2193
|
+
try { el.scrollTop = snapped; } finally { ownWrite = false; }
|
|
2194
|
+
writeCount += 1;
|
|
2195
|
+
moveCount += 1; // 只统计真的落地过的写(bootOver 判据同源)
|
|
2196
|
+
if (el === followScroller) followWrote = readTop(el); // 浏览器可能钳位:以落地值为准
|
|
2197
|
+
return true;
|
|
1709
2198
|
}
|
|
1710
|
-
const snapSetters = new WeakMap(); // el -> 我们装上去的那个访问器(认亲:旧实例 dispose 迟到会把它删掉)
|
|
1711
2199
|
function takeOverSnap(el) {
|
|
1712
|
-
|
|
1713
|
-
// 还指着同一个元素 —— 原来会永远不再重装(吸底过滤静默失效,症状正是"新内容一出现就被吸到底")。
|
|
1714
|
-
if (snapTaken !== null && snapTaken.el === el) {
|
|
1715
|
-
const cur = Object.getOwnPropertyDescriptor(el, "scrollTop");
|
|
1716
|
-
if (cur !== undefined && cur.set === snapSetters.get(el)) return;
|
|
1717
|
-
}
|
|
2200
|
+
if (snapTaken !== null && snapTaken.el === el) return;
|
|
1718
2201
|
const desc = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop");
|
|
1719
2202
|
if (!desc || typeof desc.get !== "function" || typeof desc.set !== "function") return;
|
|
1720
|
-
if (snapTaken !== null) { try { delete snapTaken.el.scrollTop; } catch { /* ignore */ } }
|
|
2203
|
+
if (snapTaken !== null) { try { delete snapTaken.el.scrollTop; delete snapTaken.el.scrollTo; delete snapTaken.el.scrollBy; } catch { /* ignore */ } }
|
|
1721
2204
|
const taken = { el };
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
2205
|
+
/** 贴底写(目标 == floor)的唯一判据:writer=us ⇒ 吸收(恢复我方位置);writer=reader ⇒ 放行。
|
|
2206
|
+
* 非贴底 JS 写统一交给 afterForeignWrite()(task-12:先落地 → 微任务倒回 → 设为幂等目标)。 */
|
|
2207
|
+
const absorbTop = (next) => {
|
|
2208
|
+
const tAbs = performance.now();
|
|
2209
|
+
try {
|
|
2210
|
+
if (ownWrite) return false;
|
|
2211
|
+
if (state.mode !== "fold") return false; // 官方档位:吸收器停手,官方吸附原样生效(task-15 追加)
|
|
2212
|
+
const fl = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
2213
|
+
if (fl - next > 1) return false; // 非贴底:交给 afterForeignWrite(它负责消费 navIntent)
|
|
2214
|
+
clearNavIntent(); // 贴底写也是一笔外来写:消费掉这个位
|
|
2215
|
+
// 除官方档外一律接管:读者态**吞掉不落地**(读者在哪就停在哪;写压根没落地 ⇒ 不需要回写 ⇒ 不破坏"读者态零程序化写")。
|
|
2216
|
+
// 例外:会话切换/容器变更(releaseReason === "reset")那一次是官方在"恢复位置",必须放行。
|
|
2217
|
+
if (writer === WRITER_READER && releaseReason !== "reset") { snapBlocked += 1; ensureTickLoop(); return true; }
|
|
2218
|
+
if (writer === WRITER_READER) return false;
|
|
2219
|
+
// goal=none(停在中途):官方吸附也不落地 —— "回尾"改由我们自己的判据(出现新 user 消息)负责。
|
|
2220
|
+
snapBlocked += 1;
|
|
2221
|
+
const keep = followWrote === null ? desc.get.call(el) : followWrote;
|
|
2222
|
+
if (desc.get.call(el) !== keep) { ownWrite = true; try { desc.set.call(el, keep); } finally { ownWrite = false; } }
|
|
2223
|
+
ensureTickLoop();
|
|
2224
|
+
return true; // 吸收:恢复我方位置,不让它落地
|
|
2225
|
+
} catch (err) { ioErrors.absorb += 1; ioErrors.last = "absorb: " + String((err && err.message) || err); return false; }
|
|
2226
|
+
finally { phaseAcc("absorb", tAbs); }
|
|
2227
|
+
};
|
|
2228
|
+
/** 落地后的统一处置(task-12:接管主容器**全部** JS 写)。规则:
|
|
2229
|
+
* ① 落点 == floor ⇒ 贴底写,维持现状(us 吸收 / reader 放行);
|
|
2230
|
+
* ② 落点 ≠ floor 且 writer===us ⇒ 这笔写算**我方目标**:先让它落地(官方写完立刻读回,必须读到真值),
|
|
2231
|
+
* 再在 queueMicrotask(paint 之前)把位置倒回旧值 + 设 goal={position, px},由唯一律平滑滑过去;
|
|
2232
|
+
* ③ writer===reader ⇒ 一律原样放行(已知限制:读者态下不接管外来定位,写进报告)。 */
|
|
2233
|
+
let claimSeq = 0; // 最近一次接管的序号:被更新的接管取代时不回倒
|
|
2234
|
+
const afterForeignWrite = (from, claimPx) => {
|
|
2235
|
+
const tAbs = performance.now();
|
|
2236
|
+
try {
|
|
2237
|
+
if (ownWrite) return;
|
|
2238
|
+
if (state.mode !== "fold") return; // 官方档位:不接管、不重设基准,全部原样放行(task-15 追加)
|
|
2239
|
+
const intent = hasNavIntent(); // 输入位:真实输入 ⇒ 导航
|
|
2240
|
+
clearNavIntent();
|
|
2241
|
+
const fl = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
2242
|
+
const landed = desc.get.call(el);
|
|
2243
|
+
const sh1 = el.scrollHeight;
|
|
2244
|
+
const st0 = baseSt < 0 ? from : baseSt; // 帧基线(没有则退化为写点前的值)
|
|
2245
|
+
const sh0 = baseSh < 0 ? sh1 : baseSh;
|
|
2246
|
+
const compensated = Math.abs((landed - st0) - (sh1 - sh0)) <= 2; // 几何位:位移能否被同期内容高度变化解释
|
|
2247
|
+
const nav = intent || !compensated; // 两者取或
|
|
2248
|
+
const px = Number.isFinite(claimPx) ? claimPx : landed;
|
|
2249
|
+
baseSt = landed; baseSh = sh1; // 收尾:这笔写之后的观察基线
|
|
2250
|
+
if (fl - px <= 1) { // ① 贴底写:只有 scrollTo/scrollBy 落到 floor 的路径会走到这里
|
|
2251
|
+
// (赋值型贴底写在 absorbTop 就被吞了)。全接管后这里也不再"采纳为回尾"——
|
|
2252
|
+
// 回尾只认一条:**出现你新发的一条消息**(见 scanStructureInner 的边沿判据)。
|
|
2253
|
+
if (writer === WRITER_READER && releaseReason !== "reset") {
|
|
2254
|
+
snapBlocked += 1;
|
|
2255
|
+
if (from !== landed) { ownWrite = true; try { desc.set.call(el, from); } finally { ownWrite = false; } } // 落到 floor 也吞:恢复到读者位置
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
if (writer === WRITER_READER) { passedOfficial += 1; return; } // reset:让官方恢复到位(计数照旧记"放行")
|
|
2259
|
+
snapBlocked += 1;
|
|
2260
|
+
const keep = followWrote === null ? landed : followWrote;
|
|
2261
|
+
if (landed !== keep) { ownWrite = true; try { desc.set.call(el, keep); } finally { ownWrite = false; } }
|
|
2262
|
+
ensureTickLoop();
|
|
2263
|
+
return;
|
|
2264
|
+
}
|
|
2265
|
+
foreignLanded += 1;
|
|
2266
|
+
lastForeign = { which: "write", from: followWrote === null ? null : Math.round(followWrote), to: Math.round(px), floor: Math.round(fl), at: Math.round(performance.now()) };
|
|
2267
|
+
if (writer === WRITER_READER) { // ③ 读者:原样放行
|
|
2268
|
+
if (el === followScroller) followWrote = landed;
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
if (!nav) { // ④ 保位/对齐(无输入且位移可由布局解释):task-6 裁决 —— 放行 + 重设基准,不接管、不改 goal
|
|
2272
|
+
if (el === followScroller) followWrote = landed;
|
|
2273
|
+
if (navActive && goalKind === "position") goalPos += (landed - from); // 远跳期间官方分页 align:目标随视口一起平移,避免拉锯
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
if (Math.abs(px - from) <= 0.5) return; // 有导航意图但没动(如 scrollBy(0,0)):无可接管
|
|
2277
|
+
if (navActive && goalKind === "element") { // S1-1:元素滑行(回到提问处/置顶条)期间,外来写不得改目标
|
|
2278
|
+
if (el === followScroller) followWrote = landed; // 放行 + 上面已重设基线:目标不动,滑行继续
|
|
2279
|
+
resetVel(mainVel); tailWant = -1; // task-11:落点是外来跳变 ⇒ 作废旧方向的 approach 速度态(否则方向翻转后会零步停机)
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
const seq = ++claimSeq; // ② writer=us:已落地 ⇒ 微任务倒回 + 设我方目标
|
|
2283
|
+
queueMicrotask(() => {
|
|
2284
|
+
try {
|
|
2285
|
+
if (disposed || snapTaken === null || snapTaken.el !== el) return; // 收摊/换容器:不接管
|
|
2286
|
+
if (seq !== claimSeq) return; // 被更新的一笔接管取代
|
|
2287
|
+
if (writer !== WRITER_US) return; // 期间读者接管 / 官方档位:不接管
|
|
2288
|
+
ownWrite = true;
|
|
2289
|
+
try { desc.set.call(el, from); } finally { ownWrite = false; } // 同帧(paint 前)倒回旧值
|
|
2290
|
+
if (el === followScroller) followWrote = from;
|
|
2291
|
+
navigate({ kind: "position", px }); // 取回写权 + 一次性导航到落点
|
|
2292
|
+
} catch (err) { ioErrors.absorb += 1; ioErrors.last = "claim: " + String((err && err.message) || err); }
|
|
2293
|
+
});
|
|
2294
|
+
} catch (err) { ioErrors.absorb += 1; ioErrors.last = "settle: " + String((err && err.message) || err); }
|
|
2295
|
+
finally { phaseAcc("absorb", tAbs); }
|
|
1737
2296
|
};
|
|
2297
|
+
const nativeScrollTo = el.scrollTo;
|
|
1738
2298
|
Object.defineProperty(el, "scrollTop", {
|
|
1739
2299
|
configurable: true,
|
|
1740
2300
|
get() { return desc.get.call(el); },
|
|
1741
|
-
set
|
|
2301
|
+
set(value) {
|
|
2302
|
+
if (ownWrite) { desc.set.call(el, value); return; } // 我方写入:直接落地(writeTop 随后回读基准)
|
|
2303
|
+
const from = desc.get.call(el);
|
|
2304
|
+
if (absorbTop(value)) return; // 贴底写 + us:吸收,不落地
|
|
2305
|
+
desc.set.call(el, value); // 先落地:官方写完立刻读回,必须读到真值(不许吞写)
|
|
2306
|
+
afterForeignWrite(from);
|
|
2307
|
+
},
|
|
1742
2308
|
});
|
|
1743
|
-
|
|
2309
|
+
try {
|
|
2310
|
+
// 关键:**不要**用固定形参 (a,b) —— 那会把 1 参的 scrollTo(options) 变成 2 参调用,
|
|
2311
|
+
// WebIDL 会因此选中 scrollTo(x,y) 重载、把 options 对象转成数字 0(Y 的「滑到 0」回归根因)。
|
|
2312
|
+
el.scrollTo = function () {
|
|
2313
|
+
if (arguments.length) {
|
|
2314
|
+
const a = arguments[0];
|
|
2315
|
+
const next = (a && typeof a === "object") ? Number(a.top) : Number(arguments[1]); // (x,y) 形态里纵向是 y
|
|
2316
|
+
if (Number.isFinite(next)) {
|
|
2317
|
+
const from = desc.get.call(el);
|
|
2318
|
+
if (absorbTop(next)) return;
|
|
2319
|
+
const r = Reflect.apply(nativeScrollTo, el, arguments);
|
|
2320
|
+
if (!ownWrite) afterForeignWrite(from, next);
|
|
2321
|
+
return r;
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
const from = desc.get.call(el);
|
|
2325
|
+
const r = Reflect.apply(nativeScrollTo, el, arguments);
|
|
2326
|
+
if (!ownWrite) afterForeignWrite(from);
|
|
2327
|
+
return r;
|
|
2328
|
+
};
|
|
2329
|
+
const noteExternal = (which, before, claimPx) => {
|
|
2330
|
+
if (ownWrite) return;
|
|
2331
|
+
extWrites += 1;
|
|
2332
|
+
lastExtWrite = { which: which, from: before === null ? null : Math.round(before), landed: null, floor: Math.round(Math.max(0, el.scrollHeight - el.clientHeight)), stack: (() => { try { return String(new Error().stack || "").split("\n")[2] || ""; } catch { return ""; } })() };
|
|
2333
|
+
afterForeignWrite(before, claimPx);
|
|
2334
|
+
};
|
|
2335
|
+
const nativeScrollBy = el.scrollBy;
|
|
2336
|
+
try {
|
|
2337
|
+
el.scrollBy = function () {
|
|
2338
|
+
const before = desc.get.call(el);
|
|
2339
|
+
const r = Reflect.apply(nativeScrollBy, el, arguments);
|
|
2340
|
+
noteExternal("scrollBy", before);
|
|
2341
|
+
backfillLanded(el, before === null ? null : Math.round(before));
|
|
2342
|
+
return r;
|
|
2343
|
+
};
|
|
2344
|
+
} catch { /* ignore */ }
|
|
2345
|
+
if (protoIntoView === null) { // 只装一次:重复接管不叠加
|
|
2346
|
+
protoIntoView = Element.prototype.scrollIntoView;
|
|
2347
|
+
try {
|
|
2348
|
+
Element.prototype.scrollIntoView = function () {
|
|
2349
|
+
const sc = mainScroller();
|
|
2350
|
+
if (sc === null || !sc.contains(this) || ownWrite) return Reflect.apply(protoIntoView, this, arguments); // 非主容器内的调用:原样转发、不干预
|
|
2351
|
+
const arg = arguments.length ? arguments[0] : undefined;
|
|
2352
|
+
const block = arg && typeof arg === "object" ? arg.block : "start"; // boolean/缺省 = start(与浏览器一致)
|
|
2353
|
+
const target = scrollIntoViewTarget(sc, this, block); // 先自己算目标(要害 2),再走"落地→接管"
|
|
2354
|
+
const before = sc.scrollTop;
|
|
2355
|
+
const r = Reflect.apply(protoIntoView, this, arguments);
|
|
2356
|
+
noteExternal("scrollIntoView", before, target);
|
|
2357
|
+
backfillLanded(sc, Math.round(before));
|
|
2358
|
+
return r;
|
|
2359
|
+
};
|
|
2360
|
+
} catch { protoIntoView = null; }
|
|
2361
|
+
}
|
|
2362
|
+
} catch { /* ignore */ }
|
|
1744
2363
|
snapTaken = taken;
|
|
1745
2364
|
}
|
|
1746
|
-
function guardSnap() {
|
|
1747
|
-
// 官方档 = 插件整体撒手:observer 还挂着,没有这道门就会在官方自己吸底之后把位置拽回我们旧的 followWrote。
|
|
1748
|
-
if (state.mode !== "fold") return;
|
|
1749
|
-
const el = followScroller;
|
|
1750
|
-
if (!el || (!followOn && glide === null) || followWrote === null) return;
|
|
1751
|
-
const floor = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
1752
|
-
const back = floor - followWrote;
|
|
1753
|
-
if (back <= 1 || back > SNAP_UNDO_MAX) return;
|
|
1754
|
-
if (el.scrollTop < floor - 1) return; // 没被推到贴底:不是吸底
|
|
1755
|
-
writeTop(el, followWrote); // 撤销,仍在 paint 之前
|
|
1756
|
-
guardCount += 1;
|
|
1757
|
-
}
|
|
1758
|
-
function guardObserve(flow) {
|
|
1759
|
-
if (!flow) return;
|
|
1760
|
-
// 缓存:这个函数每帧都会被 mainScroller() 调到,querySelector 在长会话里要走完整棵子树,不能每帧查
|
|
1761
|
-
if (flow === guardFlow && guardComposer !== undefined && (guardComposer === null || guardComposer.isConnected)) return;
|
|
1762
|
-
const composer = followScroller === null ? null : followScroller.querySelector("[data-composer-seat]");
|
|
1763
|
-
if (flow === guardFlow && composer === guardComposer) return;
|
|
1764
|
-
if (guardRO === null) { try { guardRO = new ResizeObserver(guardSnap); } catch { guardRO = false; return; } }
|
|
1765
|
-
if (guardRO === false) return;
|
|
1766
|
-
guardRO.disconnect();
|
|
1767
|
-
guardFlow = flow;
|
|
1768
|
-
guardComposer = composer;
|
|
1769
|
-
guardRO.observe(flow);
|
|
1770
|
-
if (composer !== null) guardRO.observe(composer); // 官方那条 RO 也 observe 它:composer 一变它就会吸底
|
|
1771
|
-
}
|
|
1772
2365
|
|
|
1773
2366
|
/* -------------------------------------------------------------- 小窗打标 -- */
|
|
1774
2367
|
/** 某一轮里最后一个匹配元素(运行中"最新那一个"就是它)。 */
|
|
@@ -1785,6 +2378,17 @@ function syncWindows(flow, running, collected, bodies, heights) {
|
|
|
1785
2378
|
const now = Date.now();
|
|
1786
2379
|
// ① 只有"正文已挂载"的块才需要小窗打标 / 揭示动画 / 收起拦截(收起只可能发生在展开着的块上)。
|
|
1787
2380
|
// 扫描开始时一次遍历就拿到这些块,而不是对每个卡片各查一次子树——会话越长,这笔账越贵。
|
|
2381
|
+
// H19b:提问卡(LLM 提问)小窗强制展开 —— 仅"我们第一次看到它"时点一次;尊重用户之后的手动折叠。
|
|
2382
|
+
// 成本契约:合成点击会触发 React 同步提交(真机实测一次扫描 +30.4ms)⇒ **扫描只登记,点击挪到下一帧**;
|
|
2383
|
+
// 去重按「轮次」而不是卡片元素(React 重建卡片时元素变了,按元素记会重复点)。
|
|
2384
|
+
try {
|
|
2385
|
+
for (const card of flow.querySelectorAll('[data-tool="ask_user_question"],[data-tool="request_user_input"]')) {
|
|
2386
|
+
if (qCardHandled(card) || card.dataset.dshsfKeepOpen === "1" || qCardQueued.has(card)) continue;
|
|
2387
|
+
qCardQueued.add(card);
|
|
2388
|
+
qCardQueue.push(card);
|
|
2389
|
+
}
|
|
2390
|
+
if (qCardQueue.length > 0 && qCardRaf === 0 && typeof requestAnimationFrame === "function") qCardRaf = requestAnimationFrame(runQCardQueue);
|
|
2391
|
+
} catch (err) { scanErrors += 1; lastScanError = String((err && err.message) || err).slice(0, 120); } // F4:异常计数进诊断(不空吞)
|
|
1788
2392
|
const wiredTools = new Set();
|
|
1789
2393
|
const windowQueue = []; // 先只读、后集中写:量一个写一个会让下一次量强制重排(布局抖动)
|
|
1790
2394
|
for (const body of bodies || flow.querySelectorAll(BODY_ANY)) {
|
|
@@ -1806,10 +2410,7 @@ function syncWindows(flow, running, collected, bodies, heights) {
|
|
|
1806
2410
|
wireDisclosure(root, toolOpen, TOOL_BODY, (el) => { el.dataset.dshsfKeepClosed = "1"; delete el.dataset.dshsfToolAuto; });
|
|
1807
2411
|
}
|
|
1808
2412
|
for (const [body, height] of windowQueue) tagThinkWindow(body, height); // 集中写:这一轮里不再夹读取
|
|
1809
|
-
//
|
|
1810
|
-
for (const root of autoThinks) if (!root.isConnected) autoThinks.delete(root);
|
|
1811
|
-
for (const root of autoTools) if (!root.isConnected) autoTools.delete(root);
|
|
1812
|
-
if (!running) { sparks.focus(null); return; } // 跑完就不再磨金属:火星自然熄灭后画布一并摘掉
|
|
2413
|
+
if (!running) { activeWindow = null; sparks.focus(null); return; } // 跑完就不再磨金属:火星熄灭 + 跟尾对象清空(旧窗不再吸附)
|
|
1813
2414
|
// ② 运行中只处理"本轮":自动展开最新那一个、被取代的我方窗口延迟折回。
|
|
1814
2415
|
// 原来是扫整个会话的思考/工具根,会话一长就是几百次 closest + 子树查询。
|
|
1815
2416
|
const lastTurn = collected ? collected.lastTurn : collectTurns().lastTurn;
|
|
@@ -1827,21 +2428,21 @@ function syncWindows(flow, running, collected, bodies, heights) {
|
|
|
1827
2428
|
if (hit.length) newestTool = hit[hit.length - 1];
|
|
1828
2429
|
}
|
|
1829
2430
|
}
|
|
1830
|
-
|
|
2431
|
+
// 合成点击挪到下一帧(与提问卡同一手法):React 的同步提交不再算进扫描成本。
|
|
1831
2432
|
if (newestThink !== null && !newestThink.closest("[data-dshsf-folded]") && state.autoExpandReasoning !== false
|
|
1832
|
-
&& !thinkOpen(newestThink) &&
|
|
1833
|
-
|
|
1834
|
-
//
|
|
1835
|
-
// 再排下去就是"屏幕上什么都没变也每 300ms 全量重扫"。
|
|
1836
|
-
else if (newestThink.dataset.dshsfKeepOpen !== "1" && Number(newestThink.dataset.dshsfTries || "0") < 8) busyWork = true;
|
|
2433
|
+
&& !thinkOpen(newestThink) && inViewport(newestThink)) {
|
|
2434
|
+
queueAutoOpen("think", newestThink);
|
|
2435
|
+
busyWork = true; // 还没真的打开:让扫描继续推进(重试节流由 openThink 自己的 260ms 管)
|
|
1837
2436
|
}
|
|
1838
2437
|
if (newestTool !== null && !newestTool.closest("[data-dshsf-folded]") && state.autoExpandTools !== false
|
|
1839
|
-
&& !toolOpen(newestTool) &&
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
}
|
|
1843
|
-
//
|
|
1844
|
-
|
|
2438
|
+
&& !toolOpen(newestTool) && inViewport(newestTool)) {
|
|
2439
|
+
queueAutoOpen("tool", newestTool);
|
|
2440
|
+
busyWork = true;
|
|
2441
|
+
}
|
|
2442
|
+
// 火星与跟尾同源:本轮最新的思考正文(没展开就是 null)。activeWindow 由这里维护,与 sparks 开关无关。
|
|
2443
|
+
const newestBody = newestThink ? newestThink.querySelector(THINK_BODY) : null;
|
|
2444
|
+
if (activeWindow !== newestBody) { activeWindow = newestBody; if (newestBody !== null) ensureTickLoop(); } // 活动窗刚出现:排一帧做首屏贴底
|
|
2445
|
+
sparks.focus(newestBody);
|
|
1845
2446
|
// 被取代的我方窗口:只遍历"我们自己开过的那几个"(通常 1~3 个),不再遍历本轮所有思考/工具根
|
|
1846
2447
|
for (const root of autoThinks) {
|
|
1847
2448
|
if (!root.isConnected) { autoThinks.delete(root); continue; }
|
|
@@ -1870,69 +2471,36 @@ function syncContextRows(flow, bodies, heights) {
|
|
|
1870
2471
|
}
|
|
1871
2472
|
}
|
|
1872
2473
|
|
|
1873
|
-
/*
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
for (const el of row.querySelectorAll("[data-dshsf-textwindow]")) {
|
|
2474
|
+
/* ------------------------------------------------ 穿插正文(保留 + 分隔线) -- */
|
|
2475
|
+
/** 升级/热重载残留:容器级一次清扫旧的「穿插正文限高小窗」(已不再套窗)。 */
|
|
2476
|
+
function clearTextWindows(scope) {
|
|
2477
|
+
for (const el of scope.querySelectorAll("[data-dshsf-textwindow]")) {
|
|
1878
2478
|
el.removeAttribute("data-dshsf-textwindow");
|
|
1879
2479
|
el.style.maxHeight = "";
|
|
1880
2480
|
el.style.overflow = "";
|
|
1881
2481
|
liveWindows.delete(el);
|
|
1882
2482
|
}
|
|
2483
|
+
for (const row of scope.querySelectorAll("[data-dshsf-tw]")) row.removeAttribute("data-dshsf-tw");
|
|
1883
2484
|
}
|
|
1884
|
-
/**
|
|
1885
|
-
* 只用于诊断(probe().textWindowDetail):确认某个残留的正文壳里是不是过程块。 */
|
|
1886
|
-
const PROCESS_BLOCK = THINK + ", " + TOOL_CARD + ", [data-context-injection-body], [data-turn-process-inline]";
|
|
1887
|
-
/** 撤销一个块的窗标记:短块不套壳时用(阈值由紧到松变化后,旧壳也要拆掉)。 */
|
|
1888
|
-
function unwindowText(el) {
|
|
1889
|
-
if (el.dataset.dshsfTextwindow !== "1") return;
|
|
1890
|
-
el.removeAttribute("data-dshsf-textwindow");
|
|
1891
|
-
el.style.maxHeight = "";
|
|
1892
|
-
el.style.overflow = "";
|
|
1893
|
-
liveWindows.delete(el);
|
|
1894
|
-
}
|
|
1895
|
-
/** 折叠后仍保留的穿插正文行进限高小窗(答案行正文不套窗,只用一条线跟它分开)。 */
|
|
2485
|
+
/** 折叠后仍保留的穿插正文行:不套窗,只用答案行上的一条横线跟正式回答分开。 */
|
|
1896
2486
|
function syncTextWindows(flow, collected) {
|
|
2487
|
+
clearTextWindows(flow || document); // 一次容器级清扫:旧的每行早退依赖 data-dshsf-tw,而它已无写入点 ⇒ 已失效
|
|
1897
2488
|
const keepText = state.keepInterleavedText !== false;
|
|
1898
2489
|
for (const turnRows of (collected ? collected.turns : collectTurns().turns).values()) {
|
|
1899
|
-
const
|
|
1900
|
-
|
|
2490
|
+
const answerRow = answerRowOf(turnRows); // task-20 A11:复用 syncTurns 已算好的按轮 memo(answerRowCache)
|
|
2491
|
+
if (!keepText || answerRow === null) { // 原语义保持:不保留穿插正文 ⇒ 该轮 answer-sep 一律清掉
|
|
2492
|
+
if (answerRow) answerRow.removeAttribute("data-dshsf-answer-sep");
|
|
2493
|
+
continue;
|
|
2494
|
+
}
|
|
1901
2495
|
let keptText = false;
|
|
1902
|
-
for (const row of
|
|
1903
|
-
if (
|
|
1904
|
-
|
|
1905
|
-
continue;
|
|
1906
|
-
}
|
|
1907
|
-
const body = assistantBody(row);
|
|
1908
|
-
if (!body) continue;
|
|
1909
|
-
keptText = true;
|
|
1910
|
-
// 穿插正文**不套窗**:给官方 markdown 容器外套一层限高/内滚,会把它内部的布局压坏 —— 实测表现为
|
|
1911
|
-
// "文字被切一半"和"整块空白玻璃壳"。保留正文、直接自然排版才是最紧凑的。
|
|
1912
|
-
// 这里同时把历史版本留下的壳拆掉(unwindowText 幂等)。
|
|
1913
|
-
for (const child of Array.from(body.children)) unwindowText(child);
|
|
1914
|
-
row.removeAttribute("data-dshsf-tw");
|
|
2496
|
+
for (const row of assistantRowsOf(turnRows)) { // task-20 A11:复用同一份 assistant 行列表;只判断"有没有"
|
|
2497
|
+
if (row === answerRow || row.hasAttribute("data-dshsf-folded") || !hasText(row)) continue;
|
|
2498
|
+
if (assistantBody(row)) { keptText = true; break; } // 穿插正文不再套限高小窗:只靠 answer-sep 横线与正式回答分开
|
|
1915
2499
|
}
|
|
1916
|
-
|
|
2500
|
+
answerRow.toggleAttribute("data-dshsf-answer-sep", keptText); // 有穿插正文才画线
|
|
1917
2501
|
}
|
|
1918
2502
|
}
|
|
1919
2503
|
|
|
1920
|
-
/* ---------------------------------------------------- 官方「对话显示」行 -- */
|
|
1921
|
-
let nativeRowSeen = null; // 上次标记过的那条官方行:还连着且标记还在,就不必每扫描全文档查按钮
|
|
1922
|
-
function syncNativeRow() {
|
|
1923
|
-
const ours = document.querySelector(".dshsf-select");
|
|
1924
|
-
if (ours === null && document.querySelector("[data-dshsf-native-row]") === null) { nativeRowSeen = null; return; }
|
|
1925
|
-
if (ours !== null && nativeRowSeen !== null && nativeRowSeen.isConnected && nativeRowSeen.hasAttribute("data-dshsf-native-row")) return;
|
|
1926
|
-
for (const button of document.querySelectorAll('button[aria-haspopup="menu"]')) {
|
|
1927
|
-
if (ours !== null && ours.contains(button)) continue; // 别把自己的下拉当成官方那一行
|
|
1928
|
-
const text = (button.textContent || "").trim();
|
|
1929
|
-
if (!/^(简洁|标准|详细|完全展开|紧凑|Normal|Compact|Standard|Detailed|Verbose)(\s*[▾⌄v])?$/.test(text)) continue;
|
|
1930
|
-
const row = button.closest('[class*="_row"]');
|
|
1931
|
-
if (!row) continue;
|
|
1932
|
-
if (ours !== null) { row.setAttribute("data-dshsf-native-row", ""); nativeRowSeen = row; }
|
|
1933
|
-
else { row.removeAttribute("data-dshsf-native-row"); nativeRowSeen = null; }
|
|
1934
|
-
}
|
|
1935
|
-
}
|
|
1936
2504
|
|
|
1937
2505
|
/* ------------------------------------------------------ 火星特效(独立) -- */
|
|
1938
2506
|
/**
|
|
@@ -1957,7 +2525,7 @@ const SPARK_LIT_MAX = 2.4; // 亮度曲线满值(画的时候映射到描
|
|
|
1957
2525
|
const SPARK_LIT_LO = 0.35; // 曲线起点:u(速度/基准)低于它基本不发光
|
|
1958
2526
|
const SPARK_LIT_HI = 5; // 曲线顶端:u 到这里算拉满
|
|
1959
2527
|
const SPARK_LIT_CURVE = 0.6; // 曲线形状:<1 = 低段抬起来(压缩),1 = 线性
|
|
1960
|
-
const SPARK_EMIT_MAX = 1.8; //
|
|
2528
|
+
const SPARK_EMIT_MAX = 1.8; // 满强度发射率倍率(恒定发射率:密度观感不随输出速度变化)
|
|
1961
2529
|
const SPARK_EMIT_BASE = 0.55; // 最慢时的发射率占比:速度只做温和的疏密变化,慢输出也不至于没火星
|
|
1962
2530
|
const SPARK_REF_MIN = 0.35; // 基准速度下限(px/16ms):输出很慢时也保留灵敏度
|
|
1963
2531
|
const SPARK_REF_MAX = 2.5; // 基准速度上限:到顶后不再按输出速度归一化 → 亮度继续随绝对速度涨
|
|
@@ -1990,7 +2558,6 @@ function createSparkFx() {
|
|
|
1990
2558
|
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
1991
2559
|
const tone = () => { core = rgb.map((c) => Math.round(c + (255 - c) * 0.75)).join(","); };
|
|
1992
2560
|
tone();
|
|
1993
|
-
// 注意:这一句**不能**缓存成一次性结果 —— 系统"减弱动态效果"中途变化必须立刻生效(test/spark.mjs 有断言)。
|
|
1994
2561
|
const reduced = () => { try { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch { return false; } };
|
|
1995
2562
|
const enabled = () => opt.on && !reduced();
|
|
1996
2563
|
const enabledForge = () => enabled() && opt.forge;
|
|
@@ -2070,7 +2637,7 @@ function stats() {
|
|
|
2070
2637
|
* flow = EMA(|ΔscrollHeight| / dt16) 当前输出速度(内容长得多快,τ=200ms)
|
|
2071
2638
|
* ref = clamp(flow*0.6, 0.35, 2.5) **基准速度只在小速度侧跟着输出速度走**:
|
|
2072
2639
|
* 输出慢 → 基准小,一点点滚动就看得出;输出快到 ref 封顶后就不再归一化,
|
|
2073
|
-
*
|
|
2640
|
+
* 亮度继续随绝对速度上涨(快输出更烫)。若 ref 与输出速度等比(上限 6),等于把速度
|
|
2074
2641
|
* 信息除掉了 —— 快慢都稳稳顶在天花板上,这才是"看不出随速度变化"的原因。
|
|
2075
2642
|
* u = speed / ref 1 ≈ 恰好跟上输出
|
|
2076
2643
|
* k = clamp((u-0.35)/(5-0.35),0,1)^0.6 压缩曲线:低段抬起来、高段放缓,单调、无台阶、无硬顶
|
|
@@ -2411,10 +2978,8 @@ const sparks = createSparkFx();
|
|
|
2411
2978
|
/* ----------------------------------------------------------- 控制器 -- */
|
|
2412
2979
|
let scheduled = false;
|
|
2413
2980
|
let scanTimer = 0; // schedule 里那个补扫定时器
|
|
2414
|
-
let readyTimer = 0; // [streamfold] ready 那条日志的句柄:卸载后不该再打
|
|
2415
2981
|
let lastScan = 0;
|
|
2416
2982
|
let allowAnim = false; // 首屏不做动效(几十个过渡是加载卡顿主因)
|
|
2417
|
-
let stoodDown = false; // 官方档位下是否已撒手(重活只做一次)
|
|
2418
2983
|
let observer = null;
|
|
2419
2984
|
const SCAN_INTERVAL = 300;
|
|
2420
2985
|
|
|
@@ -2422,7 +2987,7 @@ function scanStructure() {
|
|
|
2422
2987
|
if (disposed) return;
|
|
2423
2988
|
try { scanStructureInner(); }
|
|
2424
2989
|
catch (error) { cost.lastError = String((error && error.message) || error); throw error; } // 扫描里抛错要看得见,而不是静默少记一次
|
|
2425
|
-
finally {
|
|
2990
|
+
finally { scanRows = null; scanRunningMark = null; scanRunningKnown = false; } // task-19 A9:扫描作用域结束(含抛错)
|
|
2426
2991
|
}
|
|
2427
2992
|
function scanStructureInner() {
|
|
2428
2993
|
const t0 = performance.now();
|
|
@@ -2435,32 +3000,42 @@ function scanStructureInner() {
|
|
|
2435
3000
|
cost.phases.hasText = 0;
|
|
2436
3001
|
cost.phases.cards = 0;
|
|
2437
3002
|
answerRowCache = new Map(); // 每次扫描重算:行还在变(流式),不能跨扫描复用
|
|
3003
|
+
// task-19 A9:扫描开头把行列表与 RUNNING 标记各查一次;之后 collectTurns/userRows/pinSource 全复用这一份
|
|
3004
|
+
scanRows = null; scanRunningKnown = true; scanRunningMark = null;
|
|
3005
|
+
scanRows = chatRows();
|
|
3006
|
+
scanRunningMark = (chatScope() || document).querySelector(RUNNING);
|
|
2438
3007
|
// eslint-disable-next-line no-use-before-define
|
|
2439
3008
|
rootVars();
|
|
2440
3009
|
syncSession(); // 换会话先把串号状态清掉(轮号复用会让运行中的轮次被折)
|
|
2441
3010
|
const flow = chatScope();
|
|
2442
3011
|
if (state.mode !== "fold") {
|
|
2443
|
-
//
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
3012
|
+
// 顺序契约:先展开/删条,再放手。unfoldAll / clearChips 是按 data-dshsf-row / data-dshsf-chip 找目标的,
|
|
3013
|
+
// 先 releaseOurVisuals 会把这两个标记先摘掉 ⇒ 折叠行展不开、折叠条删不掉,只有刷新页面(React 重渲染)才恢复。
|
|
3014
|
+
if (flow) { unfoldAll(flow); clearChips(flow); }
|
|
3015
|
+
releaseOurVisuals(); // task-30:完全放手 —— 连小窗正文的内联限高/变量/数据集与残余标记一起清
|
|
3016
|
+
activeWindow = null; // 官方档:跟尾对象同样清空
|
|
3017
|
+
sparks.focus(null);
|
|
3018
|
+
sparks.forge(null);
|
|
3019
|
+
pinRows = pinSource(chatRows()); // H19f:native 分支此前只收 user 行(缺回答)
|
|
3020
|
+
if (pinPick >= pinRows.length) pinPick = -1;
|
|
2449
3021
|
return;
|
|
2450
3022
|
}
|
|
2451
|
-
stoodDown = false;
|
|
2452
3023
|
if (!flow) return;
|
|
2453
3024
|
// 运行态带粘性:标记一消失就当成跑完,会在步骤/工具交接的空档里折一次(用户看到"运行中全折起来")。
|
|
2454
3025
|
// 只有连续 RUN_GRACE_MS 看不到任何运行标记,才算这一轮结束 —— 收尾的折叠只做这一次。
|
|
2455
3026
|
const stamp = performance.now();
|
|
2456
3027
|
const collected = collectTurns(); // 提前取一次:运行判定要用最后一轮
|
|
2457
|
-
//
|
|
2458
|
-
//
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
3028
|
+
// 回尾:**出现你新发的一条消息** ⇒ 滑回尾部(内容侧边沿判据,不是时间窗)。
|
|
3029
|
+
// 官方自己就是这么干的(ui-chat:4961 ownInput ⇒ followTail),我们接管吸附后这一条必须自己认。
|
|
3030
|
+
{
|
|
3031
|
+
let newestUser = null;
|
|
3032
|
+
for (let i = collected.rows.length - 1; i >= 0; i -= 1) { if (rowKind(collected.rows[i]) === "user") { newestUser = collected.rows[i]; break; } }
|
|
3033
|
+
const uKey = newestUser === null ? null : (newestUser.getAttribute("data-chat-flow-key") || (newestUser.getAttribute("data-chat-turn") || "") + ":" + String(newestUser.textContent || "").length);
|
|
3034
|
+
if (uKey !== null && lastUserKey !== null && uKey !== lastUserKey && state.returnToTail !== false
|
|
3035
|
+
&& !(writer === WRITER_US && goalKind === "tail")) navigate({ kind: "tail" });
|
|
3036
|
+
if (uKey !== null) lastUserKey = uKey;
|
|
3037
|
+
}
|
|
3038
|
+
const markRunning = scanRunningMark !== null; // task-19 A9:复用扫描开头那次 RUNNING 查询(flow === chatScope())
|
|
2464
3039
|
if (markRunning) lastRunningAt = stamp;
|
|
2465
3040
|
else if (runningSticky) {
|
|
2466
3041
|
// 标记已经没了、粘性还在:跑完那一刻之后没有 DOM 变化就不会再有扫描,
|
|
@@ -2473,10 +3048,19 @@ function scanStructureInner() {
|
|
|
2473
3048
|
// (模型在两步之间、工具在跑、刚提交还没出字,这些空档都不该被当成"跑完")
|
|
2474
3049
|
const openLastTurn = collected.lastTurn !== null && !turnClosed(collected.lastTurn);
|
|
2475
3050
|
const running = markRunning || runningSticky || openLastTurn;
|
|
2476
|
-
if (
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
3051
|
+
if (wasRunning && !running) { // 刚跑完这一轮:先起滑,本轮折叠压到滑完(settleTurn)
|
|
3052
|
+
const turn = collected.runningTurn === null || collected.runningTurn === undefined ? collected.lastTurn : collected.runningTurn;
|
|
3053
|
+
if (settleToPrompt(flow)) { settleTurn = turn; settleUntil = performance.now() + SETTLE_MAX_MS; }
|
|
3054
|
+
if (settleTurn !== null && settleTurn === turn) { // S2-1:刚起滑 ⇒ 把兜底预算换成与 navBudget 同源(长滑行不得被 1400ms 切断)
|
|
3055
|
+
const sc = mainScroller();
|
|
3056
|
+
let dist = 0;
|
|
3057
|
+
if (sc !== null && goalEl !== null && goalEl.isConnected) {
|
|
3058
|
+
const floor = Math.max(0, sc.scrollHeight - sc.clientHeight);
|
|
3059
|
+
dist = Math.abs(Math.max(0, Math.min(floor, viewTop(sc, goalEl) - SETTLE_PAD)) - sc.scrollTop);
|
|
3060
|
+
}
|
|
3061
|
+
settleUntil = performance.now() + Math.max(SETTLE_MAX_MS, navBudget(dist));
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
2480
3064
|
wasRunning = running;
|
|
2481
3065
|
|
|
2482
3066
|
busyWork = false;
|
|
@@ -2494,12 +3078,18 @@ function scanStructureInner() {
|
|
|
2494
3078
|
heights.set(body, body.getBoundingClientRect().height);
|
|
2495
3079
|
}
|
|
2496
3080
|
}
|
|
2497
|
-
|
|
3081
|
+
// H19c:置顶条 = 用户提问行(照旧,不替换)∪ ——仅当「提问不入折叠」开启时—— 提问的**用户回答**
|
|
3082
|
+
// 按**真实 flow kind** 匹配:reply 行的行级标志是 data-chat-flow-kind="question-reply"(本文件 976 行既有判定用的就是它);
|
|
3083
|
+
// [data-question-reply] 只是提问卡**内部**的气泡,卡本身不是 collected.rows 里的行 ⇒ 之前用它匹配必然为空。
|
|
3084
|
+
pinRows = pinSource(collected.rows); // H19f:与 native 分支共用同一个 pinSource()
|
|
2498
3085
|
if (pinPick >= pinRows.length) pinPick = -1;
|
|
2499
3086
|
cost.phases.collect = +(performance.now() - tC).toFixed(2);
|
|
2500
3087
|
const turnKeys = Array.from(collected.turns.keys());
|
|
2501
3088
|
hotRows = new WeakSet();
|
|
2502
|
-
|
|
3089
|
+
// A8:热集 = 最近 3 轮 ∪ 运行中的轮(更老的轮次靠 observer 定向失效,不靠热集)
|
|
3090
|
+
for (const key of new Set([...turnKeys.slice(-3), collected.runningTurn].filter((k) => k !== null && k !== undefined))) {
|
|
3091
|
+
for (const row of collected.turns.get(key) || []) hotRows.add(row);
|
|
3092
|
+
}
|
|
2503
3093
|
let t1 = performance.now();
|
|
2504
3094
|
syncWindows(flow, running, collected, bodies, heights);
|
|
2505
3095
|
cost.phases.windows = +(performance.now() - t1).toFixed(2);
|
|
@@ -2514,9 +3104,9 @@ function scanStructureInner() {
|
|
|
2514
3104
|
cost.phases.ctx = +(performance.now() - t1).toFixed(2);
|
|
2515
3105
|
const ms = performance.now() - t0;
|
|
2516
3106
|
cost.lastMs = +ms.toFixed(2);
|
|
2517
|
-
cost.counts.layouts = layoutReads;
|
|
2518
3107
|
cost.scanMs += ms;
|
|
2519
3108
|
scanSamples.push(ms);
|
|
3109
|
+
scanMsSinceFrame += ms; scanCountSinceFrame += 1;
|
|
2520
3110
|
if (scanSamples.length > SCAN_SAMPLES) scanSamples.shift();
|
|
2521
3111
|
if (ms > cost.maxMs) {
|
|
2522
3112
|
cost.maxMs = +ms.toFixed(2);
|
|
@@ -2529,7 +3119,6 @@ function scanStructureInner() {
|
|
|
2529
3119
|
dom: { rows: collected.rows.length, bodies: bodies.length, nodes: flow.getElementsByTagName("*").length },
|
|
2530
3120
|
};
|
|
2531
3121
|
}
|
|
2532
|
-
syncNativeRow();
|
|
2533
3122
|
}
|
|
2534
3123
|
function scan() {
|
|
2535
3124
|
lastScan = performance.now();
|
|
@@ -2545,10 +3134,9 @@ function schedule() {
|
|
|
2545
3134
|
const now = performance.now();
|
|
2546
3135
|
const wait = SCAN_INTERVAL - (now - lastScan);
|
|
2547
3136
|
if (disposed) return;
|
|
2548
|
-
if (scanTimer) { clearTimeout(scanTimer); scanTimer = 0; } // 同一个节流窗口会被排很多次:先撤旧的,只留一个待触发
|
|
2549
3137
|
if (wait <= 0) { lastScan = now; scanStructure(); }
|
|
2550
3138
|
else scanTimer = setTimeout(schedule, wait); // 被节流挡下的这次变化,到点补扫一遍(别丢)
|
|
2551
|
-
if (busyWork)
|
|
3139
|
+
if (busyWork) scanTimer = setTimeout(schedule, SCAN_INTERVAL); // 还有没做完的展开/收起,继续排队
|
|
2552
3140
|
ensureTickLoop(); // 跟随统一走帧循环,一帧只推进一次
|
|
2553
3141
|
});
|
|
2554
3142
|
}
|
|
@@ -2562,7 +3150,6 @@ function rowsReport() {
|
|
|
2562
3150
|
runningTurn,
|
|
2563
3151
|
lastTurn,
|
|
2564
3152
|
lastTurnClosed: turnClosed(lastTurn),
|
|
2565
|
-
tailNodes: flow.querySelectorAll("[data-turn-tail]").length, // 官方收尾标记节点数(本会话,≥2 轮时不该是 0)
|
|
2566
3153
|
turns: [...turns.entries()].slice(-6).map(([turn, list]) => ({
|
|
2567
3154
|
turn,
|
|
2568
3155
|
rows: list.length,
|
|
@@ -2630,8 +3217,6 @@ function probe() {
|
|
|
2630
3217
|
lastTurn,
|
|
2631
3218
|
runningMark: flow.querySelector(RUNNING) ? "yes" : "no",
|
|
2632
3219
|
officialView: nativeValue, // 折叠模式下应为官方「不折叠」档(旧内核 normal,0.1.7 起 verbose),不能是 compact
|
|
2633
|
-
bound: lowerBound(), // 小窗底边不许越过的下边界(composer 上沿,视口坐标);null = 没认到 composer 座位
|
|
2634
|
-
|
|
2635
3220
|
// 回底按钮自检:n=我方注入的按钮数、vis=当前可见数,detail=每个小窗的状态
|
|
2636
3221
|
jumps: {
|
|
2637
3222
|
n: document.querySelectorAll("[data-dshsf-jump]").length,
|
|
@@ -2641,16 +3226,21 @@ function probe() {
|
|
|
2641
3226
|
has: el.querySelector(":scope > [data-dshsf-jump]") ? 1 : 0,
|
|
2642
3227
|
sub: el.querySelectorAll("[data-dshsf-jump]").length,
|
|
2643
3228
|
gap: Math.round(el.scrollHeight - el.clientHeight - el.scrollTop),
|
|
2644
|
-
|
|
2645
|
-
|
|
3229
|
+
follow: winState.get(el) !== undefined && winState.get(el).writer === "us" ? 1 : 0,
|
|
3230
|
+
active: el === activeWindow ? 1 : 0,
|
|
2646
3231
|
vis: el.querySelector(":scope > [data-dshsf-jump][data-dshsf-visible]") ? 1 : 0,
|
|
2647
3232
|
})),
|
|
2648
3233
|
},
|
|
2649
|
-
perf: { ticks: tickCount, announces: announceCount }, // 帧循环次数 / 补发 scroll 次数
|
|
3234
|
+
perf: { ticks: tickCount, announces: announceCount, tickErrors, lastTickError }, // 帧循环次数 / 补发 scroll 次数 / 循环内异常
|
|
3235
|
+
trace: trace.slice(), // 最近 240 帧的帧轨迹 {dt,writes,announces,tickMs,scans,step,gap,writer,goal}(直接 JSON.stringify 粘回来;空闲不记)
|
|
2650
3236
|
// 我们自己的 JS 成本:帧循环平均每帧多少毫秒、结构扫描平均每次多少毫秒(不含浏览器布局/绘制)
|
|
2651
3237
|
cost: {
|
|
2652
3238
|
frames: cost.frames,
|
|
2653
3239
|
tickAvgMs: +(cost.tickMs / Math.max(1, cost.frames)).toFixed(3),
|
|
3240
|
+
tickP50: percentile(tickSamples, 0.5), tickP95: percentile(tickSamples, 0.95),
|
|
3241
|
+
tickMax: tickSamples.length ? Math.max.apply(null, tickSamples) : 0,
|
|
3242
|
+
pinRects,
|
|
3243
|
+
tickPhases: (() => { const n = Math.max(1, tickPhase.n); return { plan: +(tickPhase.plan / n).toFixed(2), main: +(tickPhase.main / n).toFixed(2), mainPlan: +(tickPhase.mainPlan / n).toFixed(2), mainSlide: +(tickPhase.mainSlide / n).toFixed(2), mainGate: +(tickPhase.mainGate / n).toFixed(2), pin: +(tickPhase.pin / n).toFixed(2), win: +(tickPhase.win / n).toFixed(2), winStyle: +(tickPhase.winStyle / n).toFixed(2), winSlide: +(tickPhase.winSlide / n).toFixed(2), winJump: +(tickPhase.winJump / n).toFixed(2), sparks: +(tickPhase.sparks / n).toFixed(2), announce: +(tickPhase.announce / n).toFixed(2), absorb: +(tickPhase.absorb / n).toFixed(2), other: +(tickPhase.other / n).toFixed(2) }; })(),
|
|
2654
3244
|
scans: cost.scans,
|
|
2655
3245
|
scanAvgMs: +(cost.scanMs / Math.max(1, cost.scans)).toFixed(2), // 含加载时那一次全量折叠,会被它拉高
|
|
2656
3246
|
scanLastMs: cost.lastMs, // 最近一次扫描
|
|
@@ -2668,32 +3258,29 @@ function probe() {
|
|
|
2668
3258
|
skipped: document.querySelectorAll('[hidden="until-found"]').length, // 已跳过渲染的折叠行(应能 Ctrl+F 搜到)
|
|
2669
3259
|
searchBodies: document.querySelectorAll("[data-dshsf-search]").length, // 为搜索而挂载并藏起的思考正文数
|
|
2670
3260
|
heapMb: (() => { const m = performance.memory; return m ? Math.round(m.usedJSHeapSize / 1048576) : null; })(),
|
|
2671
|
-
//
|
|
2672
|
-
settleLog: settleLog.slice(-8),
|
|
2673
|
-
foldLog: foldLog.slice(-8),
|
|
2674
|
-
settleHold: settleHoldTurn,
|
|
2675
|
-
animAll: scanAnimateAll, // 本次扫描是否被标记为「用户正看着的折叠」(诊断:跨扫描残留会看得见)
|
|
3261
|
+
// 主窗口运动自检:writer=谁写、goal=我方目标、nav=一次性导航中、gap=离底像素
|
|
2676
3262
|
mainFollow: (() => {
|
|
2677
3263
|
const el = mainScroller();
|
|
2678
3264
|
if (!el) return null;
|
|
2679
|
-
|
|
3265
|
+
const f = Math.max(0, el.scrollHeight - el.clientHeight);
|
|
3266
|
+
let goalWant = null;
|
|
3267
|
+
if (goalKind === "tail") goalWant = f;
|
|
3268
|
+
else if (goalKind === "position") goalWant = Math.max(0, Math.min(f, goalPos));
|
|
3269
|
+
else if (goalKind === "element" && goalEl !== null && goalEl.isConnected) goalWant = Math.max(0, Math.min(f, viewTop(el, goalEl) - SETTLE_PAD));
|
|
3270
|
+
return { writer, goal: goalKind, navActive: navActive ? 1 : 0, gap: Math.round(f - el.scrollTop), height: followHeight, wrote: followWrote === null ? null : Math.round(followWrote), moves: moveCount, blocked: snapBlocked, passed: passedOfficial, foreign: foreignLanded, extWrites: extWrites, lastExtWrite: lastExtWrite, lastForeign: lastForeign, flips: followFlips.slice(-6),
|
|
3271
|
+
step: lastStep, // 上一帧 {writer, goal, gap, base, rate(px/帧), vMax(px/s), vFeed, vChase, a, dt, moving, nav}
|
|
3272
|
+
settle: { turn: settleTurn === null ? null : String(settleTurn), until: Math.round(settleUntil), last: lastSettle }, // A14:真机才能看到 lastSettle.why
|
|
3273
|
+
abort: { count: navAbortCount, last: lastNavAbort }, // task-14:区分"卡住终止"(why=stuck)与"settle 超时"(lastSettle.why=timeout)
|
|
3274
|
+
nav: lastNav, // 最近一次一次性导航:kind / why=done|timeout|reduced-motion / to / ms
|
|
3275
|
+
releaseReason, rearmCount, releaseBy: { ...releaseBy }, ioErrors: { ...ioErrors }, capRejected, accelRejected,
|
|
3276
|
+
want: goalWant === null ? null : Math.round(goalWant), left: goalWant === null ? null : Math.round(goalWant - el.scrollTop),
|
|
2680
3277
|
officialJump: document.querySelector("[class*='_toBottomSlot']") === null ? 0 : 1, // 官方那颗在不在 DOM(由官方 state 决定)
|
|
2681
3278
|
jumpGate: document.documentElement.hasAttribute("data-dshsf-jumpgate") ? 1 : 0, // 我们的放行开关(= 我们判定该显示)
|
|
2682
3279
|
pin: pinBar !== null && pinBar.hasAttribute("data-dshsf-visible") ? 1 : 0,
|
|
2683
|
-
pinTurn: pinRow === null ? null : pinRow.getAttribute("data-chat-turn")
|
|
2684
|
-
settle: glide === null ? null : { top: Math.round(viewTop(el, glide.el) - SETTLE_PAD), left: Math.round(Math.max(0, viewTop(el, glide.el) - SETTLE_PAD) - el.scrollTop) } };
|
|
3280
|
+
pinTurn: pinRow === null ? null : pinRow.getAttribute("data-chat-turn") };
|
|
2685
3281
|
})(),
|
|
2686
3282
|
keepText: state.keepInterleavedText !== false ? 1 : 0,
|
|
2687
3283
|
textWindows: document.querySelectorAll("[data-dshsf-textwindow]").length,
|
|
2688
|
-
// 每个正文小窗的现场:cls=class、len=可见文本长度、h=实测高度、sh=内容高度、process=是否含过程块
|
|
2689
|
-
// (len 小、h 大 = 空壳/被官方折叠的内容;确认"多出来的空白窗体"是什么就看这里)
|
|
2690
|
-
textWindowDetail: Array.from(document.querySelectorAll("[data-dshsf-textwindow]")).slice(-14).map((el) => ({
|
|
2691
|
-
cls: String(el.className || el.tagName).slice(0, 44),
|
|
2692
|
-
len: (el.textContent || "").trim().length,
|
|
2693
|
-
h: Math.round(el.getBoundingClientRect().height),
|
|
2694
|
-
sh: el.scrollHeight,
|
|
2695
|
-
process: el.querySelector(PROCESS_BLOCK) !== null ? 1 : 0,
|
|
2696
|
-
})),
|
|
2697
3284
|
ctxBodies: document.querySelectorAll(CTX_BODY).length,
|
|
2698
3285
|
// 各轮所有"带正文"的行:answer=1 是答案行(设计上不套窗);wins 是套上的正文窗数
|
|
2699
3286
|
textRows: rows.filter((row) => rowKind(row) === "assistant-step" && hasText(row)).slice(-12).map((row) => ({
|
|
@@ -2753,7 +3340,6 @@ function probe() {
|
|
|
2753
3340
|
}).filter((item) => item.vis > 1),
|
|
2754
3341
|
anim: {
|
|
2755
3342
|
attr: document.documentElement.hasAttribute("data-dshsf-anim"), // = 动效开关 && (交互或 4 秒后)
|
|
2756
|
-
lastClose, // 最近一次收起:anim=是否播了动画、以及每个判据当时的值
|
|
2757
3343
|
lastReveal,
|
|
2758
3344
|
lastWindow, // 最近一次思考小窗:h=实测高度 cap=动画目标 now=40ms 时高度 anim=计算动画名
|
|
2759
3345
|
setting: state.animations !== false,
|
|
@@ -2782,13 +3368,9 @@ function startController() {
|
|
|
2782
3368
|
// 热重载/重复注入时,上一个实例可能还活着(disposer 没被调到)——那样按钮、帧循环、画布都会翻倍。
|
|
2783
3369
|
// 先把上一个实例收掉,保证同一时刻只有一个控制器在前台。
|
|
2784
3370
|
try { const prev = window.__dshStreamfold; if (prev && typeof prev.dispose === "function") prev.dispose(); } catch { /* ignore */ }
|
|
2785
|
-
// disposed 是模块级的:同一个模块被二次 apply(重复注入/双 fiber)时,上面那句 prev.dispose()
|
|
2786
|
-
// 会把新实例一起判死(scan/schedule/帧循环全部早退)——这里必须复位。
|
|
2787
|
-
disposed = false;
|
|
2788
|
-
ticking = false;
|
|
2789
3371
|
for (const old of document.querySelectorAll("[data-dshsf-pin]")) { try { old.remove(); } catch { /* ignore */ } } // 同上:置顶条只留本实例那一个
|
|
2790
3372
|
sparks.setOptions({ on: state.sparks !== false, color: state.sparkColor, density: state.sparkDensity, forge: state.forgeSparks !== false, forgeSpeed: state.forgeSpeed, forgeLife: state.forgeLife }); // localStorage 里的设置先落地(不等宿主)
|
|
2791
|
-
scan();
|
|
3373
|
+
try { scan(); } catch (err) { scanErrors += 1; lastScanError = String((err && err.message) || err).slice(0, 200); } // F4:首次 scan 不得让插件半死
|
|
2792
3374
|
// 开动效 = 标志位 + 立刻把 html[data-dshsf-anim] 置位(只改标志位要等下一次扫描,首次点开卡片常常赶不上)
|
|
2793
3375
|
const wakeAnim = () => { if (!allowAnim) { allowAnim = true; rootVars(); } };
|
|
2794
3376
|
const timer = setTimeout(wakeAnim, 4000);
|
|
@@ -2801,7 +3383,7 @@ function startController() {
|
|
|
2801
3383
|
// 用户自己点开工具卡时,React 提交后给"新出现的正文"补播揭示动画。
|
|
2802
3384
|
// 不能按 [data-tool] 找:bash 卡由官方 BashRow 渲染,根上没有 data-tool,只能按正文类名认。
|
|
2803
3385
|
const onUserActivate = (event) => {
|
|
2804
|
-
if (
|
|
3386
|
+
if (synthetic || !(event.target instanceof Element)) return;
|
|
2805
3387
|
const flow = chatScope();
|
|
2806
3388
|
if (!flow.contains(event.target)) return; // 点的不在对话流里(侧栏/设置)→ 不扫
|
|
2807
3389
|
if (event.target.closest(TOOL_BODY)) return; // 点的是正文内部(复制/选字/开文件),不是展开动作
|
|
@@ -2814,7 +3396,7 @@ function startController() {
|
|
|
2814
3396
|
const prepped = thinkRoot !== null && thinkRoot.querySelector("[data-dshsf-search]") !== null;
|
|
2815
3397
|
clearSearchHidden(thinkRoot || anchorEl);
|
|
2816
3398
|
if (prepped) { event.preventDefault(); event.stopPropagation(); } // 放行 = 官方收起 = 正文被卸载,搜索又白做
|
|
2817
|
-
|
|
3399
|
+
hold(); // 用户点击展开/收起 = 我方定位:取回写权,官方贴底写不再拖我们,位置交给 anchorDuring
|
|
2818
3400
|
anchorDuring(anchorEl, anchorEl.getBoundingClientRect().top, 500);
|
|
2819
3401
|
queueMicrotask(anchorSync); // React 在同一个 click 里提交,微任务阶段补一次(仍在绘制之前)
|
|
2820
3402
|
}
|
|
@@ -2831,58 +3413,63 @@ function startController() {
|
|
|
2831
3413
|
// 在这里把那一轮瞬时展开——搜索照样能命中,折叠省下的布局也不用还回去。
|
|
2832
3414
|
const onBeforeMatch = (event) => {
|
|
2833
3415
|
const target = event.target instanceof Element ? event.target : null;
|
|
2834
|
-
const skipped =
|
|
3416
|
+
const skipped = target && (target.closest('[hidden="until-found"]') || target.closest("[data-dshsf-folded]"));
|
|
2835
3417
|
if (!skipped) return;
|
|
2836
3418
|
const row = skipped.closest(ROW) || skipped;
|
|
2837
3419
|
clearSearchHidden(row); // 命中:把"为搜索而藏"的正文亮出来
|
|
2838
3420
|
const turn = row.getAttribute("data-chat-turn");
|
|
2839
|
-
if (turn)
|
|
2840
|
-
|
|
3421
|
+
if (turn) setManual(turn, "open");
|
|
3422
|
+
if (writer !== WRITER_READER) hold(); // Ctrl+F 已交权给读者 ⇒ 不抢回来;其它 beforematch(如锚点)维持旧行为
|
|
2841
3423
|
const prev = allowAnim;
|
|
2842
3424
|
allowAnim = false; // 瞬时展开:高度动画会把"滚到匹配处"带偏
|
|
2843
|
-
try { scan(); } finally { allowAnim = prev; }
|
|
3425
|
+
try { scan(); } catch (err) { scanErrors += 1; lastScanError = String((err && err.message) || err).slice(0, 200); } finally { allowAnim = prev; } // F4:调用点兜底
|
|
3426
|
+
};
|
|
3427
|
+
const onFindKey = (event) => {
|
|
3428
|
+
if (!((event.ctrlKey || event.metaKey) && (event.key === "f" || event.key === "F"))) return;
|
|
3429
|
+
release("reader"); // 用户要求:Ctrl+F 一按就彻底撒手 —— 浏览匹配期间零程序化写,绝不会被拽回底部
|
|
3430
|
+
releaseWindows(); // 小窗同一语义:所有 liveWindows 撒手;搜索期间新登记/重挂载的窗也不贴底
|
|
3431
|
+
searchPrep();
|
|
2844
3432
|
};
|
|
2845
|
-
const onFindKey = (event) => { if ((event.ctrlKey || event.metaKey) && (event.key === "f" || event.key === "F")) searchPrep(); };
|
|
2846
3433
|
document.addEventListener("click", onUserActivate, true);
|
|
2847
3434
|
document.addEventListener("click", onToBottomClick, true);
|
|
3435
|
+
document.addEventListener("click", onNavIntentInput, true); // 真实输入 ⇒ 给下一笔外来写打"导航"位(task-13)
|
|
3436
|
+
window.addEventListener("keydown", onNavIntentInput, true);
|
|
2848
3437
|
document.addEventListener("keydown", onUserKey, true);
|
|
2849
3438
|
document.addEventListener("keydown", onFindKey, true);
|
|
2850
3439
|
document.addEventListener("beforematch", onBeforeMatch, true);
|
|
2851
|
-
//
|
|
2852
|
-
// 只认轮号挡不住 —— 缓存说「没正文」会让承载正式回答的行被当过程行折掉(B3)。
|
|
3440
|
+
// 会话 id 就地更新不触发 childList:侦听该属性,微任务里立刻复位(不再等 SCAN_INTERVAL=300ms 的扫描)
|
|
2853
3441
|
observer = new MutationObserver((records) => {
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
const
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
const
|
|
2865
|
-
|
|
2866
|
-
const root = body.closest(THINK);
|
|
2867
|
-
if (root && root.closest("[data-dshsf-folded]")) continue; // 折起来的行不打(与扫描同一判据)
|
|
2868
|
-
tagThinkWindow(body);
|
|
2869
|
-
}
|
|
3442
|
+
// A8:定向失效 —— 任意行(含很老的轮次)的正文在 DOM 里变了,清掉该行及其祖先行的 hasText 缓存;
|
|
3443
|
+
// 新增子树里若带着行(React 换节点)也同样失效。避免"已定轮正文不再变"的假设失效后拿到陈旧 hasText。
|
|
3444
|
+
for (const r of records) {
|
|
3445
|
+
const t = r.target;
|
|
3446
|
+
const node = t === null || t === undefined ? null : (t.nodeType === 1 ? t : (t.parentElement || null));
|
|
3447
|
+
for (let n = node; n !== null; n = n.parentElement) {
|
|
3448
|
+
if (n.nodeType === 1 && n.matches && n.matches(ROW)) { textCache.delete(n); cardCountCache.delete(n); pinTextOf.delete(n); }
|
|
3449
|
+
}
|
|
3450
|
+
if (r.type === "childList") {
|
|
3451
|
+
for (const add of r.addedNodes || []) {
|
|
3452
|
+
const el = add && add.nodeType === 1 ? add : null;
|
|
3453
|
+
if (el !== null && el.closest) { const row = el.closest(ROW); if (row !== null) { textCache.delete(row); cardCountCache.delete(row); pinTextOf.delete(row); } }
|
|
2870
3454
|
}
|
|
2871
3455
|
}
|
|
2872
3456
|
}
|
|
3457
|
+
if (records.some((r) => r.type === "attribute")) syncSession();
|
|
2873
3458
|
schedule();
|
|
2874
3459
|
});
|
|
2875
|
-
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
|
3460
|
+
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["data-conversation-session"] });
|
|
2876
3461
|
const unsubscribe = subscribe(schedule);
|
|
2877
3462
|
const dispose = () => {
|
|
3463
|
+
if (disposed) return; // A3:幂等门 —— 旧 fiber 的 effect 清理可能在新实例装配之后再跑一次
|
|
2878
3464
|
disposed = true;
|
|
3465
|
+
const ownsGlobals = window.__dshStreamfold !== undefined && window.__dshStreamfold.dispose === dispose; // A3:全局副作用所有权
|
|
3466
|
+
clearSettle("dispose"); // 收摊:协调作废(不再排扫描)
|
|
2879
3467
|
ticking = false;
|
|
2880
3468
|
if (rafId) { try { cancelAnimationFrame(rafId); } catch { /* ignore */ } rafId = 0; }
|
|
2881
3469
|
clearTimeout(scanTimer);
|
|
2882
|
-
clearTimeout(readyTimer);
|
|
2883
3470
|
clearTimeout(runGraceTimer);
|
|
2884
|
-
clearTimeout(
|
|
2885
|
-
|
|
3471
|
+
clearTimeout(navWatchdog); navWatchdog = 0;
|
|
3472
|
+
navRecheck = null; // S1-4:收摊 ⇒ 观察窗口立即退出(无 rAF/timer 残留)
|
|
2886
3473
|
unsubscribe();
|
|
2887
3474
|
clearTimeout(timer);
|
|
2888
3475
|
window.removeEventListener("pointerdown", wakeAnim, true);
|
|
@@ -2892,40 +3479,42 @@ function startController() {
|
|
|
2892
3479
|
window.removeEventListener("resize", ensureTickLoop);
|
|
2893
3480
|
document.removeEventListener("click", onUserActivate, true);
|
|
2894
3481
|
document.removeEventListener("click", onToBottomClick, true);
|
|
3482
|
+
document.removeEventListener("click", onNavIntentInput, true);
|
|
3483
|
+
window.removeEventListener("keydown", onNavIntentInput, true);
|
|
2895
3484
|
document.removeEventListener("keydown", onUserKey, true);
|
|
2896
3485
|
document.removeEventListener("keydown", onFindKey, true);
|
|
2897
3486
|
document.removeEventListener("beforematch", onBeforeMatch, true);
|
|
2898
3487
|
if (followScroller) { followScroller.removeEventListener("scroll", onMainScroll); followScroller.removeEventListener("pointerdown", onScrollerPointerDown); followScroller = null; }
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
3488
|
+
anchorStop(); // A2:收锚定补偿(还原 overflow-anchor / 断开 RO,否则卸载后还在补写)
|
|
3489
|
+
clearFoldTimers(); // A2:清错峰折叠定时器(否则卸载后还会被折叠)
|
|
3490
|
+
for (const el of document.querySelectorAll("[data-dshsf-chip]")) { try { el.remove(); } catch { /* ignore */ } } // chip 先摘:releaseOurVisuals 只摘属性、之后认不出它
|
|
3491
|
+
unfoldAll(document); // A2:摘行级折叠标记 + 行内 max-height/overflow(必须在 releaseOurVisuals 摘 data-dshsf-row 之前,否则查不到)
|
|
3492
|
+
releaseOurVisuals(); // A2:摘掉我方全部内联视觉痕迹(行/正文/小窗/折叠条/置顶条/动画标记)
|
|
3493
|
+
for (const el of document.querySelectorAll("[data-dshsf-jump]")) { try { el.remove(); } catch { /* ignore */ } } // 回底按钮:releaseOurVisuals 只收显隐
|
|
3494
|
+
liveWindows.clear(); // 放在 releaseOurVisuals 之后:它会经 clearSearchHidden→wireWindow 重新登记
|
|
3495
|
+
activeWindow = null;
|
|
3496
|
+
if (snapTaken !== null) { // A3:实例属性/原型补丁是全局副作用 —— 不属本实例就不还原(否则拆掉新实例的吸收器)
|
|
3497
|
+
if (ownsGlobals) { try { delete snapTaken.el.scrollTop; delete snapTaken.el.scrollTo; delete snapTaken.el.scrollBy; } catch { /* ignore */ } }
|
|
3498
|
+
snapTaken = null;
|
|
3499
|
+
}
|
|
3500
|
+
if (protoIntoView !== null && ownsGlobals) { // H4:原型补丁还原(幂等)。不属本实例:原型已被新实例接管,
|
|
3501
|
+
try { Element.prototype.scrollIntoView = protoIntoView; } catch { /* ignore */ } // 保留 protoIntoView 让本实例的包装层
|
|
3502
|
+
protoIntoView = null; // 仍可调用(否则新包装链 apply 到 null)
|
|
3503
|
+
}
|
|
2903
3504
|
document.documentElement.removeAttribute("data-dshsf-jumpgate");
|
|
2904
|
-
document.documentElement.removeAttribute("data-dshsf-mode"); // 卸载后别把「折叠档」标记留在 <html> 上
|
|
2905
|
-
document.documentElement.removeAttribute("data-dshsf-anim");
|
|
2906
|
-
for (const old of document.querySelectorAll("[data-dshsf-mainjump]")) { try { old.remove(); } catch { /* ignore */ } } // 旧版本自绘的容器:清掉
|
|
2907
3505
|
if (pinBar !== null) { try { pinBar.remove(); } catch { /* ignore */ } pinBar = null; }
|
|
2908
3506
|
pinRows = []; pinRow = null;
|
|
2909
3507
|
if (observer) observer.disconnect();
|
|
2910
3508
|
observer = null;
|
|
2911
|
-
// 把自己改过的 DOM 还原:样式表已随 styles effect 移除,折叠行会停在 hidden="until-found"
|
|
2912
|
-
// (不渲染)、折叠条成了没样式的死按钮 —— 关插件/卸载后只能刷新页面才恢复。
|
|
2913
|
-
try {
|
|
2914
|
-
unfoldAll(document);
|
|
2915
|
-
clearChips(document);
|
|
2916
|
-
for (const el of document.querySelectorAll("[data-dshsf-search]")) { el.removeAttribute("data-dshsf-search"); clearSkipHidden(el); } // 归属感知:别把官方挂的那份也摘了、也别留僵尸标记
|
|
2917
|
-
for (const el of document.querySelectorAll("[data-dshsf-window], [data-dshsf-answer-sep]")) {
|
|
2918
|
-
el.removeAttribute("data-dshsf-window"); el.removeAttribute("data-dshsf-answer-sep");
|
|
2919
|
-
el.style.maxHeight = ""; el.style.overflow = "";
|
|
2920
|
-
}
|
|
2921
|
-
for (const el of document.querySelectorAll("[data-dshsf-jump]")) { try { el.remove(); } catch { /* ignore */ } }
|
|
2922
|
-
} catch { /* ignore */ }
|
|
2923
3509
|
sparks.reset();
|
|
2924
|
-
if (
|
|
3510
|
+
if (ownsGlobals) delete window.__dshStreamfold;
|
|
2925
3511
|
};
|
|
3512
|
+
// task-57 H10:v11 契约的固定出口名(按优先级第一个)+ 兼容别名
|
|
3513
|
+
const diagFn = () => ({ lastStep: lastStep, writer, goal: goalKind, navActive: navActive ? 1 : 0, nav: lastNav, navAbort: navAbortCount, lastNavAbort, blocked: snapBlocked, passed: passedOfficial, foreign: foreignLanded, extWrites: extWrites, lastExtWrite: lastExtWrite, releaseBy: { ...releaseBy }, rearmCount, ioErrors: { ...ioErrors }, capRejected, accelRejected });
|
|
3514
|
+
try { window.__streamfoldDiag = diagFn; window.__dshStreamfoldDiag = diagFn; window.__sfDiag = diagFn; window.__dshsfProbe = diagFn; } catch { /* ignore */ }
|
|
2926
3515
|
window.__dshStreamfold = { stats, rows: rowsReport, probe, scan, set: setSettings, get: snapshot, chooseDisplay, resetCost, state: () => ({ ...state, nativeValue }),
|
|
2927
3516
|
fx: sparks, dispose }; // 调参/排障入口:__dshStreamfold.fx 可实时调火花参数
|
|
2928
|
-
|
|
3517
|
+
setTimeout(() => { try { console.info("[streamfold] ready", JSON.stringify(stats())); } catch { /* ignore */ } }, 2500);
|
|
2929
3518
|
return dispose;
|
|
2930
3519
|
}
|
|
2931
3520
|
|
|
@@ -2982,6 +3571,67 @@ function Select(props) {
|
|
|
2982
3571
|
onClick: () => { setOpen(false); props.onChange(option.value); },
|
|
2983
3572
|
}, option.label))) : null);
|
|
2984
3573
|
}
|
|
3574
|
+
/* -------------------------------------------- 设置页:只留我们这一行 -- */
|
|
3575
|
+
/** T9:官方 slot item(id transcript-view)DOM 里没有 id ⇒ 按结构识别;我们自己的行加标记用于排除。 */
|
|
3576
|
+
const OWN_ROW_ATTR = "data-dshsf-own-row"; // 只打在我们自己的行上(排除用,避免误伤别的设置行)
|
|
3577
|
+
const HIDE_OFFICIAL_ATTR = "data-dshsf-hide-official"; // 只打在官方「工作步骤展示」行上(唯一痕迹:隐藏)
|
|
3578
|
+
let hideObserver = null;
|
|
3579
|
+
/** 官方标题文案(tChat 取不到就退回中文),只用于确认这一行的身份。 */
|
|
3580
|
+
const transcriptTitle = () => {
|
|
3581
|
+
if (!tChat) return "工作步骤展示";
|
|
3582
|
+
try { const t = tChat("settings.transcript.title"); return typeof t === "string" && t !== "settings.transcript.title" ? t : "工作步骤展示"; } catch { return "工作步骤展示"; }
|
|
3583
|
+
};
|
|
3584
|
+
/** 我们自己的行:`.dshsf-select` 所在的那条设置行。 */
|
|
3585
|
+
function ownRowEl() {
|
|
3586
|
+
const sel = document.querySelector(".dshsf-select");
|
|
3587
|
+
if (sel === null) return null;
|
|
3588
|
+
return sel.closest(".dshsf-row") ?? sel.closest('[class*="_row"]'); // 我方行类名是 dshsf-row(不含 _row);不能只靠官方那套 CSS-module 命名猜
|
|
3589
|
+
}
|
|
3590
|
+
/** 官方「工作步骤展示」行:触发按钮(aria-haspopup=menu)+ 四个官方档位标签之一 + 标题文本,并排除我方下拉/我方行。 */
|
|
3591
|
+
function findOfficialRow(own) {
|
|
3592
|
+
const title = transcriptTitle();
|
|
3593
|
+
for (const button of document.querySelectorAll('button[aria-haspopup="menu"]')) {
|
|
3594
|
+
if (button.closest(".dshsf-select") !== null) continue; // 我方下拉:永不隐藏
|
|
3595
|
+
const row = button.closest('[class*="_row"]');
|
|
3596
|
+
if (row === null) continue;
|
|
3597
|
+
if (own !== null && (row === own || own.contains(button))) continue; // 我方行:排除
|
|
3598
|
+
const text = String(button.textContent || "").trim();
|
|
3599
|
+
if (!NATIVE_MODES.some((m) => modeLabel(m) === text)) continue; // 必须是四个官方档位之一
|
|
3600
|
+
if (String(row.textContent || "").indexOf(title) < 0) continue; // 必须带官方标题
|
|
3601
|
+
return row;
|
|
3602
|
+
}
|
|
3603
|
+
return null;
|
|
3604
|
+
}
|
|
3605
|
+
/** 幂等:我方行渲染成功 ⇒ 隐藏官方行;我方行缺席/卸载 ⇒ 立即恢复。绝不允许两行都没了。 */
|
|
3606
|
+
function syncOfficialRowHidden() {
|
|
3607
|
+
const own = ownRowEl();
|
|
3608
|
+
if (own !== null && !own.hasAttribute(OWN_ROW_ATTR)) own.setAttribute(OWN_ROW_ATTR, "");
|
|
3609
|
+
const official = findOfficialRow(own);
|
|
3610
|
+
if (own !== null && official !== null) {
|
|
3611
|
+
if (!official.hasAttribute(HIDE_OFFICIAL_ATTR)) official.setAttribute(HIDE_OFFICIAL_ATTR, "");
|
|
3612
|
+
return;
|
|
3613
|
+
}
|
|
3614
|
+
for (const el of document.querySelectorAll("[" + HIDE_OFFICIAL_ATTR + "]")) el.removeAttribute(HIDE_OFFICIAL_ATTR);
|
|
3615
|
+
}
|
|
3616
|
+
/** 观察设置页 DOM(childList):我方行出现/消失、官方行被 React 重建 ⇒ 重贴。不进帧路径。 */
|
|
3617
|
+
function startOfficialRowHide() {
|
|
3618
|
+
if (hideObserver === null && typeof MutationObserver === "function" && document.body !== null) {
|
|
3619
|
+
hideObserver = new MutationObserver(() => {
|
|
3620
|
+
if (document.querySelector(".dshsf-select") === null && document.querySelector("[" + HIDE_OFFICIAL_ATTR + "]") === null) return;
|
|
3621
|
+
syncOfficialRowHidden();
|
|
3622
|
+
});
|
|
3623
|
+
hideObserver.observe(document.body, { childList: true, subtree: true });
|
|
3624
|
+
}
|
|
3625
|
+
syncOfficialRowHidden();
|
|
3626
|
+
}
|
|
3627
|
+
/** 收摊:断开观察并摘掉两个标记 ⇒ 官方行恢复。 */
|
|
3628
|
+
function stopOfficialRowHide() {
|
|
3629
|
+
if (hideObserver !== null) { try { hideObserver.disconnect(); } catch { /* ignore */ } hideObserver = null; }
|
|
3630
|
+
for (const el of document.querySelectorAll("[" + OWN_ROW_ATTR + "], [" + HIDE_OFFICIAL_ATTR + "]")) {
|
|
3631
|
+
el.removeAttribute(OWN_ROW_ATTR);
|
|
3632
|
+
el.removeAttribute(HIDE_OFFICIAL_ATTR);
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
2985
3635
|
function DisplayRow() {
|
|
2986
3636
|
const [choice, setChoice] = React.useState(displayChoice);
|
|
2987
3637
|
React.useEffect(() => subscribe(() => setChoice(displayChoice())), []);
|
|
@@ -2990,7 +3640,7 @@ function DisplayRow() {
|
|
|
2990
3640
|
try { return nativeScope.subscribe(() => setChoice(displayChoice())); } catch { return undefined; }
|
|
2991
3641
|
}, []);
|
|
2992
3642
|
const options = NATIVE_MODES.map((mode) => ({ value: mode, label: modeLabel(mode) })).concat([{ value: "fold", label: modeLabel("fold") }]);
|
|
2993
|
-
return h(Row, { title:
|
|
3643
|
+
return h(Row, { title: "工作步骤展示", desc: "选择希望看到多少工具调用细节;「折叠」=由流式折叠插件提供的模式" },
|
|
2994
3644
|
h(Select, { value: choice, options, onChange: (value) => { chooseDisplay(value); setChoice(displayChoice()); } }));
|
|
2995
3645
|
}
|
|
2996
3646
|
function SettingsPage() {
|
|
@@ -3000,10 +3650,16 @@ function SettingsPage() {
|
|
|
3000
3650
|
h("div", { className: "dshsf-group" },
|
|
3001
3651
|
h(Row, { title: "折叠历史轮次", desc: "关闭后:加载页面不再自动折叠历史轮次(只在跑动时折前面的)" },
|
|
3002
3652
|
h(Toggle, { value: s.foldHistory !== false, onChange: (v) => setSettings({ foldHistory: v }) })),
|
|
3003
|
-
h(Row, { title: "保留穿插正文", desc: "
|
|
3004
|
-
h(Toggle, { value: s.keepInterleavedText !== false, onChange: (v) => setSettings({ keepInterleavedText: v }) }))
|
|
3653
|
+
h(Row, { title: "保留穿插正文", desc: "折叠时保留带正文的行,用横线与正式回答分开(正文不再套限高小窗)" },
|
|
3654
|
+
h(Toggle, { value: s.keepInterleavedText !== false, onChange: (v) => setSettings({ keepInterleavedText: v }) })),
|
|
3655
|
+
h(Row, { title: "提问不入折叠", desc: "LLM 的提问工具与其回复(ask_user_question / question-reply):开启则两者都始终保留、不折进「已折叠 N 行」;关闭则随其余内容一起折叠" },
|
|
3656
|
+
h(Toggle, { value: s.keepUserQuestions === true, onChange: (v) => setSettings({ keepUserQuestions: v }) }))),
|
|
3005
3657
|
h("div", { className: "dshsf-group" },
|
|
3006
|
-
h(Row, { title: "
|
|
3658
|
+
h(Row, { title: "跟随速度上限(px/s)", desc: "我们主动追赶时的速度上限(px/s,默认 " + DEFAULTS.followMaxSpeed + " = 旧 8px/帧@60Hz 的手感)。越小越柔、高速输出时落后越多;前馈(内容自己的增长速度)不受它限制" },
|
|
3659
|
+
h(NumberInput, { value: s.followMaxSpeed, min: 60, max: 2000, step: 10, onChange: (v) => setSettings({ followMaxSpeed: v }) })),
|
|
3660
|
+
h(Row, { title: "跟随加速度上限(px/s²)", desc: "速度变化的上限(px/s²,默认 " + DEFAULTS.followMaxAccel + ")。人对加速度突变更敏感:调小起步/刹车更柔,调大更跟手;2000–120000 为实用区间" },
|
|
3661
|
+
h(NumberInput, { value: s.followMaxAccel, min: 2000, max: 120000, step: 1000, onChange: (v) => setSettings({ followMaxAccel: v }) })),
|
|
3662
|
+
h(Row, { title: "窗口高度", desc: "单个思考小窗的最大高度(px)" },
|
|
3007
3663
|
h(NumberInput, { value: s.windowHeight, min: 80, max: 1200, onChange: (v) => setSettings({ windowHeight: v }) })),
|
|
3008
3664
|
h(Row, { title: "运行中自动展开思考", desc: "只自动展开本轮最新的那一个;被取代的旧窗按下面的宽限折回一行摘要" },
|
|
3009
3665
|
h(Toggle, { value: s.autoExpandReasoning !== false, onChange: (v) => setSettings({ autoExpandReasoning: v }) })),
|
|
@@ -3012,12 +3668,10 @@ function SettingsPage() {
|
|
|
3012
3668
|
h(Row, { title: "运行中自动展开工具", desc: "开:运行中最新的工具卡自动展开(用官方卡片自己的折叠/内滚,不套我们的限高窗)" },
|
|
3013
3669
|
h(Toggle, { value: s.autoExpandTools !== false, onChange: (v) => setSettings({ autoExpandTools: v }) }))),
|
|
3014
3670
|
h("div", { className: "dshsf-group" },
|
|
3015
|
-
h(Row, { title: "
|
|
3016
|
-
h(Toggle, { value: s.
|
|
3671
|
+
h(Row, { title: "发消息回到尾部", desc: "你停在中途(看过置顶、跳转过轮次)时,自己再发一条消息 ⇒ 视图滑回尾部;关掉后一律停在原地,只有滚到底或点「回到底部」才恢复跟随" },
|
|
3672
|
+
h(Toggle, { value: s.returnToTail !== false, onChange: (v) => setSettings({ returnToTail: v }) })),
|
|
3017
3673
|
h(Row, { title: "平滑系数", desc: "跟随底部时每帧吃掉多少差距,越小越柔(默认 0.15)" },
|
|
3018
3674
|
h(NumberInput, { value: s.smoothGrow, min: 0.05, max: 0.9, step: 0.05, onChange: (v) => setSettings({ smoothGrow: v }) })),
|
|
3019
|
-
h(Row, { title: "平滑最小步长", desc: "每 16ms 至少推进多少像素(与屏幕刷新率无关)" },
|
|
3020
|
-
h(NumberInput, { value: s.smoothMin, min: 0, max: 12, step: 1, onChange: (v) => setSettings({ smoothMin: v }) })),
|
|
3021
3675
|
h(Row, { title: "离底显示「回到底部」" }, h(Toggle, { value: s.showJumpButton !== false, onChange: (v) => setSettings({ showJumpButton: v }) })),
|
|
3022
3676
|
h(Row, { title: "跑完回到提问处", desc: "一轮结束后平滑移到本轮你那句话的位置;你已经自己上滚过就不动你。动效过渡关掉时直接到位" },
|
|
3023
3677
|
h(Toggle, { value: s.settleToPrompt !== false, onChange: (v) => setSettings({ settleToPrompt: v }) })),
|
|
@@ -3038,7 +3692,7 @@ function SettingsPage() {
|
|
|
3038
3692
|
h(Row, { title: "火花密度", desc: "火星数量倍率(0.2~3):越高越密,绘制开销也越大" },
|
|
3039
3693
|
h(Range, { value: s.sparkDensity, min: 0.2, max: 3, step: 0.1, unit: "×", onChange: (v) => setSettings({ sparkDensity: v }) }))),
|
|
3040
3694
|
h("div", { className: "dshsf-actions" },
|
|
3041
|
-
h("button", { type: "button", className: "dshsf-btn", onClick: () => setSettings({ ...DEFAULTS }) }, "恢复默认")));
|
|
3695
|
+
h("button", { type: "button", className: "dshsf-btn", onClick: () => { setSettings({ ...DEFAULTS }); chooseDisplay("fold"); } }, "恢复默认"))); // A5:恢复默认也要强制官方档(否则官方仍自折 ⇒ 双重折叠)
|
|
3042
3696
|
}
|
|
3043
3697
|
|
|
3044
3698
|
/* ------------------------------------------------------------- apply -- */
|
|
@@ -3068,16 +3722,29 @@ function apply(ctx) {
|
|
|
3068
3722
|
};
|
|
3069
3723
|
adopt();
|
|
3070
3724
|
enforceNativeBaseline().then(schedule, () => {});
|
|
3071
|
-
|
|
3072
|
-
// 且旧实例卸载后还会继续把用户的官方档位写回去。
|
|
3073
|
-
const offNative = nativeScope.subscribe(() => { if (disposed) return; adopt(); enforceNativeBaseline(); schedule(); });
|
|
3074
|
-
ctx.effect(() => () => { try { if (offNative) offNative(); } catch { /* ignore */ } }, "dsh-streamfold: native settings scope");
|
|
3725
|
+
nativeScope.subscribe(() => { adopt(); enforceNativeBaseline(); schedule(); });
|
|
3075
3726
|
} catch (error) { console.warn("[streamfold] native settings scope unavailable", error); }
|
|
3076
3727
|
});
|
|
3077
|
-
if (ctx.slots)
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3728
|
+
if (ctx.slots) ctx.effect(() => applyPageSlots(ctx), "dsh-streamfold: page slots");
|
|
3729
|
+
}
|
|
3730
|
+
/** 页面级 slot 注册做成**幂等**:disposer 挂在 window 上;apply 时先把上一次的逐个 dispose 再注册。
|
|
3731
|
+
* 热重载时旧模块实例的 effect disposer 不会被调到(那就是"工作步骤展示"重复两行的根因),
|
|
3732
|
+
* 所以清理由 window 上的列表负责;正常卸载时由 ctx.effect 返回的 disposer 清理。 */
|
|
3733
|
+
function applyPageSlots(ctx) {
|
|
3734
|
+
const prev = window.__dshsfPageSlots;
|
|
3735
|
+
if (Array.isArray(prev)) { for (const d of prev) { try { d(); } catch { /* ignore */ } } }
|
|
3736
|
+
const list = [];
|
|
3737
|
+
window.__dshsfPageSlots = list;
|
|
3738
|
+
const keep = (d) => { if (typeof d === "function") list.push(d); return d; };
|
|
3739
|
+
// 方案A:折叠回到我们自己的设置行(唯一入口)。
|
|
3740
|
+
keep(ctx.slots.inject("settings.general.item", () => keep(ctx.slots.register({ name: "settings.general.item", id: "streamfold", order: 13 }, DisplayRow))));
|
|
3741
|
+
// T9:我方行渲染成功时隐藏官方「工作步骤展示」行(只改可见性;我方行不在 ⇒ 立即恢复)。
|
|
3742
|
+
if (ctx.effect) keep(ctx.effect(() => { startOfficialRowHide(); return () => stopOfficialRowHide(); }, "dsh-streamfold: hide official row"));
|
|
3743
|
+
keep(ctx.slots.inject("settings.section", () => keep(ctx.slots.register({ name: "settings.section", id: "streamfold", order: 42, label: () => "流式折叠" }, SettingsPage))));
|
|
3744
|
+
return () => {
|
|
3745
|
+
for (const d of list) { try { d(); } catch { /* ignore */ } }
|
|
3746
|
+
if (window.__dshsfPageSlots === list) { try { delete window.__dshsfPageSlots; } catch { /* ignore */ } }
|
|
3747
|
+
};
|
|
3081
3748
|
}
|
|
3082
3749
|
exports.apply = apply;
|
|
3083
3750
|
exports.inject = ["slots"];
|