dsh-streamfold 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,3085 @@
1
+ window.__ModuleLoader__.load({ id: "dsh-streamfold", factory: (require) => {
2
+ var module = { exports: {} }; var exports = module.exports;
3
+ "use strict";
4
+ /**
5
+ * dsh-streamfold —— 流式折叠(DSH Web 显示增强)
6
+ *
7
+ * 模型只有两条:
8
+ * 1) 正在跑的那一轮:只自动展开本轮**最新的那一个**思考小窗;被取代的旧窗按 supersedeDelay 秒后折回一行摘要
9
+ * (工具卡默认不自动展开);
10
+ * 2) 其它所有轮次:过程行全量折叠成一条「已折叠 N 行 · 点击展开」;
11
+ * 点开展开时,行回来了但**思考保持一行摘要**(不自动展开),想深看单条自己点。
12
+ *
13
+ * 边界(DSH 0.1.5-rc.2 实测,勿改):
14
+ * - 行 wrapper:[data-chat-flow] > [data-chat-flow-kind][data-chat-turn][data-chat-flow-key]
15
+ * - 思考:根 [data-variant="think"],正文 [class*="_thinkBody"](仅展开时挂载)
16
+ * - 工具:根 [data-tool],正文 [class*="_bodyScroll"] 或 [class*="_bodyWrap"](仅展开时挂载)
17
+ * - 展开控件:[data-disclosure-row][aria-expanded],祖先 [data-open];正文条件挂载 → 必须先点开
18
+ * - 条件属性不可当判据:data-turn-process-inline / data-expanded 只在特定状态存在
19
+ * - 一次尝试只能触发一种事件:click 与 Enter 各 toggle 一次,同时发 = 白干
20
+ */
21
+ const React = require("react");
22
+ const h = React.createElement;
23
+
24
+ const NS = "dsh-streamfold";
25
+ const NATIVE_NS = "ui-chat";
26
+ const NATIVE_FIELD = "transcriptView";
27
+ const DEFAULTS = {
28
+ mode: "native", // native = 官方标准/紧凑;fold = 本插件
29
+ foldHistory: true, // 非运行中的轮次是否自动折叠(默认是)
30
+ keepInterleavedText: false, // 折叠时保留"穿插正文"行(只有正文,不含思考)
31
+ windowHeight: 260,
32
+ autoFollow: true,
33
+ showJumpButton: true,
34
+ animations: true,
35
+ autoExpandReasoning: true, // 运行中的那一轮自动展开思考
36
+ autoExpandTools: false, // 运行中的那一轮自动展开工具(默认不展开:工具卡自己点)
37
+ settleToPrompt: true, // 一轮跑完后平滑移到本轮提问处(读者已接管滚动时不动)
38
+ pinPrompt: true, // 置顶显示提问:跟着"正在看的那一轮"换,超过一行只显示一行
39
+ smoothGrow: 0.15, // 跟随底部时每帧吃掉多少差距(越小越柔)
40
+ smoothMin: 1, // 每 16ms 至少推进多少像素
41
+ supersedeDelay: 2, // 运行中旧的思考窗被取代后,延迟多少秒再自动折回
42
+ sparks: true, // 思考小窗底部的火星特效(独立模块,见「火星特效」一节)
43
+ sparkColor: "#4fa8ff", // 火星颜色(核心自动提亮成白热)
44
+ sparkDensity: 1, // 火星数量倍率(0.2~3)
45
+ forgeSparks: true, // 正文流式写头的"锻打"火星(独立开关,同一个火星模块)
46
+ forgeSpeed: 1, // 锻打火星速度倍率(0.2~4)
47
+ forgeLife: 1.3, // 锻打火星寿命(秒,0.2~5;实际在 ±30% 内随机)
48
+ };
49
+
50
+ /* ---------------------------------------------------------------- store -- */
51
+ const listeners = new Set();
52
+ let state = { ...DEFAULTS };
53
+ try {
54
+ const cached = JSON.parse(localStorage.getItem(NS) || "null");
55
+ if (cached && typeof cached === "object") state = normalize({ ...state, ...cached });
56
+ } catch { /* ignore */ }
57
+ function normalize(next) {
58
+ if (typeof next.autoExpandReasoning === "string") next.autoExpandReasoning = next.autoExpandReasoning !== "off";
59
+ if (typeof next.autoExpandTools === "string") next.autoExpandTools = next.autoExpandTools !== "off";
60
+ return next;
61
+ }
62
+ let nativeValue = "standard";
63
+ let nativeScope = null;
64
+ /* 官方「工作步骤展示」档位(0.1.7):与官方 TRANSCRIPT_VIEW_MODES 同步;
65
+ normal/expanded 是上一代的取值,0.1.7 只在读取时按 standard/detailed 兼容。 */
66
+ const NATIVE_MODES = ["compact", "standard", "detailed", "verbose"];
67
+ const NATIVE_ALIAS = { normal: "standard", expanded: "detailed" };
68
+ const NATIVE_LABELS = { compact: "简洁", standard: "标准", detailed: "详细", verbose: "完全展开", fold: "折叠" };
69
+ const NATIVE_LABELS_EN = { compact: "Compact", standard: "Standard", detailed: "Detailed", verbose: "Verbose", fold: "Fold" };
70
+ 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
+ const modeLabel = (mode) => {
79
+ const fallback = (isZh() ? NATIVE_LABELS : NATIVE_LABELS_EN)[mode] || mode;
80
+ if (!tChat) return fallback;
81
+ try {
82
+ const text = tChat("settings.transcript." + mode);
83
+ return typeof text === "string" && text !== "settings.transcript." + mode ? text : fallback;
84
+ } catch { return fallback; }
85
+ };
86
+
87
+ const snapshot = () => state;
88
+ const emit = () => {
89
+ sparks.setOptions({ on: state.sparks !== false, color: state.sparkColor, density: state.sparkDensity, forge: state.forgeSparks !== false, forgeSpeed: state.forgeSpeed, forgeLife: state.forgeLife });
90
+ for (const fn of Array.from(listeners)) { try { fn(); } catch { /* ignore */ } }
91
+ };
92
+ const subscribe = (fn) => { listeners.add(fn); return () => listeners.delete(fn); };
93
+ const persistLocal = () => { try { localStorage.setItem(NS, JSON.stringify(state)); } catch { /* ignore */ } };
94
+
95
+ function setSettings(patch) {
96
+ state = normalize({ ...state, ...patch });
97
+ persistLocal();
98
+ emit();
99
+ }
100
+ function useSettings() {
101
+ const [snap, setSnap] = React.useState(snapshot);
102
+ React.useEffect(() => subscribe(() => setSnap(snapshot())), []);
103
+ return snap;
104
+ }
105
+ function displayChoice() {
106
+ if (state.mode === "fold") return "fold";
107
+ return NATIVE_MODES.includes(nativeValue) ? nativeValue : "standard";
108
+ }
109
+ let nativePinned = false; // 官方取值是否已校正(只做一次,避免写值 → 订阅 → 再写的回环)
110
+ let nativeProbing = false; // 写入进行中(挡住订阅回调重入)
111
+ let nativeTries = 0; // 写入被拒的次数(失败会经订阅回调回到这里,不能无限重写)
112
+ const NATIVE_TRIES_MAX = 3;
113
+ /** 折叠模式必须让官方「别自己折」:0.1.7 的四档里只有 verbose 是 foldCompletedTurns=false + stepGrouping=none
114
+ * (= 完全展开),其余三档官方自己就会折叠或分组,会和我们的折叠双重生效。
115
+ * 镜像没就绪时不写、也不落定 —— 订阅回调会再来一次。 */
116
+ async function enforceNativeBaseline() {
117
+ if (nativePinned || nativeProbing || nativeTries >= NATIVE_TRIES_MAX || state.mode !== "fold" || !nativeScope) return;
118
+ const section = nativeScope.getSnapshot();
119
+ if (!section || section.status !== "ready") return;
120
+ nativeProbing = true;
121
+ try {
122
+ const value = section.value ? section.value[NATIVE_FIELD] : undefined;
123
+ let ok = value === "verbose";
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 次
131
+ nativeValue = "verbose";
132
+ nativePinned = true;
133
+ } finally { nativeProbing = false; }
134
+ }
135
+ function chooseDisplay(choice) {
136
+ if (choice === "fold") {
137
+ // 以「官方不折叠」那一档为底:官方自己折了的话,点开我们折叠条时官方那份 hidden 还在,等于打架。
138
+ // 「折叠」以官方「完全展开」为底:官方自己不折、不分组,折叠只由我们做。
139
+ if (nativeScope) { try { Promise.resolve(nativeScope.set(NATIVE_FIELD, "verbose")).catch(() => {}); } catch { /* ignore */ } }
140
+ nativeValue = "verbose";
141
+ setSettings({ mode: "fold" });
142
+ return;
143
+ }
144
+ setSettings({ mode: "native" });
145
+ if (nativeScope) { try { Promise.resolve(nativeScope.set(NATIVE_FIELD, choice)).catch(() => {}); } catch { /* ignore */ } }
146
+ nativeValue = choice;
147
+ }
148
+
149
+ /* ------------------------------------------------------------------ css -- */
150
+ const STYLE_ID = "dsh-streamfold/styles";
151
+ const CSS = [ // 本文件的样式表字符串(名字遮蔽了浏览器全局 CSS 对象,本插件已不再用 CSS.highlights)
152
+ ":root{--dshsf-h:260px}",
153
+ "[data-chat-flow]{overflow-anchor:none}", // 滚动位置只由我们和读者决定,别让浏览器锚定插一脚
154
+ "[data-dshsf-native-row]{display:none!important}",
155
+ /* 限高滚动窗:一个位置只有一个滚动条 */
156
+ "[data-dshsf-window]{max-height:var(--dshsf-h);overflow:auto;overscroll-behavior:contain;contain:content;scrollbar-gutter:stable;",
157
+ "scrollbar-width:thin;scrollbar-color:var(--dsw-alias-scrollbar-bg-l2,rgba(128,128,128,.35)) transparent;",
158
+ "border-radius:8px;padding:2px 8px 2px 6px;margin-right:4px;",
159
+ "transition:background-color .2s ease,box-shadow .2s ease}", // 高度由 tickFollow 每帧推进:固定过渡跟不上快输出,不要用
160
+ "[data-dshsf-window='reasoning']{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.05))}",
161
+ /* 穿插正文与正式答案之间的分隔线(用 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:6px}",
163
+ "@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
+ "html[data-dshsf-anim] [data-dshsf-window]{animation:dshsf-window-in .26s var(--ds-ease-out,cubic-bezier(.2,.8,.2,1)) backwards}",
165
+ "[data-dshsf-window][data-dshsf-closing]{transition:max-height .2s var(--ds-ease-in,ease),opacity .2s ease}",
166
+ "@keyframes dshsf-body-in{from{max-height:0;opacity:0}to{max-height:var(--dshsf-h0,2000px);opacity:1}}",
167
+ "html[data-dshsf-anim] [data-dshsf-reveal]{animation:dshsf-body-in .26s var(--ds-ease-out,cubic-bezier(.2,.8,.2,1)) backwards;overflow:hidden}",
168
+ "[class*='_bodyWrap'][data-dshsf-closing]{transition:max-height .2s var(--ds-ease-in,ease),opacity .2s ease}",
169
+ "html[data-dshsf-anim] [data-context-injection-body][data-dshsf-reveal]{overflow:auto}", // 官方这块正文自带内滚,别被上面的通用规则压成 hidden
170
+ "[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}",
174
+ "[data-dshsf-window][data-dshsf-offbottom]{box-shadow:inset 0 -14px 12px -14px var(--dsw-alias-label-caption,rgba(0,0,0,.4))}",
175
+ /* 折叠 / 折叠条 / 回到底部 */
176
+ "[data-dshsf-row]{transition:max-height .3s var(--ds-ease-in-out,ease),opacity .22s ease}",
177
+ "[data-dshsf-folded]{max-height:0;opacity:0;overflow:hidden;margin-top:0!important;margin-bottom:0!important}",
178
+ // 折叠内容跳过渲染走 hidden="until-found"(长会话最大的一笔省项):
179
+ // 它和 content-visibility:hidden 一样不布局,但**允许 Ctrl+F 搜到**并在命中前派发 beforematch。
180
+ // 注意:不能自己写 [data-x]{content-visibility:hidden}——那样内容不进布局树,Chrome 的查找就找不到它。
181
+ "[data-dshsf-chip]{display:flex;align-items:center;gap:6px;width:100%;margin:2px 0 6px;padding:3px 10px;",
182
+ "border:1px solid var(--dsw-alias-border-l3,rgba(127,127,127,.3));border-radius:999px;",
183
+ "background:rgba(127,127,127,.08);",
184
+ "background:color-mix(in srgb,var(--dsw-alias-label-primary,#7a869a) 5%,transparent);",
185
+ "backdrop-filter:blur(14px) saturate(150%);",
186
+ "-webkit-backdrop-filter:blur(14px) saturate(150%);",
187
+ "color:var(--dsw-alias-label-tertiary,#888);font-size:12px;line-height:18px;cursor:pointer;text-align:left;",
188
+ "transition:background-color .18s ease,color .18s ease}",
189
+ "[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
192
+ "padding:4px 7px;border-radius:999px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));",
193
+ "background:var(--dsw-alias-button-floating-fill,var(--dsw-alias-bg-layer-1,#fff));color:var(--dsw-alias-label-secondary,#555);",
194
+ "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;pointer-events:none;transform:translateY(4px);transition:opacity .2s ease,transform .2s ease}", // 藏起来时不占位也不挡正文点击
196
+ "[data-dshsf-jump][data-dshsf-visible]{opacity:1;pointer-events:auto;transform:translateY(0)}",
197
+ /* 主窗口那颗回底按钮:外观与位置都用官方的,只把"何时出现"接管过来——默认藏起来,
198
+ 只有我们判定"读者接管了、而且确实离底"时才放出来(官方自己按 25px 阈值无滞回地切 = 闪)。 */
199
+ "html[data-dshsf-mode='fold'] [class*='_toBottomSlot']{display:none}",
200
+ "html[data-dshsf-mode='fold'][data-dshsf-jumpgate] [class*='_toBottomSlot']{display:flex}",
201
+ "[data-dshsf-jump]>svg{display:block}",
202
+ /* 置顶的提问:固定层贴在会话视口顶端,跟着"正在看的那一轮"换;超过一行只显示一行,它自己在视口里时让位。 */
203
+ "[data-dshsf-pin]{position:fixed;z-index:7;display:none;cursor:pointer;box-sizing:border-box;",
204
+ "padding:5px max(16px,calc((100% - var(--dsh-chat-content-width,100%)) / 2 + 16px));",
205
+ "background:color-mix(in srgb,var(--dsw-alias-bg-layer-1,#fff) 90%,transparent);",
206
+ "border-bottom:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));",
207
+ "color:var(--dsw-alias-label-secondary,#555);font-size:12px;line-height:18px;",
208
+ "white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
209
+ "[data-dshsf-pin][data-dshsf-visible]{display:block}",
210
+ /* 火星特效:零高度 sticky 容器贴住可视底边,画布往上铺——容器不参与布局,窗口高度与 scrollHeight 都不受影响 */
211
+ "[data-dshsf-spark]{position:fixed;left:0;top:0;display:block;pointer-events:none;z-index:9999;will-change:transform}", // 固定层:位置由 JS 每帧摆
212
+ /* 设置页 */
213
+ ".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
+ ".dshsf-row:last-child{border-bottom:none}",
215
+ ".dshsf-rowMain{flex:1;min-width:0}",
216
+ ".dshsf-rowTitle{color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}",
217
+ ".dshsf-rowDesc{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;margin-top:2px}",
218
+ ".dshsf-switch{position:relative;width:34px;height:20px;flex:none;border-radius:10px;cursor:pointer;padding:0;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,.12));transition:background-color .18s ease}",
219
+ ".dshsf-switch[aria-checked='true']{background:var(--dsw-alias-state-business-primary,#2f6fed);border-color:transparent}",
220
+ ".dshsf-switch:after{content:'';position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:#fff;transition:transform .18s ease}",
221
+ ".dshsf-switch[aria-checked='true']:after{transform:translateX(14px)}",
222
+ ".dshsf-num{width:88px;padding:4px 8px;border-radius:6px;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-1,transparent));border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3))}",
223
+ ".dshsf-rangeWrap{display:flex;align-items:center;gap:10px;flex:none}",
224
+ ".dshsf-range{width:150px;accent-color:var(--dsw-alias-state-business-primary,#2f6fed);cursor:pointer;background:transparent}",
225
+ ".dshsf-rangeVal{min-width:34px;color:var(--dsw-alias-label-secondary);font-size:12px;text-align:right;font-variant-numeric:tabular-nums}",
226
+ ".dshsf-rowCol{flex-direction:column;align-items:stretch;gap:8px}",
227
+ ".dshsf-color{width:44px;height:24px;padding:0;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));border-radius:6px;background:transparent;cursor:pointer}",
228
+ /* 「工作步骤展示」下拉:外形向官方设置页那排菜单靠(半透明卡片 + 大圆角 + 同一套色 token) */
229
+ ".dshsf-select{position:relative;display:inline-flex;flex:none}",
230
+ ".dshsf-selectBtn{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;min-width:76px;justify-content:space-between;font-size:12px;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-1,transparent));border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.3));border-radius:8px;cursor:pointer;transition:background-color .16s ease}",
231
+ ".dshsf-selectBtn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.1))}",
232
+ ".dshsf-caret{font-size:9px;line-height:1;color:var(--dsw-alias-label-secondary)}",
233
+ ".dshsf-menu{position:absolute;top:calc(100% + 4px);right:0;z-index:1100;min-width:132px;padding:3px;display:flex;flex-direction:column;border-radius:16px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-1,#222));box-shadow:var(--dsw-elevation-prominent,0 8px 24px rgba(0,0,0,.28))}",
234
+ ".dshsf-menuItem{padding:6px 10px;font-size:12px;text-align:left;color:var(--dsw-alias-label-primary);background:transparent;border:none;border-radius:12px;cursor:pointer}",
235
+ ".dshsf-menuItem:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}",
236
+ ".dshsf-menuItem[data-active]{background:var(--dsw-alias-interactive-bg-active,rgba(127,127,127,.16))}",
237
+ ".dshsf-page{display:flex;flex-direction:column;gap:6px;padding:4px 2px 24px}",
238
+ ".dshsf-intro{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;margin:2px 0 10px}",
239
+ ".dshsf-group{border:.5px solid var(--dsw-alias-border-l1,rgba(127,127,127,.16));border-radius:12px;padding:2px 14px;background:var(--dsw-alias-bg-layer-1,transparent)}",
240
+ ".dshsf-actions{display:flex;gap:8px;margin-top:14px}",
241
+ ".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
+ ".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}}",
244
+ ].join("");
245
+
246
+ function injectStyles() {
247
+ // 总是换成当前这一份:热重载时旧 style 标签还在,但规则可能是上个版本的 —— 不换掉,CSS 改动在刷新页面前
248
+ // 根本不生效(表现就是"代码改了、界面没变",比如官方那颗回底按钮藏不掉 → 屏幕上两个按钮)。
249
+ for (const old of document.querySelectorAll("style[data-plugin-css='" + STYLE_ID + "']")) { try { old.remove(); } catch { /* ignore */ } }
250
+ const tag = document.createElement("style");
251
+ tag.dataset.plugin = "dsh-streamfold";
252
+ tag.dataset.pluginCss = STYLE_ID;
253
+ tag.textContent = CSS;
254
+ document.head.appendChild(tag);
255
+ return () => { tag.remove(); };
256
+ }
257
+ function rootVars() {
258
+ const root = document.getElementById("root") || document.documentElement;
259
+ const h = Math.max(80, Number(state.windowHeight) || 260) + "px";
260
+ 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);
266
+ }
267
+
268
+ /* ------------------------------------------------------------- 常量/工具 -- */
269
+ const CHAT_FLOW = "[data-chat-flow]";
270
+ const ROW = "[data-chat-flow-kind]";
271
+ const TOGGLE = "[data-disclosure-row], [role='button'][aria-expanded]"; // 展开/收起控件:点它 = 我要读这一处
272
+ const THINK = '[data-variant="think"]';
273
+ const THINK_BODY = '[class*="_thinkBody"]';
274
+ const TOOL_CARD = "[data-tool], [data-sample='bash']"; // bash 卡走 BashRow,根上没有 data-tool
275
+ const CTX_BODY = "[data-context-injection-body]"; // 上下文注入行的正文(根上只有哈希 class,只能从正文反推根)
276
+ const TOOL_BODY = '[class*="_bodyScroll"], [class*="_bodyWrap"]';
277
+ // 一次遍历拿到"所有已挂载的正文",替代三次全量查询(思考/工具/上下文各一次)
278
+ const BODY_ANY = '[class*="_thinkBody"], [class*="_bodyScroll"], [class*="_bodyWrap"], [data-context-injection-body]';
279
+ // 运行中的标记:思考行 data-state=running、正文 data-streaming、工具卡 data-state=running|preparing
280
+ // (工具执行期间没有流式文本,只靠前两个会误判成"跑完了" → 运行中来一次全量折叠)
281
+ const RUNNING = '[data-state="running"], [data-state="preparing"], [data-streaming]';
282
+ const RUN_GRACE_MS = 700; // 运行标记消失多久才算真跑完(步骤/工具交接的空档不算)
283
+ let runningSticky = false;
284
+ let contractWarned = false; // 官方收尾标记的契约自检只报一次
285
+ let lastRunningAt = 0;
286
+ let runGraceTimer = 0;
287
+ 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)
288
+ const inViewport = (el) => { const r = el.getBoundingClientRect(); return r.bottom > -400 && r.top < (window.innerHeight || 800) + 600; };
289
+ const rowKind = (row) => row.getAttribute("data-chat-flow-kind");
290
+ const disclosureTarget = (root) => root.matches("[role='button'][aria-expanded], [data-expandable]") ? root
291
+ : (root.querySelector("[data-disclosure-row]") || root.querySelector("[role='button'], [aria-expanded], button"));
292
+ let synthetic = false; // 我方派发的事件(捕获拦截器要放行,否则会自己拦自己)
293
+ const clickOnce = (el) => {
294
+ synthetic = true;
295
+ try {
296
+ el.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true, view: window }));
297
+ for (const type of ["mousedown", "mouseup", "click"]) el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
298
+ return true;
299
+ } catch { return false; } finally { synthetic = false; }
300
+ };
301
+ /** 把元素高度收到 0(先测高再过渡),结束回调里再让 React 真正收起。 */
302
+ function animateClose(el, done) {
303
+ el.dataset.dshsfClosing = "1";
304
+ el.style.overflow = "hidden";
305
+ el.style.maxHeight = el.getBoundingClientRect().height + "px";
306
+ void el.offsetHeight;
307
+ requestAnimationFrame(() => { el.style.maxHeight = "0px"; el.style.opacity = "0"; });
308
+ setTimeout(() => { if (disposed) return; if (typeof done === "function") done(); finishClose(el); }, 200);
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 = "";
316
+ }
317
+ /** 每个模块实例一个接线令牌:热重载后新实例必须能重新挂自己的监听(见 wireDisclosure)。 */
318
+ const WIRE_TOKEN = "wire-" + Math.random().toString(36).slice(2, 8);
319
+ /** 用户点收起时先播动画再放行:捕获阶段拦下 React 的 onClick。 */
320
+ function wireDisclosure(root, isOpen, bodySel, onUserClose) {
321
+ // 认亲而不是认「已接线」:写死 "1" 时,旧实例留下的标记会把新实例挡在门外 ——
322
+ // 旧 handler 已被 disposed 挡住、新 handler 又挂不上 = 收起动画拦截静默失效到刷新。
323
+ if (root.dataset.dshsfWired === WIRE_TOKEN) return;
324
+ root.dataset.dshsfWired = WIRE_TOKEN;
325
+ root.addEventListener("click", (event) => {
326
+ if (disposed || synthetic || !isOpen(root)) return; // 卸载/热重载后不再拦点击 // 我方事件 / 是展开动作 → 放行
327
+ if (event.target instanceof Element && event.target.closest(bodySel)) return; // 点在正文里 → 放行
328
+ if (state.mode !== "fold" || state.animations === false || !allowAnim) return; // 非折叠档 / 关了动效:直接放行
329
+ const body = bodyOf(root, bodySel);
330
+ if (!body || body.dataset.dshsfClosing === "1") return;
331
+ event.preventDefault();
332
+ event.stopPropagation();
333
+ if (onUserClose) onUserClose(root);
334
+ animateClose(body, () => clickOnce(disclosureTarget(root)));
335
+ }, true);
336
+ }
337
+ const thinkOpen = (root) => root.querySelector(THINK_BODY) !== null;
338
+ /** 展开正文:通用工具卡在 root 内([data-tool] > _bodyWrap);bash 卡的折叠行与正文是 .card 下的兄弟节点。 */
339
+ const bodyOf = (root, sel) => {
340
+ const own = root.querySelector(sel);
341
+ if (own) return own;
342
+ const sibling = root.nextElementSibling;
343
+ return sibling && sibling.matches && sibling.matches(sel) ? sibling : null;
344
+ };
345
+ const toolOpen = (root) => bodyOf(root, TOOL_BODY) !== null;
346
+
347
+ /** 一段子树里"除思考之外"的文字(思考整块跳过;hidden 的不算)。不看层级假设,避免结构一变就漏判。 */
348
+ /** hidden="until-found" 是折叠标记(内容还在,只是跳过渲染),不算官方藏起来的行。 */
349
+ const isHiddenNode = (node) => node.hasAttribute("hidden") && node.getAttribute("hidden") !== SKIP;
350
+ function rowText(el, skip, limit) {
351
+ let out = "";
352
+ const walk = (node) => {
353
+ if (limit !== undefined && out.length >= limit) return; // 只取前 limit 字:置顶条只显示一行,没必要走完整棵子树
354
+ if (node.nodeType === 3) { out += node.nodeValue || ""; return; }
355
+ if (node.nodeType !== 1) return;
356
+ if (isHiddenNode(node) || node.hasAttribute("data-dshsf-jump")) return;
357
+ if (skip !== undefined && skip(node)) return;
358
+ if (node.matches && (node.matches(THINK) || node.matches(TOOL_CARD) || node.hasAttribute("data-context-injection-body"))) return; // 过程块整块跳过(工具输出不是穿插正文,而且往往是最大的子树)
359
+ for (const child of node.childNodes) walk(child);
360
+ };
361
+ walk(el);
362
+ return (limit !== undefined && out.length > limit ? out.slice(0, limit) : out).trim();
363
+ }
364
+ /** 只判"有没有正文":命中第一个非空白文字就收工(比 rowText 便宜,扫描里每行都要用)。 */
365
+ function hasRowText(el) {
366
+ const walk = (node) => {
367
+ if (node.nodeType === 3) return (node.nodeValue || "").trim() !== "";
368
+ if (node.nodeType !== 1) return false;
369
+ if (isHiddenNode(node) || node.hasAttribute("data-dshsf-jump")) return false;
370
+ if (node.matches && (node.matches(THINK) || node.matches(TOOL_CARD) || node.hasAttribute("data-context-injection-body"))) return false; // 同上:别为工具输出白走一遍
371
+ for (const child of node.childNodes) if (walk(child)) return true;
372
+ return false;
373
+ };
374
+ return walk(el);
375
+ }
376
+ let textCache = new WeakMap(); // 行 → { turn, value }:有没有正文。跨扫描保留,但**必须认轮号** ——
377
+ // React 会把同一个行节点复用给别的轮次/别的会话,只认节点会把旧结论套到新内容上
378
+ // (缓存说「没正文」时,承载正式回答的行会被当过程行折掉)。切会话时整表重建。
379
+ let hotRows = new WeakSet(); // 最近两轮的行:正文可能还在变 → 每次扫描重算
380
+ /** assistant-step 行里除思考外是否真有正文。 */
381
+ function hasText(row) {
382
+ if (rowKind(row) !== "assistant-step") return false;
383
+ if (!hotRows.has(row)) {
384
+ const hit = textCache.get(row);
385
+ if (hit !== undefined && hit.turn === (row.getAttribute("data-chat-turn") || "")) return hit.value; // 老轮次:正文不会再变,直接复用(轮号对不上=节点被复用,重算)
386
+ }
387
+ cost.counts.hasText = (cost.counts.hasText || 0) + 1;
388
+ const t = performance.now();
389
+ // 整行判:hasRowText 会跳过思考/工具卡/隐藏子树,正好回答"这一行有没有正式正文"。
390
+ // 不再依赖 assistantBody 的具体层级 —— 0.1.7 的行结构变过,认错容器会让正文行被当成过程行,
391
+ // 跑完收尾时连回答一起折掉(就是"全量折叠")。
392
+ const result = hasRowText(row);
393
+ cost.phases.hasText = +((cost.phases.hasText || 0) + (performance.now() - t)).toFixed(2);
394
+ textCache.set(row, { turn: row.getAttribute("data-chat-turn") || "", value: result });
395
+ return result;
396
+ }
397
+ /** 行里承载「正式正文」的容器。0.1.5 是 row > markdown > body;0.1.7 多包了一层,
398
+ * 所以按「哪个子节点真的有字」挑,都认不出来才退回行本身(下游会跳过思考/工具卡子树)。 */
399
+ function assistantBody(row) {
400
+ const inline = row.querySelector("[data-turn-process-inline]");
401
+ if (inline && inline.parentElement) return inline.parentElement;
402
+ const md = row.firstElementChild;
403
+ if (!md) return row;
404
+ const nested = md.firstElementChild;
405
+ if (nested && (nested.textContent || "").trim() !== "") return nested;
406
+ return md;
407
+ }
408
+
409
+ /* ------------------------------------------------------- 展开/收起(点) -- */
410
+ let busyWork = false; // 本轮扫描是否还有没做完的展开/收起 → 继续排下一轮
411
+ let closeBudget = 0; // 每次扫描最多收起 2 个思考(避免一次几十个重渲染)
412
+
413
+ function openThink(root) {
414
+ if (thinkOpen(root)) { root.dataset.dshsfAuto = "1"; return false; }
415
+ if (root.dataset.dshsfKeepOpen === "1") return false; // 用户收起过,不抢
416
+ const tries = Number(root.dataset.dshsfTries || "0");
417
+ if (tries >= 8) return false;
418
+ const now = Date.now();
419
+ if (now - Number(root.dataset.dshsfAt || "0") < 260) return false; // 等 React 提交
420
+ root.dataset.dshsfAt = String(now);
421
+ root.dataset.dshsfTries = String(tries + 1);
422
+ const target = disclosureTarget(root);
423
+ if (!target) return false;
424
+ const tOpen = performance.now(); // 合成 click → 官方挂载/渲染正文,这是"net"往外的 React 成本
425
+ if (!clickOnce(target)) return false;
426
+ cost.phases.openThink = +((cost.phases.openThink || 0) + (performance.now() - tOpen)).toFixed(2);
427
+ cost.counts.opens = (cost.counts.opens || 0) + 1;
428
+ clearSearchHidden(root.closest(ROW) || root);
429
+ autoThinks.add(root);
430
+ root.dataset.dshsfAuto = "1"; // 这次展开是我方行为
431
+ delete root.dataset.dshsfSupersededAt; // 它又成了"最新那个",撤销折叠宽限
432
+ const pending = Number(root.dataset.dshsfCloseTimer || "0");
433
+ if (pending) { clearTimeout(pending); delete root.dataset.dshsfCloseTimer; }
434
+ tagThinkWindow(root.querySelector(THINK_BODY)); // 当场打标:晚一帧就会先闪一下没有小窗的样子
435
+ return true;
436
+ }
437
+ /** 收起一块已展开的正文:能动画就先收下去再放行 React 的收起(否则正文会被直接卸载,看不到过程)。 */
438
+ function collapse(root, isOpen, bodySel, atKey, triesKey) {
439
+ if (!isOpen(root)) return false;
440
+ const tries = Number(root.dataset[triesKey] || "0");
441
+ if (tries >= 6) return false;
442
+ const now = Date.now();
443
+ if (now - Number(root.dataset[atKey] || "0") < 260) return false;
444
+ root.dataset[atKey] = String(now);
445
+ root.dataset[triesKey] = String(tries + 1);
446
+ const target = disclosureTarget(root);
447
+ if (!target) return false;
448
+ const body = bodyOf(root, bodySel);
449
+ const canAnim = !!(body && state.animations !== false && allowAnim && animBudget > 0 && body.dataset.dshsfClosing !== "1");
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) {
456
+ animBudget -= 1;
457
+ layoutReads += 1;
458
+ // 只写一处:dataset 落到 DOM 上就是 data-dshsf-closing="1"(CSS 按"属性在不在"匹配)。
459
+ // 再补一句 setAttribute(…, "") 会把值改成空串,下面所有 `=== "1"` 的守卫全部失效
460
+ // (tickFollow 不再给收起的窗让路、revealBody/wireDisclosure 也认不出"正在收")——
461
+ // 帧循环每帧把 max-height 写回内容高度,收起过渡被反复拽回去:自动折叠只剩淡出、高度不动。
462
+ body.dataset.dshsfClosing = "1";
463
+ body.style.overflow = "hidden";
464
+ body.style.maxHeight = body.getBoundingClientRect().height + "px";
465
+ void body.offsetHeight;
466
+ requestAnimationFrame(() => { body.style.maxHeight = "0px"; body.style.opacity = "0"; });
467
+ setTimeout(() => {
468
+ if (disposed) return; // 卸载后别再派发合成 click(拦截器有守卫,这一次没有)
469
+ const late = disclosureTarget(root);
470
+ if (late) clickOnce(late);
471
+ finishClose(body); // 合成 click 没能让 React 收起时别把窗留在"透明且 0 高"
472
+ }, 200);
473
+ return true;
474
+ }
475
+ return clickOnce(target);
476
+ }
477
+ function closeThink(root) {
478
+ if (root.dataset.dshsfKeepOpen === "1") return false; // 用户点开的,永远不动
479
+ if (!thinkOpen(root)) { delete root.dataset.dshsfAuto; autoThinks.delete(root); return false; }
480
+ if (root.dataset.dshsfAuto !== "1") { root.dataset.dshsfKeepOpen = "1"; autoThinks.delete(root); return false; } // 非我方打开 = 用户
481
+ const ok = collapse(root, thinkOpen, THINK_BODY, "dshsfCloseAt", "dshsfCloseTries");
482
+ if (ok) autoThinks.delete(root); // 真收起来了才注销登记:失败(控件被换掉/被节流/tries 用满)时留着,下一轮还能重试
483
+ return ok;
484
+ }
485
+ function closeTool(root) {
486
+ if (!toolOpen(root)) { delete root.dataset.dshsfToolAuto; autoTools.delete(root); return false; }
487
+ if (root.dataset.dshsfToolAuto !== "1") { autoTools.delete(root); return false; } // 用户自己开的,不动
488
+ const ok = collapse(root, toolOpen, TOOL_BODY, "dshsfToolCloseAt", "dshsfToolCloseTries");
489
+ if (ok) autoTools.delete(root); // 同上:收起失败要留着重试,否则旧窗整轮都敞着
490
+ return ok;
491
+ }
492
+ /** 展开后按实测高度播揭示动画:固定 2000px 上限对矮卡片等于"瞬间全开",只有量到真实高度才看得见展开。 */
493
+ let lastReveal = null;
494
+ let lastWindow = null;
495
+ let lastClose = null; // 最近一次"收起一行/一个窗"的动画决策(诊断用)
496
+ const ANIM_MAX_PX = 1200; // 超过这个高度就别逐帧算 max-height 了:一次展开要重排几百毫秒,肉眼看不出区别
497
+ function revealBody(body, preHeight) {
498
+ if (!body || body.dataset.dshsfReveal === "1" || body.dataset.dshsfNoReveal === "1" || body.dataset.dshsfClosing === "1") return;
499
+ if (state.animations === false || !allowAnim) return;
500
+ let height = Math.ceil(preHeight === undefined ? body.getBoundingClientRect().height : preHeight);
501
+ if (body.parentElement && body.parentElement.closest("[data-dshsf-window]")) {
502
+ height = Math.min(height, windowCap() + 40); // 窗外那部分本来就被裁掉,动画目标不必长到几千像素
503
+ } else if (height > ANIM_MAX_PX) {
504
+ body.dataset.dshsfNoReveal = "1"; // 太高:直接出现
505
+ return;
506
+ }
507
+ body.dataset.dshsfReveal = "1";
508
+ body.style.setProperty("--dshsf-h0", height + "px");
509
+ const record = { wrap: body.className, h: height };
510
+ lastReveal = record;
511
+ // 诊断:40ms 后回读计算样式,看动画是否真的挂在元素上
512
+ setTimeout(() => {
513
+ const cs = getComputedStyle(body);
514
+ lastReveal = { ...record, anim: cs.animationName, dur: cs.animationDuration, fill: cs.animationFillMode, now: Math.round(body.getBoundingClientRect().height), attr: document.documentElement.hasAttribute("data-dshsf-anim") };
515
+ }, 40);
516
+ }
517
+ /** 被新窗取代后延迟折叠:起一个定时器;期间它又变回最新、或被用户接管,就不折了。 */
518
+ function scheduleSupersedeClose(root, ms) {
519
+ const running = Number(root.dataset.dshsfCloseTimer || "0");
520
+ if (running) clearTimeout(running);
521
+ root.dataset.dshsfCloseTimer = String(setTimeout(() => {
522
+ delete root.dataset.dshsfCloseTimer;
523
+ if (state.mode !== "fold") return; // 已切到官方档位:别再动用户的窗口
524
+ const flow = chatScope();
525
+ const row = root.closest(ROW);
526
+ if (!row || !thinkOpen(root)) return; // 已经不在了
527
+ if (root.dataset.dshsfAuto !== "1" || root.dataset.dshsfKeepOpen === "1") return; // 被用户接管
528
+ if (root === newestInTurn(flow, THINK, row.getAttribute("data-chat-turn"))) return; // 又成最新了
529
+ if (closeBudget <= 0) { scheduleSupersedeClose(root, 300); return; } // 一次扫描的收起配额用完了,稍后再收
530
+ if (closeThink(root)) closeBudget -= 1;
531
+ else delete root.dataset.dshsfSupersededAt; // 这次没收起(控件被换掉/被节流):撤掉"已排定时器"标记,让下一轮扫描重排
532
+ }, Math.max(0, ms)));
533
+ }
534
+ function openTool(root) {
535
+ if (toolOpen(root)) return false;
536
+ if (root.dataset.dshsfKeepClosed === "1") return false; // 用户自己收起的,不抢
537
+ const tries = Number(root.dataset.dshsfToolTries || "0");
538
+ if (tries >= 6) return false;
539
+ const now = Date.now();
540
+ if (now - Number(root.dataset.dshsfToolAt || "0") < 260) return false;
541
+ root.dataset.dshsfToolAt = String(now);
542
+ root.dataset.dshsfToolTries = String(tries + 1);
543
+ const target = disclosureTarget(root);
544
+ if (!target) return false;
545
+ const tOpen = performance.now();
546
+ if (!clickOnce(target)) return false;
547
+ cost.phases.openTool = +((cost.phases.openTool || 0) + (performance.now() - tOpen)).toFixed(2);
548
+ cost.counts.opens = (cost.counts.opens || 0) + 1;
549
+ autoTools.add(root);
550
+ root.dataset.dshsfToolAuto = "1"; // 这次展开是我方行为(运行中只留最新一个,更早的要收)
551
+ revealBody(bodyOf(root, TOOL_BODY)); // React 对 click 同步提交,此刻正文已在 DOM 里
552
+ return true;
553
+ }
554
+
555
+ /* ------------------------------------------------------------ 滚动小窗 -- */
556
+ /** 回底按钮只有图标:它是浮在正文上的控件,标签比按钮本身还显眼(官方那颗也只是个箭头)。 */
557
+ const JUMP_ICON = '<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true"><path d="M4 6.2 8 10.2 12 6.2" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
558
+ const jumpOf = new WeakMap(); // el -> 我方回底按钮:不靠 DOM 查询认亲,React 挪动/多实例残留都不会认错
559
+ /** 保证这个窗有回底按钮(且是直接子节点——sticky 贴底靠它)。多实例/热重载会留下残留:**留第一个、删其余**,
560
+ * 这样两个控制器也会收敛到同一个节点,而不是各建一个、互相删来删去。 */
561
+ function ensureWindowJump(el) {
562
+ let jump = jumpOf.get(el); // 先信自己的登记:DOM 挪动/多实例都不会认错
563
+ if (jump !== undefined && !jump.isConnected) jump = undefined;
564
+ if (jump === undefined) jump = el.querySelector(":scope > [data-dshsf-jump]"); // 兜底:认领页面上残留的那个
565
+ for (const extra of el.querySelectorAll(":scope > [data-dshsf-jump]")) if (extra !== jump) { try { extra.remove(); } catch { /* ignore */ } }
566
+ if (jump === null || jump === undefined) {
567
+ jump = jumpButton();
568
+ jump.addEventListener("click", (event) => {
569
+ event.preventDefault();
570
+ event.stopPropagation();
571
+ el.dataset.dshsfFollow = "1";
572
+ el.scrollTo({ top: el.scrollHeight, behavior: state.animations === false || !allowAnim ? "auto" : "smooth" });
573
+ jump.removeAttribute("data-dshsf-visible");
574
+ });
575
+ }
576
+ if (jump.parentElement !== el || el.lastElementChild !== jump) el.appendChild(jump);
577
+ jumpOf.set(el, jump);
578
+ return jump;
579
+ }
580
+ function jumpButton() {
581
+ const btn = document.createElement("button");
582
+ btn.type = "button";
583
+ btn.dataset.dshsfJump = "";
584
+ btn.setAttribute("aria-label", uiText("回到底部", "Back to bottom"));
585
+ btn.title = uiText("回到底部", "Back to bottom");
586
+ btn.innerHTML = JUMP_ICON;
587
+ return btn;
588
+ }
589
+ const wired = new WeakSet();
590
+ const liveWindows = new Set();
591
+ const atBottom = (el) => el.scrollHeight - el.scrollTop - el.clientHeight <= 24;
592
+
593
+ function wireWindow(el, followBottom = true) {
594
+ ensureWindowJump(el);
595
+ liveWindows.add(el);
596
+ if (wired.has(el)) return;
597
+ wired.add(el);
598
+ // 穿插正文窗不跟底:静态段落跟到底只会看到"最后一行 + 一屏空白"(内容短时浏览器会把 scrollTop 夹回 0)
599
+ el.dataset.dshsfFollow = followBottom ? "1" : "";
600
+ const noteIntent = () => { if (disposed) return; el.dataset.dshsfIntentAt = String(performance.now()); };
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 });
603
+ el.addEventListener("scroll", () => {
604
+ if (disposed) return;
605
+ ensureTickLoop(); // 收敛后帧循环是停的:读者一滚就得排一帧,否则按钮要等下一次 DOM 变化才冒出来
606
+ const mine = Number(el.dataset.dshsfMine);
607
+ if (Number.isFinite(mine) && Math.abs(el.scrollTop - mine) <= 2) return; // 我方推进造成的滚动,不算读者
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 = "";
614
+ }, { passive: true });
615
+ }
616
+
617
+ /** 思考正文挂载后立刻打小窗标记:扫描器最慢要等一次节流(~300ms),这段空档就是"展开瞬间没有小窗"。 */
618
+ function tagThinkWindow(body, preHeight) {
619
+ if (!body || body.getAttribute("hidden") === SKIP) return; // 为搜索藏起的正文不渲染:打小窗会强制布局,白花
620
+ if (body.dataset.dshsfWindow === "reasoning") { wireWindow(body); return; } // 已打过标:补登记(折叠期间被移出过清单)
621
+ if (body.parentElement && body.parentElement.closest("[data-dshsf-window]")) return;
622
+ // 先按实测高度写 --dshsf-hw 再打标:固定 260px 上限对"刚起步只剩一两行"的新思考,
623
+ // 动画十几毫秒就跑完了,看着跟没动效一样;按真实高度播才有完整 0.26s 的展开过程。
624
+ const cap = Math.max(80, Number(state.windowHeight) || 260);
625
+ const rect = body.getBoundingClientRect();
626
+ // 底边不许越过下边界(composer 上沿):新窗常常就长在输入框上方,按实测高度播入场动画会直接长到输入框底下。
627
+ const visible = Math.max(WINDOW_MIN_H, Math.round(Math.min(preHeight === undefined ? rect.height : preHeight, cap, roomAbove(rect.top, lowerBound()))));
628
+ body.style.setProperty("--dshsf-hw", visible + "px");
629
+ body.dataset.dshsfH = String(visible);
630
+ 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
+ wireWindow(body);
639
+ const record = { h: Math.round(body.getBoundingClientRect().height), cap: visible };
640
+ lastWindow = record;
641
+ setTimeout(() => { // 诊断:40ms 后回读,看动画是否真挂上并在长高
642
+ const cs = getComputedStyle(body);
643
+ lastWindow = { ...record, anim: cs.animationName, dur: cs.animationDuration, now: Math.round(body.getBoundingClientRect().height) };
644
+ }, 40);
645
+ }
646
+
647
+ /** 每帧一次:先只读、后集中写(滚动卡顿的主因是读写交错)。 */
648
+ const supersedeDelay = () => Math.min(60, Math.max(0, num(state.supersedeDelay, 2))); // 单位:秒
649
+ 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));
666
+ const num = (value, fallback) => (Number.isFinite(Number(value)) ? Number(value) : fallback);
667
+ const smoothGrow = () => Math.min(0.9, Math.max(0.05, num(state.smoothGrow, 0.25))); // 每帧吃掉的差距比例
668
+ const smoothMin = () => Math.min(12, Math.max(0, num(state.smoothMin, 2))); // 每 16ms 的最小推进像素
669
+ const minStep = (dt) => smoothMin() * (dt / 16); // 换算成本帧:与屏幕刷新率无关
670
+ // 自身耗时统计:只统计"我们的 JS",不含浏览器的布局/绘制(那部分不在我们手里)
671
+ const cost = { frames: 0, tickMs: 0, scans: 0, scanMs: 0, lastMs: 0, maxMs: 0, phases: {}, counts: {}, worst: null, lastError: null, pinMs: 0, pinFrames: 0 };
672
+ /** 分位数:单次采样会被外部卡顿(远程桌面/后台节流/GC)污染,中位数才反映我们自己的成本。 */
673
+ function percentile(list, q) {
674
+ if (!list.length) return 0;
675
+ const sorted = list.slice().sort((a, b) => a - b);
676
+ return +sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))].toFixed(2);
677
+ }
678
+ /** 分阶段按耗时排序取前几项——截图/粘贴时不用展开就能看到贵在哪。 */
679
+ function topPhases(phases) {
680
+ return Object.keys(phases)
681
+ .map((k) => [k, phases[k]])
682
+ .filter(([, ms]) => ms > 0)
683
+ .sort((a, b) => b[1] - a[1])
684
+ .slice(0, 5);
685
+ }
686
+ 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
+ let ticking = false;
689
+ let rafId = 0; // 帧循环句柄(dispose 要能停掉它)
690
+ let disposed = false; // 实例已收摊:所有异步入口早退(热重载后旧实例不许再动 DOM)
691
+ /** 未收敛就自己接着排帧:只靠 MutationObserver 的话,两次输出之间的那段就冻住不动了。 */
692
+ const TICK_MIN_MS = 8; // 上限 120fps:60Hz 屏每帧都推进(16.7ms > 8ms),高刷屏最多 120fps
693
+ let lastTickRun = 0;
694
+ function tickLoop() {
695
+ if (disposed) { ticking = false; return; }
696
+ const now = performance.now();
697
+ if (now - lastTickRun >= TICK_MIN_MS) {
698
+ lastTickRun = now;
699
+ if (!tickFollow()) { ticking = false; return; }
700
+ }
701
+ rafId = requestAnimationFrame(tickLoop);
702
+ }
703
+ function ensureTickLoop() {
704
+ if (ticking || disposed) return;
705
+ ticking = true;
706
+ rafId = requestAnimationFrame(tickLoop);
707
+ }
708
+ function tickFollow() {
709
+ const t0 = performance.now();
710
+ const moving = tickFollowInner();
711
+ cost.frames += 1;
712
+ cost.tickMs += performance.now() - t0;
713
+ return moving;
714
+ }
715
+ function tickFollowInner() {
716
+ tickCount += 1;
717
+ const now = performance.now();
718
+ const dt = Math.min(64, Math.max(1, now - (followTickAt || now))); // 下限 1ms:320Hz 帧间隔只有 3.1ms,别再钳
719
+ followTickAt = now;
720
+ let moving = mainFollowTick(dt);
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;
729
+ const plan = [];
730
+ for (const el of liveWindows) {
731
+ if (!el.isConnected) { liveWindows.delete(el); continue; }
732
+ if (el.clientHeight === 0) continue;
733
+ const sh = el.scrollHeight; // 每帧只读一次:读尺寸在 DOM 脏的时候会强制重排
734
+ const max = sh - el.clientHeight;
735
+ const gap = max - el.scrollTop;
736
+ const bottom = gap <= 24;
737
+ const follow = state.autoFollow !== false && (bottom || el.dataset.dshsfFollow === "1");
738
+ // 流式输出是一行一行来的:max-height 跟住内容高度(长出来而不是跳一格)
739
+ const rect = el.getBoundingClientRect();
740
+ const top = rect.top;
741
+ const entering = el.dataset.dshsfEntering === "1";
742
+ // 入场动画期间渲染高度归 CSS 动画管:这段时间目标高度不许超过动画目标 —— 否则动画一结束,我们早就推到上限的
743
+ // inline 值整块落地(渲染高度从动画值一步跳到上限),窗就在那一刻"掉下去"。
744
+ const cap = entering ? Math.max(WINDOW_MIN_H, Number(el.dataset.dshsfEnterH) || 0) : windowCap();
745
+ const want = el.dataset.dshsfClosing === "1" ? null : Math.min(sh, cap, pinBottom ? Infinity : roomAbove(top, bound));
746
+ // 回底按钮查登记表(jumpOf),不再每帧每窗查一次 DOM:只要它还挂在窗口里就直接用,
747
+ // 不在(被 React 挪走/删掉)才走 ensureWindowJump 的完整自愈(那里面有查询 + 去重 + 补挂)。
748
+ const knownJump = jumpOf.get(el);
749
+ const jump = knownJump !== undefined && knownJump.isConnected && knownJump.parentElement === el ? knownJump : null;
750
+ plan.push({ el, jump, bottom, gap, follow, cur: el.scrollTop, want, top, renderedH: rect.height, entering });
751
+ }
752
+ let over = 0; // 这一帧被小窗长出去、越过下边界的量:跟随中要用同量滚动补掉(底边钉住)
753
+ for (const item of plan) {
754
+ if (item.want !== null) {
755
+ const known = item.el.dataset.dshsfH;
756
+ const cur = known === undefined ? item.want : Number(known);
757
+ const gap = item.want - cur;
758
+ if (Math.abs(gap) > 0.5) {
759
+ const step = Math.min(Math.abs(gap), Math.max(minStep(dt), Math.abs(gap) * smoothGrow() * (dt / 16)));
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";
764
+ 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
+ }
775
+ }
776
+ // 跟随中:朝底部"追"过去,而不是直接跳到 max —— 内容才有顺流而下的感觉
777
+ if (item.follow && item.gap > 0.5) {
778
+ const step = Math.min(item.gap, Math.max(minStep(dt), item.gap * smoothGrow() * (dt / 16)));
779
+ const next = item.cur + step;
780
+ item.el.scrollTop = next;
781
+ item.el.dataset.dshsfMine = String(next); // 记下"这是我写的",scroll 监听据此分辨是不是读者
782
+ moving = true;
783
+ }
784
+ if (item.bottom && item.el.dataset.dshsfTextwindow !== "1" && item.el.dataset.dshsfFollow !== "1") item.el.dataset.dshsfFollow = "1"; // 正文窗永不自开跟底(跟底 = 只看得到尾巴)
785
+ item.el.toggleAttribute("data-dshsf-offbottom", item.gap > 24);
786
+ if (!item.jump) item.jump = ensureWindowJump(item.el); // 自愈:登记过的窗任何时候都必须有按钮
787
+ if (item.jump) {
788
+ // 读者接管了 → 给按钮;跟随中只有"落后很多"才给(追赶时的轻微落后不该让按钮一闪一闪)
789
+ // 显隐带滞回:点亮要"读者接管或落后很多",熄灭只在真正回到贴底(gap<12)——
790
+ // 跟随状态/差距在阈值附近抖动时按钮不该跟着闪。
791
+ if (state.showJumpButton === false) item.jump.removeAttribute("data-dshsf-visible");
792
+ else if (item.jump.hasAttribute("data-dshsf-visible")) { if (item.gap < 12) item.jump.removeAttribute("data-dshsf-visible"); }
793
+ else if (item.gap > 24 && (!item.follow || item.gap > 120)) item.jump.setAttribute("data-dshsf-visible", "");
794
+ }
795
+ }
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
+ if (moving) ensureTickLoop();
807
+ return moving;
808
+ }
809
+
810
+ /* -------------------------------------------------------------- 折叠 -- */
811
+ const manualState = new Map(); // turn -> "open" | "fold"
812
+
813
+ /** 会话切换:manualState 按轮号存、wasRunning/lastRunningAt 是模块级 —— 不清就会串到新会话:
814
+ * 新会话同号轮次继承 "fold"(运行中那一轮被瞬间折掉),或让新会话误触发"跑完回到提问处"。
815
+ * 会话 id 由官方挂在对话根上([data-conversation-session])。 */
816
+ let sessionKey = null;
817
+ function syncSession() {
818
+ // 同上:只在"当前会话"里找,document 序第一个可能正是不相关的那个会话根
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]");
827
+ const key = holder ? holder.getAttribute("data-conversation-session") : null;
828
+ if (key === null || key === "") return; // 当前视图没有会话信息(设置页/嵌入态):状态不动
829
+ if (sessionKey === null) { sessionKey = key; return; }
830
+ if (key === sessionKey) return;
831
+ sessionKey = key;
832
+ manualState.clear();
833
+ snapOwn = false; // 换会话:位置所有权归零(新会话的首次贴底让官方做)
834
+ glide = null; // 滑行 / 钉住补偿 / 记下的写入位置全是上一会话的:留着会继续把新会话的视角拖走
835
+ anchorStop();
836
+ followWrote = null;
837
+ announcedTop = -1;
838
+ announcedHeight = -1;
839
+ followHeight = -1;
840
+ wasRunning = false;
841
+ lastRunningAt = 0;
842
+ runningSticky = false;
843
+ // 换会话必须把上一会话的「收尾折叠 hold」清掉:data-chat-turn 每会话都从 1 起,
844
+ // 留着它会让新会话里同号的历史轮被当成"刚跑完那轮"——那不是不折,是主动展开(走 toUnfoldAll)。
845
+ settleHoldTurn = null;
846
+ settleHoldUntil = 0;
847
+ textCache = new WeakMap(); // 换会话:行节点会被复用,正文缓存整表作废
848
+ clearTimeout(runGraceTimer);
849
+ for (const chip of document.querySelectorAll("[data-dshsf-chip]")) { try { chip.remove(); } catch { /* ignore */ } }
850
+ }
851
+ /** 折叠/展开一行:用真实高度驱动 max-height,动画只在用户交互后启用。 */
852
+ /** 折叠动画播完再挂 content-visibility(挂早了动画直接被跳过,看着就是硬切)。 */
853
+ const SKIP = "until-found";
854
+ const isSkipped = (row) => row.getAttribute("hidden") === SKIP;
855
+ /* 官方 useSearchableHidden 也用 hidden="until-found"(同一个字面量,见内核 ui-chat),
856
+ 所以摘的时候必须认归属:只摘我们自己挂过的那一份,别把官方设的也一起摘了
857
+ (React 的 layout effect 只在依赖变化时重设,被外部摘掉不会自愈)。 */
858
+ const ownsSkip = (el) => el.hasAttribute("data-dshsf-hidden");
859
+ function setSkipHidden(el) { el.setAttribute("data-dshsf-hidden", ""); el.setAttribute("hidden", SKIP); }
860
+ function clearSkipHidden(el) {
861
+ if (!ownsSkip(el)) return;
862
+ el.removeAttribute("data-dshsf-hidden");
863
+ el.removeAttribute("hidden");
864
+ }
865
+ function skipSoon(row) {
866
+ setTimeout(() => {
867
+ if (!row.hasAttribute("data-dshsf-folded")) return;
868
+ setSkipHidden(row);
869
+ for (const el of row.querySelectorAll("[data-dshsf-window]")) liveWindows.delete(el); // 不渲染的窗不必每帧跟进
870
+ }, 320);
871
+ }
872
+ function applyFold(row, fold, animate) {
873
+ if (!row.hasAttribute("data-dshsf-row")) row.setAttribute("data-dshsf-row", "");
874
+
875
+ const folded = row.hasAttribute("data-dshsf-folded");
876
+ if (fold === folded) {
877
+ if (fold && !isSkipped(row)) skipSoon(row); // 已经是折的状态(热重载/旧状态)也补挂
878
+ return;
879
+ }
880
+ if (!animate || (animBudget <= 0 && !scanAnimateAll)) { // 配额用完 → 直接切;用户手点那次(scanAnimateAll)不看配额,展开方向也得看得见
881
+ // 瞬时路径:不读、不 flush,一次布局都不做
882
+ row.style.maxHeight = "";
883
+ row.style.overflow = "";
884
+ row.toggleAttribute("data-dshsf-folded", fold);
885
+ if (fold) setSkipHidden(row);
886
+ else { clearSkipHidden(row); delete row.dataset.dshsfCards; } // 展开后子树可能再变:计数缓存作废
887
+ return;
888
+ }
889
+ animBudget -= 1;
890
+ row.style.overflow = "hidden";
891
+ if (fold) {
892
+ layoutReads += 1;
893
+ row.style.maxHeight = row.getBoundingClientRect().height + "px";
894
+ void row.offsetHeight;
895
+ row.setAttribute("data-dshsf-folded", "");
896
+ row.style.maxHeight = "0px";
897
+ skipSoon(row);
898
+ return;
899
+ }
900
+ 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
+ row.removeAttribute("data-dshsf-folded");
905
+ delete row.dataset.dshsfCards;
906
+ row.style.maxHeight = "";
907
+ layoutReads += 1;
908
+ const height = row.getBoundingClientRect().height;
909
+ if (height > ANIM_MAX_PX) { row.style.overflow = ""; return; } // 太高的行直接展开,省掉 0.3s 的逐帧重排
910
+ row.style.maxHeight = "0px";
911
+ void row.offsetHeight;
912
+ requestAnimationFrame(() => { row.style.maxHeight = height + "px"; });
913
+ setTimeout(() => { if (!row.hasAttribute("data-dshsf-folded")) { row.style.maxHeight = ""; row.style.overflow = ""; } }, 340);
914
+ }
915
+ /**
916
+ * 批量折叠:先全部量高 → 写起始 max-height → 一次 flush → 写终值。
917
+ * 逐个"量一个写一个"会让每次量都强制一次整树重排:一次折叠几十行就是几十次重排
918
+ * (实测这台 i5-2300 上一次扫描 885ms,全在 syncTurns 里)。
919
+ */
920
+ function foldBatch(rows, animate, force) {
921
+ const targets = rows.filter((row) => !row.hasAttribute("data-dshsf-folded"));
922
+ if (targets.length === 0) return;
923
+ // force:刚跑完那一轮的收尾折叠 —— 你此刻可能已经滚到别处(settle 因此不触发),但它仍是「刚看着跑完的那一轮」,
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; }
928
+ layoutReads += 1;
929
+ const heights = targets.map((row) => row.getBoundingClientRect().height); // ① 只读
930
+ for (let i = 0; i < targets.length; i += 1) { // ② 写起始值
931
+ if (!targets[i].hasAttribute("data-dshsf-row")) targets[i].setAttribute("data-dshsf-row", "");
932
+ targets[i].style.overflow = "hidden";
933
+ targets[i].style.maxHeight = heights[i] + "px";
934
+ }
935
+ layoutReads += 1;
936
+ void targets[0].offsetHeight; // ③ 一次 flush,让过渡有起点
937
+ for (const row of targets) { // ④ 写终值
938
+ row.setAttribute("data-dshsf-folded", "");
939
+ row.style.maxHeight = "0px";
940
+ skipSoon(row);
941
+ }
942
+ }
943
+ /** 数一段子树里的匹配元素:嵌在同类里的(工具卡套工具卡)只算最外层那一个。 */
944
+ function countCards(row, selector) {
945
+ cost.counts.cards = (cost.counts.cards || 0) + 1;
946
+ const t = performance.now();
947
+ let n = 0;
948
+ for (const el of row.querySelectorAll(selector)) {
949
+ const outer = el.parentElement && el.parentElement.closest(selector);
950
+ if (outer && row.contains(outer)) continue;
951
+ n += 1;
952
+ }
953
+ cost.phases.cards = +((cost.phases.cards || 0) + (performance.now() - t)).toFixed(2);
954
+ return n;
955
+ }
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
+ function unfoldAll(container) {
975
+ for (const row of container.querySelectorAll("[data-dshsf-row]")) {
976
+ clearSkipHidden(row);
977
+ if (!disposed) for (const el of row.querySelectorAll("[data-dshsf-window]")) wireWindow(el, el.dataset.dshsfFollow !== ""); // 同上:展开后接回每帧清单(卸载中不接线)
978
+ row.removeAttribute("data-dshsf-folded");
979
+ delete row.dataset.dshsfCards;
980
+ row.style.maxHeight = "";
981
+ row.style.overflow = "";
982
+ }
983
+ }
984
+ function clearChips(container) {
985
+ for (const chip of container.querySelectorAll("[data-dshsf-chip]")) chip.remove();
986
+ }
987
+
988
+ /* ------------------------------------------------------ 搜索准备(Ctrl+F) -- */
989
+ /**
990
+ * 折回一行摘要的思考,正文是被 React 卸载的(DisclosureRow 的 children 条件挂载)——不在 DOM 里,
991
+ * 浏览器自然搜不到。Ctrl+F 时把正文补挂载出来,再用 hidden=until-found 藏回去:
992
+ * 界面外观不变、渲染照样跳过,但内容回到 DOM,能被搜到(命中时 beforematch 把它亮出来)。
993
+ * 工具卡的正文同理,但一次要渲染几百个大块,代价太高,这里只补思考。
994
+ */
995
+ let searchPrepRunning = false;
996
+ let searchPrepTimer = 0; // 分片挂载的定时器句柄:卸载/热重载要能停掉,否则剩余分片继续展开+藏正文
997
+ function hideForSearch(body) {
998
+ if (!body || body.getAttribute("hidden") === SKIP) return; // 已经藏着(含官方挂的那份):照样能搜,别再动
999
+ setSkipHidden(body); // 认归属:摘的时候只摘我们自己挂的那份
1000
+ body.dataset.dshsfSearch = "1";
1001
+ liveWindows.delete(body); // 不渲染的窗不必每帧跟进(与折叠行同一套处理)
1002
+ }
1003
+ /** 搜索用的隐藏标记要撤掉(被搜到 / 用户自己点开 / 手动展开这一轮)。 */
1004
+ function clearSearchHidden(scope) {
1005
+ if (!scope || !scope.querySelectorAll) return;
1006
+ for (const body of scope.querySelectorAll("[data-dshsf-search]")) {
1007
+ body.removeAttribute("data-dshsf-search");
1008
+ clearSkipHidden(body); // 同上:官方 useSearchableHidden 的 hidden 归官方
1009
+
1010
+ wireWindow(body); // 重新回到每帧清单(它会从 16px 平滑长到窗口上限)
1011
+ }
1012
+ }
1013
+ function searchPrep() {
1014
+ if (disposed || searchPrepRunning || state.mode !== "fold") return; // 官方档位不接管 Ctrl+F
1015
+ const flow = chatScope();
1016
+ const roots = Array.from(flow.querySelectorAll(THINK)).filter((root) => !thinkOpen(root));
1017
+ if (roots.length === 0) return;
1018
+ searchPrepRunning = true;
1019
+ let cursor = 0;
1020
+ const chunk = () => {
1021
+ searchPrepTimer = 0;
1022
+ if (disposed) { searchPrepRunning = false; return; } // 卸载/热重载:剩余分片不许再动 DOM
1023
+ let done = 0;
1024
+ while (cursor < roots.length && done < 12) { // 分片挂载:别让 Ctrl+F 卡住主线程
1025
+ const root = roots[cursor++];
1026
+ done += 1;
1027
+ if (!root.isConnected || thinkOpen(root)) continue;
1028
+ if (!clickOnce(disclosureTarget(root))) continue; // 合成 click → React 挂载正文
1029
+ root.dataset.dshsfKeepOpen = "1"; // 别让扫描当成"我方开的"又收回去(收回去正文又没了)
1030
+ hideForSearch(root.querySelector(THINK_BODY)); // 挂上就藏回去,界面外观不变(隐藏的前提是 until-found 所以照样能搜)
1031
+ // 行整行藏着(折叠轮次)时不必再藏正文,但正文同样挂上了 —— 展开那一轮时它才现形
1032
+ }
1033
+ if (cursor < roots.length) searchPrepTimer = setTimeout(chunk, 0);
1034
+ else searchPrepRunning = false;
1035
+ };
1036
+ chunk();
1037
+ }
1038
+
1039
+ /** 会话里的全部行(文档顺序)。
1040
+ * 0.1.5:行是 `[data-chat-flow]` 的直接子节点;0.1.7 把每个「过程组」各自包了一层
1041
+ * `[data-chat-flow]`(组内还有自己的滚动体),按 children 取只能看到一个组 —— 所以在
1042
+ * 主滚动容器里按属性收,两个版本都对。 */
1043
+ function chatRows() {
1044
+ const scope = mainScroller() || document;
1045
+ const rows = Array.from(scope.querySelectorAll("[data-chat-flow-kind]"));
1046
+ if (rows.length) lastRowSeen = rows[rows.length - 1]; // 供帧路径做缓存校验:省掉每帧一次全文档查询
1047
+ return rows;
1048
+ }
1049
+ /** 这一轮是否已经跑完:官方在 turn/end 之后才给该轮的 footer 行加 data-actions-reveal
1050
+ * (跑动中的那一轮不渲染 actions)。这是"跑完"的确定性标记 —— 比流式/工具状态稳得多:
1051
+ * 模型在两步之间、工具执行期间都没有流式标记,靠它们判断会在运行中来一次折叠。
1052
+ * 0.1.7 的 tail 行:<div data-turn-tail="<轮次>" data-actions-reveal="always|hover">。 */
1053
+ function turnClosed(turn) {
1054
+ if (turn === null || turn === undefined || turn === "") return false;
1055
+ // 作用域必须跟折叠/滚动一致:右栏开着子代理会话时页面上同时有两个会话根(ui-subagent 用
1056
+ // variant:"embedded" 走同一套骨架),而轮号两边都从 1 开始撞号 —— document 上查到的那条 tail
1057
+ // 未必属于本会话(会让"正在跑的这一轮"被误判成已收尾 → 运行中折一次)。
1058
+ const tail = chatScope().querySelector('[data-turn-tail="' + turn + '"]');
1059
+ return tail !== null && tail.hasAttribute("data-actions-reveal");
1060
+ }
1061
+ /** 本轮正式回答行 = 最后一个「有正文」的 assistant-step 行(没有正文行才退回最后一个 assistant-step)。
1062
+ * 0.1.7 会把一个 step 拆成 reasoning / response 两个行,只看"最后一个"未必落在正文上。
1063
+ * 按轮次 memo:一次扫描里 syncTurns 与 syncTextWindows 都要用,避免双倍整树遍历。 */
1064
+ let answerRowCache = new Map();
1065
+ function answerRowOf(turnRows) {
1066
+ const key = turnRows.length ? turnRows[0].getAttribute("data-chat-turn") : null;
1067
+ if (key !== null && answerRowCache.has(key)) return answerRowCache.get(key);
1068
+ const assistantRows = turnRows.filter((row) => rowKind(row) === "assistant-step");
1069
+ const textRows = assistantRows.filter((row) => hasText(row));
1070
+ const answerRow = textRows.length ? textRows[textRows.length - 1] : (assistantRows.length ? assistantRows[assistantRows.length - 1] : null);
1071
+ if (key !== null) answerRowCache.set(key, answerRow);
1072
+ return answerRow;
1073
+ }
1074
+ function collectTurns() {
1075
+ const rows = chatRows();
1076
+ const turns = new Map();
1077
+ for (const row of rows) {
1078
+ const turn = row.getAttribute("data-chat-turn") || "0";
1079
+ if (!turns.has(turn)) turns.set(turn, []);
1080
+ turns.get(turn).push(row);
1081
+ }
1082
+ // 运行中那一轮 = 带运行标记的那一行所属的轮次。比"最后一行"可靠:0.1.7 会往末尾补
1083
+ // 独立的 tail / 收尾行,它们的 data-chat-turn 未必等于正在跑的那一轮。
1084
+ const mark = chatScope().querySelector(RUNNING);
1085
+ const markRow = mark && mark.closest ? mark.closest(ROW) : null;
1086
+ const runningTurn = markRow ? markRow.getAttribute("data-chat-turn") : null;
1087
+ return { rows, turns, lastTurn: rows.length ? rows[rows.length - 1].getAttribute("data-chat-turn") : null, runningTurn };
1088
+ }
1089
+
1090
+ function syncTurns(flow, running, collected) {
1091
+ const { turns, lastTurn, runningTurn } = collected || collectTurns();
1092
+ const animate = state.animations !== false && allowAnim;
1093
+ // 批量折叠收集到"整个扫描"这一层:foldBatch 每次要"读一遍 + flush 一遍"布局,
1094
+ // 逐轮调用 = 每轮两遍(18 轮就 36 遍):单次扫描的主要成本都在这里
1095
+ const toFoldAll = [];
1096
+ const toFoldForce = []; // 刚跑完那一轮的过程行:强制播动画(视野外也要有动效)
1097
+ const toUnfoldAll = [];
1098
+ const thinkFoldsAll = [];
1099
+ let tFilter = 0; let tRows = 0; let tChip = 0;
1100
+ let forgeBody = null; // 本轮答案行的正文(写头所在),锻打火星挂它
1101
+ for (const [turn, turnRows] of turns) {
1102
+ const tF = performance.now();
1103
+ const isRunningTurn = running && turn === (runningTurn === null || runningTurn === undefined ? lastTurn : runningTurn);
1104
+ const manual = manualState.get(turn);
1105
+ const assistantRows = turnRows.filter((row) => rowKind(row) === "assistant-step");
1106
+ const answerRow = answerRowOf(turnRows);
1107
+ // 锻打锚点用**整行**(flowItem 在一轮里是稳定的),不用会随 React 重渲染被换掉的段落:
1108
+ // 段落一换,火星就得等下一次扫描才回得来(看着像闪);行稳定则挂上去的画布一直有效。
1109
+ if (isRunningTurn && answerRow) forgeBody = answerRow;
1110
+ // 可折叠候选:过程行(答案行 / 独立行除外;按设置保留穿插正文行)
1111
+ const foldableSet = new Set();
1112
+ const foldable = turnRows.filter((row) => {
1113
+ if (row === answerRow || INDEPENDENT.has(rowKind(row))) return false;
1114
+ if (state.keepInterleavedText !== false && rowKind(row) === "assistant-step" && hasText(row)) return false;
1115
+ foldableSet.add(row);
1116
+ return true;
1117
+ });
1118
+ // 刚跑完的那一轮先不折:等回到提问处的滑行结束再折(probe().settleLog 能看到这条 hold)。
1119
+ const heldForSettle = settleHoldTurn !== null && String(turn) === settleHoldTurn;
1120
+ const forceAnim = justFinishedTurn !== null && String(turn) === justFinishedTurn;
1121
+ const shouldFold = !heldForSettle && (manual === "fold"
1122
+ || (manual !== "open" && !isRunningTurn && (state.foldHistory !== false || running)));
1123
+ tFilter += performance.now() - tF;
1124
+ const tR = performance.now();
1125
+ let folded = 0;
1126
+ let thinks = 0; // 折起来的思考段数
1127
+ let tools = 0; // 折起来的工具调用数
1128
+ for (const row of turnRows) {
1129
+ const fold = shouldFold && foldableSet.has(row);
1130
+ const isFolded = row.hasAttribute("data-dshsf-folded");
1131
+ if (fold === isFolded) {
1132
+ if (fold && !isSkipped(row)) skipSoon(row);
1133
+ if (!row.hasAttribute("data-dshsf-row")) row.setAttribute("data-dshsf-row", "");
1134
+ } else if (fold) { if (forceAnim) toFoldForce.push(row); else toFoldAll.push(row); }
1135
+ else toUnfoldAll.push(row);
1136
+ if (fold) {
1137
+ folded += 1;
1138
+ const cards = cardsOf(row, turn);
1139
+ thinks += cards[0];
1140
+ tools += cards[1];
1141
+ }
1142
+ // 非运行中的轮次:思考回到一行摘要(我只收自己展开的那些)。
1143
+ // 冷行(不在最近两轮)+ 已经折着 → 内容既不变也不渲染,没什么可收的:跳过整棵子树查询。
1144
+ // (实测 18 轮时每扫描 37 次 querySelectorAll(THINK) ≈ 2N+1,长会话下是 phases.turnRows 的主要成分。)
1145
+ if (!isRunningTurn && rowKind(row) === "assistant-step" && !(fold && isFolded && !hotRows.has(row))) {
1146
+ for (const root of row.querySelectorAll(THINK)) {
1147
+ // 行没被折掉(答行、保留的穿插正文行)时,它里面的思考也得跟着收:
1148
+ // 折叠状态下连"一行摘要"都不该露头;用户手动展开本轮(manual=open)时还原成可点摘要
1149
+ if (!fold) {
1150
+ if (shouldFold) thinkFoldsAll.push(root); // 收集起来批量折:逐个折 = 每个 2 次强制重排
1151
+ else applyFold(root, false, animate);
1152
+ }
1153
+ if (!thinkOpen(root)) { delete root.dataset.dshsfAuto; continue; }
1154
+ if (closeBudget <= 0) { busyWork = true; break; }
1155
+ if (closeThink(root)) closeBudget -= 1;
1156
+ }
1157
+ }
1158
+ }
1159
+ tRows += performance.now() - tR;
1160
+ const tC = performance.now();
1161
+ // 折叠条:有可折叠内容就给,位置固定在该轮过程的最上面
1162
+ const chipId = "dshsf-chip-" + turn;
1163
+ const existing = flow.querySelector("#" + chipId);
1164
+ // 运行中的轮次不给折叠条:跑动过程中不该冒出"收起本轮过程"这种按钮
1165
+ // (用户手动折过的例外 —— 否则没有入口再展开)
1166
+ if (foldable.length === 0 || (isRunningTurn && manual !== "fold")) { if (existing) existing.remove(); continue; }
1167
+ let chip = existing;
1168
+ if (!chip) {
1169
+ chip = document.createElement("button");
1170
+ chip.type = "button";
1171
+ chip.id = chipId;
1172
+ chip.dataset.dshsfChip = turn;
1173
+ chip.addEventListener("click", () => {
1174
+ const anyFolded = flow.querySelector('[data-chat-turn="' + turn + '"][data-dshsf-folded]') !== null;
1175
+ manualState.set(turn, anyFolded ? "open" : "fold");
1176
+ allowAnim = true;
1177
+ scanAnimateAll = true; // 用户手点的折叠:即使整轮一起折也要看得到动效
1178
+ releaseSettleHold(); // 用户的点击压过「刚跑完那轮先别折」的 hold:否则这一下会被吞掉一次扫描
1179
+ followOn = false; // 先撒手再展开:跟随会把刚展开的内容拖回底部,按钮就飞了
1180
+ const before = chip.getBoundingClientRect().top;
1181
+ scan();
1182
+ anchorDuring(chip, before, 500);
1183
+ });
1184
+ }
1185
+ const anchor = turnRows.find((row) => !INDEPENDENT.has(rowKind(row))) || turnRows[0];
1186
+ if (anchor && chip.nextElementSibling !== anchor && anchor.parentElement) anchor.parentElement.insertBefore(chip, anchor);
1187
+ const summary = [];
1188
+ // 量纲:thinks 是思考**块**数(一轮可能多段),不是轮数;文案一律走 uiText,英文界面不再露中文
1189
+ if (thinks > 0) summary.push(uiText(thinks + " 段思考", thinks + " thinking block" + (thinks > 1 ? "s" : "")));
1190
+ if (tools > 0) summary.push(uiText(tools + " 次工具调用", tools + " tool call" + (tools > 1 ? "s" : "")));
1191
+ const label = folded > 0
1192
+ ? uiText("已折叠 " + (summary.length ? summary.join(" · ") : folded + " 行") + " · 点击展开",
1193
+ "Collapsed " + (summary.length ? summary.join(" · ") : folded + " rows") + " · click to expand")
1194
+ : uiText("收起本轮过程", "Collapse this turn");
1195
+ if (chip.textContent !== label) chip.textContent = label; // 没变就别写:textContent 赋值是一次真实 DOM 变更
1196
+ tChip += performance.now() - tC;
1197
+ }
1198
+ sparks.forge(forgeBody); // 只有运行中才有写头;跑完自动撒手(火星烧完即摘画布)
1199
+ const tB = performance.now();
1200
+ foldBatch(toFoldForce, animate, true); // 刚跑完那一轮:哪怕在视野外也播动画
1201
+ foldBatch(toFoldAll.concat(thinkFoldsAll), animate); // 其余整批:整个扫描读一遍 + flush 一遍
1202
+ for (const row of toUnfoldAll) applyFold(row, false, animate);
1203
+ cost.phases.turnFilter = +tFilter.toFixed(2);
1204
+ cost.phases.turnRows = +tRows.toFixed(2);
1205
+ cost.phases.turnChip = +tChip.toFixed(2);
1206
+ cost.phases.turnBatch = +(performance.now() - tB).toFixed(2);
1207
+ }
1208
+
1209
+ /**
1210
+ * 把被点的那一行钉在原来的屏幕位置,覆盖整段展开动画——不然内容一长,按钮就飞了、没法接着读。
1211
+ * 关键是"同步补":React 提交、我方 scan 都在同一个任务里改布局,等到下一帧 rAF 再补,
1212
+ * 中间那一帧就会先画出去(看着闪一下)。所以这里给三处补偿:
1213
+ * ① anchorSync() 当场补(scan 之后 / 微任务里,都赶在绘制之前);
1214
+ * ② ResizeObserver 补——官方自己也在 ResizeObserver 里吸底(followRef: el.scrollTop = scrollHeight),
1215
+ * 它的 observer 注册得比我们早、回调也就先跑;我们在同一轮投递里后补一次,仍赶在 paint 之前,
1216
+ * 所以它那一跳根本画不出去(这就是"闪一下"的来源);
1217
+ * ③ 逐帧补(折叠/展开是 CSS 过渡,高度要长 340ms);
1218
+ * ④ 期间关掉浏览器自带的滚动锚定——这两件事会互相打架。
1219
+ */
1220
+ let anchorCtl = null; // { el, top, until, scroller, prevAnchor, announced }
1221
+ let anchorRO = null; // 布局一变就补:RO 回调在 layout 之后、paint 之前,正好压住官方那个吸底跳转
1222
+ function anchorStop() {
1223
+ if (!anchorCtl) return;
1224
+ try { anchorCtl.scroller.style.overflowAnchor = anchorCtl.prevAnchor || ""; } catch { /* ignore */ }
1225
+ try { if (anchorRO) anchorRO.disconnect(); } catch { /* ignore */ }
1226
+ anchorCtl = null;
1227
+ }
1228
+ function anchorSync() {
1229
+ if (disposed) { anchorStop(); return false; } // 卸载后旧实例不许再写 scrollTop
1230
+ if (!anchorCtl) return false;
1231
+ const { el, scroller } = anchorCtl;
1232
+ if (performance.now() > anchorCtl.until || !el.isConnected) { anchorStop(); return false; }
1233
+ const delta = el.getBoundingClientRect().top - anchorCtl.top;
1234
+ if (delta === 0) return false;
1235
+ scroller.scrollTop += delta;
1236
+ anchorCtl.top = el.getBoundingClientRect().top;
1237
+ if (!anchorCtl.announced) {
1238
+ anchorCtl.announced = true;
1239
+ try { scroller.dispatchEvent(new Event("scroll", { bubbles: true })); } catch { /* ignore */ }
1240
+ }
1241
+ return true;
1242
+ }
1243
+ function anchorDuring(node, before, ms) {
1244
+ const scroller = mainScroller();
1245
+ if (!scroller) return;
1246
+ glide = null; // 点开/收起 = "我要读这一处":一次性滑行必须让位(否则它每帧把视角拽向旧目标)
1247
+ releaseSettleHold();
1248
+ anchorStop();
1249
+ anchorCtl = { el: node, top: before, until: performance.now() + ms, scroller, prevAnchor: scroller.style.overflowAnchor, announced: false };
1250
+ try { scroller.style.overflowAnchor = "none"; } catch { /* ignore */ }
1251
+ try {
1252
+ if (!anchorRO) anchorRO = new ResizeObserver(() => anchorSync());
1253
+ anchorRO.disconnect();
1254
+ anchorRO.observe(node);
1255
+ const flow = chatScope();
1256
+ if (flow && flow !== document) anchorRO.observe(flow.firstElementChild || flow);
1257
+ } catch { /* 没有 ResizeObserver 就退回 rAF 逐帧补 */ }
1258
+ anchorSync();
1259
+ const step = () => {
1260
+ if (!anchorCtl || anchorCtl.el !== node) return; // 已被新的锚定取代
1261
+ anchorSync();
1262
+ if (performance.now() < anchorCtl.until) requestAnimationFrame(step);
1263
+ else anchorStop();
1264
+ };
1265
+ requestAnimationFrame(step);
1266
+ }
1267
+ /**
1268
+ * 我方即将让内容长高(自动展开小窗/卡片)时,主窗口继续保持贴底。
1269
+ * 官方贴底逻辑(onScrollRef)用当前 scrollTop 重算 isAtBottom = floor - scrollTop <= 25:
1270
+ * 内容长高而 scrollTop 没跟上,一旦差值 > 25 就被判成"读者离开了底部",这一轮后面都不再跟随。
1271
+ * 我们只在自己写过的位置上继续贴底,位置被人动过(你上滚)就立刻撒手。
1272
+ */
1273
+ const ANIM_BUDGET = 3; // 一次扫描最多播几个"高度动画":加载/切会话/收尾这类批量场景直接切,播放反而看不清
1274
+ const scanSamples = []; // 最近 N 次扫描的墙钟耗时(算分位数用;max 会被外部卡顿污染)
1275
+ const SCAN_SAMPLES = 40;
1276
+ let animBudget = ANIM_BUDGET;
1277
+ // 本次扫描是否属于「用户正看着的折叠」(点折叠条 / 跑完后的收尾折叠):这类即使目标多也要播动画;
1278
+ // 加载/切会话那种批量收尾仍走瞬时(见 ANIM_BUDGET 的注释)。
1279
+ let scanAnimateAll = false;
1280
+ const autoThinks = new Set(); // 我方自动展开过的思考根(只有它们需要"被取代后折回")
1281
+ const autoTools = new Set(); // 同上,工具卡
1282
+ let lastAnnounceAt = 0; // 上次补发 scroll 事件的时刻(压官方吸底用)
1283
+ let announcedHeight = -1; // 上次补发时的内容高度:长高那一帧必须补,等不了 400ms
1284
+ let announcedTop = -1; // 上次补发时的位置:位移太小时补发会踩到官方"读者没动过"那条分支
1285
+ let stealCount = 0; // 诊断:官方吸底抢跑、把我们写下的位置一把抹平的次数(正常应为 0)
1286
+ let undoCount = 0; // 诊断:抢跑在帧循环里被回补的次数(提交阶段直接贴底那条路)
1287
+ let guardCount = 0; // 诊断:抢跑在自家 ResizeObserver 里被回补的次数(官方吸底那条路)
1288
+ let moveCount = 0; // 诊断:帧循环真正推进过多少帧(流式期间应持续增长)
1289
+ const followFlips = []; // 诊断:followOn 最近几次翻转(按钮闪 = 跟随在抖 → 看这里)
1290
+ let lastFollowSeen = true;
1291
+ /** 记一次跟随翻转。why=谁翻的、top/wrote=翻转瞬间的位置与我方最后写入值(不相等 = 有别人动过)。 */
1292
+ function noteFlip(on, gap, info) {
1293
+ lastFollowSeen = on;
1294
+ followFlips.push({ t: Math.round(performance.now()), on, gap: Math.round(gap), ...info });
1295
+ if (followFlips.length > 10) followFlips.shift();
1296
+ }
1297
+ let stealMax = 0; // 诊断:单次抢跑最多把我们往前推了多少像素
1298
+ const stealTail = []; // 诊断:最近几次抢跑的像素数(判断是"小幅抖动"还是"整段抹平")
1299
+ let layoutReads = 0; // 扫描里"读布局"的次数:DOM 脏的时候每读一次就强制一遍重排
1300
+ let tickCount = 0; // 帧循环跑过多少次(诊断:看优化前后量级)
1301
+ let announceCount = 0; // 补发过多少次 scroll 事件(诊断用)
1302
+ const ANNOUNCE_MS = 400;
1303
+ let followOn = true; // 主窗口是否跟随底部:真实输入关掉,回到贴底自动恢复
1304
+ let followWrote = null; // 我方最后一次写入的位置:用来分辨这个 scroll 是不是我们造成的
1305
+ let followScroller = null;
1306
+ let scrollIntentAt = 0; // 最近一次"读者真的要滚动"的输入时刻
1307
+ let followHeight = -1; // 上一次 tick 时的内容高度:位置变了但长度没变 = 有人在跳转,不是布局
1308
+ /**
1309
+ * 只有真实输入才算读者滚动。展开/折叠、浏览器滚动锚定(内容在视口上方长高时自动调 scrollTop)
1310
+ * 都会产生 scroll 事件——位置变了不等于人动了,否则点一次展开就会把贴底取消掉。
1311
+ */
1312
+ const USER_SCROLL_MS = 600;
1313
+ function noteScrollIntent(event) {
1314
+ if (event && event.type === "keydown") {
1315
+ const target = event.target;
1316
+ if (target instanceof Element && target.closest("input, textarea, [contenteditable]")) return;
1317
+ if (!/^(ArrowUp|ArrowDown|PageUp|PageDown|Home|End| )$/.test(event.key)) return;
1318
+ }
1319
+ scrollIntentAt = performance.now();
1320
+ }
1321
+ /** 点在滚动条上时事件目标就是滚动容器自身(点内容不会)。 */
1322
+ function onScrollerPointerDown(event) { if (event.target === event.currentTarget) noteScrollIntent(event); }
1323
+ /** 每帧一次的主窗口跟随:官方只在 ResizeObserver 回调且"没在采样"时贴底,靠不住;
1324
+ * 我们每帧贴到 floor(同值写入不产生 scroll 事件),读者一动就关、回到贴底再自动恢复。 */
1325
+ /** 非折叠模式 = 插件整体撒手:清掉我们加过的标记/自绘控件/每帧清单,并把滚动接管还给官方。 */
1326
+ function standDown(flow) {
1327
+ if (flow) { unfoldAll(flow); clearChips(flow); }
1328
+ for (const el of document.querySelectorAll("[data-dshsf-window]")) {
1329
+ el.removeAttribute("data-dshsf-window");
1330
+ el.style.maxHeight = ""; el.style.overflow = "";
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 起,会撞)
1349
+ }
1350
+ function mainFollowTick(dt) {
1351
+ const scroller = mainScroller();
1352
+ if (!scroller) { hidePinBar(); return false; } // 不在会话视图(设置页/别的面板):body 上的固定层不能留着
1353
+ // 官方档位 = 插件整体撒手:不跟随、不补发 scroll、不钉提问、不接管官方按钮(帧循环随之自然停)。
1354
+ if (state.mode !== "fold") { releaseTakeover(); hidePinBar(); return false; }
1355
+ if (scroller !== followScroller) {
1356
+ if (followScroller) {
1357
+ followScroller.removeEventListener("scroll", onMainScroll);
1358
+ followScroller.removeEventListener("pointerdown", onScrollerPointerDown);
1359
+ }
1360
+ followScroller = scroller;
1361
+ guardComposer = undefined; // 首次扫描时 followScroller 还是 null,guardObserve 会把 composer 记成 null 并永久提前返回
1362
+ followScroller.addEventListener("scroll", onMainScroll, { passive: true });
1363
+ followScroller.addEventListener("pointerdown", onScrollerPointerDown, { passive: true });
1364
+ }
1365
+ takeOverSnap(scroller); // 幂等(同实例直接返回):从官方档切回来时要把 setter 重新装上
1366
+
1367
+ const sh = scroller.scrollHeight; // 每帧只读一次:DOM 脏时每次读都强制一次重排
1368
+ followHeight = sh; // scroll 事件据此判断"是布局长高了还是有人在跳"
1369
+ pinTick(scroller); // 置顶条:读一遍行/容器位置并摆好(固定层,不改会话布局)
1370
+ const floor = Math.max(0, sh - scroller.clientHeight);
1371
+ // 一次性平滑移动(跑完一轮 → 移到提问处;点置顶条也走这里):**必须在"跟随关掉"的早退之前**推进,
1372
+ // 否则 glide 会永久挂着(动效开着就永远不动、动效关了就瞬跳),还会把 guard 拖在激活态拽住视角。
1373
+ const gliding = glideTick(scroller, floor, dt);
1374
+ // 跟随关掉时:不再推进跟随,但置顶条与回底按钮的显隐仍归帧循环管(否则按钮被 CSS 永久藏着)
1375
+ if (state.autoFollow === false) {
1376
+ if (!gliding) updateJumpGate(floor - scroller.scrollTop, false); // 滑动期间不改按钮显隐(和跟随时一致)
1377
+ return gliding;
1378
+ }
1379
+ // 官方还有几条不经过采样标记、直接贴底的路:React 提交阶段的 `tipMoved && atBottomRef → toBottom`、
1380
+ // 它自己 ResizeObserver 里的 `followRef`、以及 onScroll 里"读者没动过"当场 sample → toBottom。
1381
+ // 只要 pending 被官方自己的 sample() 清掉,下一次提交/下一帧就会把我们攒的滞后一次抹平——
1382
+ // 用户看到的就是"正文每来一坨字跳一下"。这些写入都发生在绘制之前,所以在同一个绘制周期里
1383
+ // 把位置还给追赶点:视觉上只剩正常的逐帧推进,和官方谁先谁后无关。
1384
+ const top0 = scroller.scrollTop; // 抢跑检测读一次,下面回补后再重读一次就够
1385
+ if (followOn && followWrote !== null && followWrote < floor - 1 && top0 > followWrote + 1) {
1386
+ stealCount += 1;
1387
+ const px = Math.round(top0 - followWrote);
1388
+ if (px > stealMax) stealMax = px;
1389
+ stealTail.push(px);
1390
+ if (stealTail.length > 8) stealTail.shift();
1391
+ writeTop(scroller, followWrote); undoCount += 1; // 跟随中一律回补(原先「离底 ≤600px 才补」会把单帧长高 >600px 的那一下放弃)
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 */ }
1430
+ }
1431
+ return true;
1432
+ }
1433
+ function onMainScroll(event) {
1434
+ const el = event.currentTarget;
1435
+ if (followWrote === null || Math.abs(el.scrollTop - followWrote) > 2) { // 位置不是我写下的 → 不是我们自己造成的
1436
+ // 读者动过滚轮/触摸/翻页键/滚动条 → 撒手;没有输入信号但长度没变却动了位置 = 有人在跳转(导航轨/外部程序)→ 也算读者
1437
+ if (performance.now() - scrollIntentAt <= USER_SCROLL_MS || el.scrollHeight === followHeight) {
1438
+ if (followOn) noteFlip(false, el.scrollHeight - el.clientHeight - el.scrollTop, {
1439
+ why: "scroll", top: Math.round(el.scrollTop), wrote: followWrote === null ? null : Math.round(followWrote),
1440
+ h: el.scrollHeight, fh: followHeight, intent: Math.round(performance.now() - scrollIntentAt),
1441
+ });
1442
+ followOn = false;
1443
+ glide = null; // 读者明确接管:一次性滑行(每帧都会写回自己的目标)必须一起取消,否则滚了也被拖走
1444
+ releaseSettleHold();
1445
+ }
1446
+ // 其余:展开/折叠让内容长高、浏览器锚定顺手改 scrollTop —— 不是读者,保持贴底
1447
+ }
1448
+ ensureTickLoop(); // 回底按钮的显隐在帧循环里定:读者一滚就得排一帧(收敛后循环本来是停的)
1449
+ }
1450
+
1451
+ let scrollerCache = null; // 滚动容器几乎不变,但找它要逐级 getComputedStyle(每帧都做就是每秒上百次样式解析)
1452
+ let flowCache = null; // 会话根([data-chat-flow])与最后一行:帧路径每帧都要用,不能每帧全文档查
1453
+ let lastRowSeen = null;
1454
+ function chatFlow() {
1455
+ if (flowCache && flowCache.isConnected) return flowCache;
1456
+ // 右栏子代理(embedded)会话挂在 [data-sidebar-chat] 里(内核 dsh-client-ui-subagent 的 SidebarChatTab)。
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;
1464
+ return flowCache;
1465
+ }
1466
+ function mainScroller() {
1467
+ const flow = chatFlow();
1468
+ guardObserve(flow); // 顺带:这个函数每帧被调一次,是唯一稳定拿到 flow 的地方,guard 的 observer 就在这儿挂(缓存后不额外查 DOM)
1469
+ const probe = lastRowSeen && lastRowSeen.isConnected ? lastRowSeen : flow;
1470
+ if (scrollerCache && scrollerCache.isConnected && probe && scrollerCache.contains(probe)) return scrollerCache;
1471
+ // 取「最外层」可滚动祖先:0.1.7 每个过程组自带一个内滚动体,从行往上第一个撞到的不是会话滚动条
1472
+ let node = probe ? probe.parentElement : null;
1473
+ let found = null;
1474
+ while (node && node !== document.body && node !== document.documentElement) {
1475
+ if (/(auto|scroll)/.test(getComputedStyle(node).overflowY)) found = node;
1476
+ node = node.parentElement;
1477
+ }
1478
+ scrollerCache = found;
1479
+ return found;
1480
+ }
1481
+
1482
+ /** 查询用的「会话根」。0.1.7 里 `[data-chat-flow]` 是每个过程组各一个,拿它当范围只看到一个组;
1483
+ * 主滚动容器才是覆盖整个会话的那一层(0.1.5 上它同样包含全部行)。 */
1484
+ function chatScope() { return mainScroller() || document; }
1485
+
1486
+ /* ------------------------------------------------- 跑完一轮 → 移到提问处 -- */
1487
+ const SETTLE_PAD = 12; // 提问行停在视口顶端往下多少像素
1488
+ const SETTLE_MAX_MS = 1400; // 一次性滑动的兜底时长(正常 300~800ms 收敛)
1489
+ let glide = null; // { el, until }:正在做的一次性平滑移动(目标元素每帧重算:折叠/展开改了布局也跟得上)
1490
+ const settleLog = []; // 诊断:每次「跑完回到提问处」的判定(为什么没滑 / 滑到哪),probe().settleLog 可读
1491
+ const foldLog = []; // 诊断:每次批量折叠的目标数与走的是动画还是瞬时,probe().foldLog 可读
1492
+ function noteFold(entry) { foldLog.push(entry); if (foldLog.length > 10) foldLog.shift(); }
1493
+ function noteSettle(entry) { settleLog.push(entry); if (settleLog.length > 8) settleLog.shift(); }
1494
+ let settleHoldTurn = null; // 刚跑完的这一轮先别折:等「回到提问处」的滑行走完再折(折叠会改布局,边走边折会把人带偏)
1495
+ let justFinishedTurn = null; // 本次扫描里「刚跑完」的那一轮:它的收尾折叠哪怕在视野外也要播动画
1496
+ let settleHoldUntil = 0; // 兜底时限:滑行若被别处取消/丢失,最多拖到这时刻
1497
+ /** 放行被压住的那一轮折叠,并排一次扫描去折。 */
1498
+ function releaseSettleHold() {
1499
+ if (settleHoldTurn === null) return;
1500
+ settleHoldTurn = null; settleHoldUntil = 0;
1501
+ scanAnimateAll = true; // 跑完那轮的收尾折叠是用户正看着的:放行时让它播动画
1502
+ schedule();
1503
+ }
1504
+ let wasRunning = false; // 上一次扫描时这一轮是否在跑(true→false = 刚跑完)
1505
+ /** 元素顶端在滚动容器里的位置(绝对 scrollTop 坐标)。 */
1506
+ function viewTop(scroller, el) {
1507
+ return scroller.scrollTop + el.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
1508
+ }
1509
+ /** 会话里所有用户发言行(文档顺序):置顶条与「跑完回到提问处」共用。 */
1510
+ function userRows() {
1511
+ return chatRows().filter((el) => el.getAttribute("data-chat-flow-kind") === "user");
1512
+ }
1513
+ function lastUserRow(rows) {
1514
+ // 传进来的 collected.rows 是**全部**行:必须自己筛出用户行。
1515
+ // (0.5.2 那批「复用行快照」优化里漏了这一步,直接把最后一行当提问行 —— 于是滑行目标是回答行,
1516
+ // 表现就是「跑完回到提问处」失效。)
1517
+ const list = rows ? rows.filter((el) => el.getAttribute("data-chat-flow-kind") === "user") : userRows();
1518
+ return list.length === 0 ? null : list[list.length - 1];
1519
+ }
1520
+ /** 本轮跑完:平滑移到本轮提问处。关掉开关、读者已接管滚动、或找不到提问行时都不动。 */
1521
+ function settleToPrompt(flow, rows) {
1522
+ // 每个出口都记一条:probe().settleLog 直接说明「跑完」这次为什么没滑(设置关 / 读者接管 / 没容器 / 没提问行)
1523
+ const mark = (why, extra) => noteSettle(Object.assign({ at: Math.round(performance.now()), why, followOn, autoFollow: state.autoFollow !== false, mode: state.mode }, extra || {}));
1524
+ if (state.settleToPrompt === false) return mark("setting-off");
1525
+ const scroller = mainScroller();
1526
+ if (scroller === null) return mark("no-scroller");
1527
+ // 中途滚过不管:只看「结束这一刻」是不是还在触底跟随状态(跟随开着,或本来就贴着底)。
1528
+ const gap = Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop);
1529
+ if (!(followOn || gap <= 25)) return mark("not-at-bottom", { gap: Math.round(gap) });
1530
+ const row = lastUserRow(rows);
1531
+ if (row === null) return mark("no-user-row", { rows: rows ? rows.length : -1 });
1532
+ const from = Math.round(scroller.scrollTop);
1533
+ const floor = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
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 });
1542
+ }
1543
+
1544
+ /** 推进一次性平滑移动:目标每帧重算(折叠/展开改了布局也追得上),和跟随同一条追赶公式。
1545
+ * 返回"这一帧动了吗"——调用方据此跳过跟随并让帧循环继续。与「触底自动跟随」开关无关。 */
1546
+ function glideTick(scroller, floor, dt) {
1547
+ if (glide === null) return false;
1548
+ // 注意:滑行结束时要把 followWrote 一起清掉。滑行每帧都写 followWrote,收尾那帧 gliding 已经是 false,
1549
+ // 于是「贴底恢复跟随」判据里的 mine 仍为真 —— 目标离底 ≤25px 时滑完会被重新武装跟随、又吸回底部。
1550
+ if (!glide.el.isConnected || performance.now() > glide.until) {
1551
+ noteSettle({ at: Math.round(performance.now()), why: glide.el.isConnected ? "glide-timeout" : "glide-el-gone", from: glide.from, want: glide.want, now: Math.round(scroller.scrollTop), ms: Math.round(performance.now() - (glide.t0 || 0)) });
1552
+ glide = null; followWrote = null; releaseSettleHold(); return false; // 滑行结束不再认领跟随写入点,见下
1553
+ }
1554
+ const want = Math.min(floor, Math.max(0, viewTop(scroller, glide.el) - SETTLE_PAD));
1555
+ const dy = want - scroller.scrollTop;
1556
+ if (Math.abs(dy) <= 0.5) { noteSettle({ at: Math.round(performance.now()), why: "glide-done", from: glide.from, want, now: Math.round(scroller.scrollTop), ms: Math.round(performance.now() - (glide.t0 || 0)) }); glide = null; followWrote = null; releaseSettleHold(); return false; }
1557
+ const step = Math.min(Math.abs(dy), Math.max(minStep(dt), Math.abs(dy) * smoothGrow() * (dt / 16)));
1558
+ const next = scroller.scrollTop + Math.sign(dy) * step;
1559
+ writeTop(scroller, next);
1560
+ followWrote = next; // 同上:写完不读回
1561
+ moveCount += 1;
1562
+ ensureTickLoop();
1563
+ return true;
1564
+ }
1565
+
1566
+ /* ----------------------------------------------------- 置顶显示提问 -- */
1567
+ let pinRows = []; // 所有用户发言行(扫描时更新,文档顺序)
1568
+ let pinPick = -1; // 上一帧钉住的下标(下一帧先验它还成不成立,省掉二分)
1569
+ let pinRow = null; // 当前置顶的那一行(帧里按"正在看哪一轮"选出来)
1570
+ let pinBar = null; // 固定层上的置顶条
1571
+ const pinTextOf = new WeakMap(); // 行 → 单行文本(滚动来回不用重算)
1572
+ /** 用户行里"人说的话":rowText 已经跳过隐藏节点与过程块,这里再跳过时间戳/操作按钮,压成一行。 */
1573
+ const promptText = (row) => rowText(row, (n) => n.matches("[class*='_time'],[class*='_actions']"), 400).replace(/\s+/g, " ").trim();
1574
+ function ensurePinBar() {
1575
+ if (pinBar !== null && pinBar.isConnected) return pinBar;
1576
+ for (const old of document.querySelectorAll("[data-dshsf-pin]")) { try { old.remove(); } catch { /* ignore */ } } // 旧实例残留:只留一个
1577
+ const bar = document.createElement("div");
1578
+ bar.setAttribute("data-dshsf-pin", "");
1579
+ bar.title = "回到本轮提问";
1580
+ bar.addEventListener("click", () => {
1581
+ const el = mainScroller();
1582
+ if (el === null || pinRow === null || !pinRow.isConnected) return;
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();
1586
+ });
1587
+ (document.body || document.documentElement).appendChild(bar);
1588
+ pinBar = bar;
1589
+ return bar;
1590
+ }
1591
+ /** 每帧:钉住"你正在看的那一轮"的提问——视口顶端之上最后一条用户发言(行位置单调,二分即可,O(log n) 次测量)。
1592
+ * 只有整行都滚到视口上方才露面:不重复占屏、边界上也不闪。位置贴滚动容器顶边,宽度跟容器一致。 */
1593
+ function pinTick(scroller) {
1594
+ const t0 = performance.now();
1595
+ try {
1596
+ pinTickInner(scroller);
1597
+ } finally {
1598
+ cost.pinMs += performance.now() - t0;
1599
+ cost.pinFrames += 1;
1600
+ }
1601
+ }
1602
+ function hidePinBar() { if (pinBar !== null && pinBar.hasAttribute("data-dshsf-visible")) pinBar.removeAttribute("data-dshsf-visible"); }
1603
+ function pinTickInner(scroller) {
1604
+ const hide = hidePinBar;
1605
+ if (state.pinPrompt !== true || pinRows.length === 0) { hide(); return; }
1606
+ const bar = ensurePinBar();
1607
+ const box = scroller.getBoundingClientRect();
1608
+ const line = box.top + 2;
1609
+ const above = (i) => i >= 0 && i < pinRows.length && pinRows[i].getBoundingClientRect().top < line;
1610
+ // 滚动是连续的:上一帧的落点若还成立(它在线之上、下一行在线之下)就直接用 —— 常态 2 次测量,不用二分
1611
+ let pick = -1;
1612
+ if (above(pinPick) && !above(pinPick + 1)) pick = pinPick;
1613
+ else {
1614
+ let lo = 0, hi = pinRows.length - 1;
1615
+ while (lo <= hi) {
1616
+ const mid = (lo + hi) >> 1;
1617
+ if (above(mid)) { pick = mid; lo = mid + 1; }
1618
+ else hi = mid - 1;
1619
+ }
1620
+ }
1621
+ pinPick = pick;
1622
+ const row = pick < 0 ? pinRows[0] : pinRows[pick];
1623
+ if (!row.isConnected) { hide(); return; }
1624
+ pinRow = row;
1625
+ const rect = row.getBoundingClientRect();
1626
+ const show = box.height > 40 && box.top < window.innerHeight - 40 && rect.bottom < line;
1627
+ const top = Math.round(box.top) + "px";
1628
+ const left = Math.round(box.left) + "px";
1629
+ const width = Math.round(box.width) + "px";
1630
+ if (bar.style.top !== top) bar.style.top = top;
1631
+ if (bar.style.left !== left) bar.style.left = left;
1632
+ if (bar.style.width !== width) bar.style.width = width;
1633
+ let text = pinTextOf.get(row);
1634
+ if (text === undefined) { text = promptText(row); pinTextOf.set(row, text); }
1635
+ if (bar.textContent !== text) bar.textContent = text;
1636
+ if (show !== bar.hasAttribute("data-dshsf-visible")) {
1637
+ if (show) bar.setAttribute("data-dshsf-visible", "");
1638
+ else bar.removeAttribute("data-dshsf-visible");
1639
+ }
1640
+ }
1641
+
1642
+ /**
1643
+ * 官方那颗回底按钮的**点击**:官方是 `toBottom → el.scrollTop = el.scrollHeight`(瞬跳,容器也没有
1644
+ * `scroll-behavior:smooth`)。捕获阶段截下这次点击,改成置 followOn + 排帧 —— 回底就交给与我们跟随完全
1645
+ * 同一条追赶公式,平滑滑下去。跟随/动效关掉时放行,退回官方那条瞬跳(否则点了没反应)。
1646
+ */
1647
+ function onToBottomClick(event) {
1648
+ if (synthetic || !(event.target instanceof Element)) return;
1649
+ const btn = event.target.closest("button[class*='_toBottom']"); // 图标在按钮里,取最近的那个按钮
1650
+ if (btn === null || btn.closest("[data-dshsf-window]") !== null) return; // 小窗里的按钮不是它
1651
+ // 官方档位 / 关动效 / 关跟随:这颗按钮完全归官方。先交还位置所有权,否则它自己的瞬跳会被我们的接管挡掉(点了没反应)
1652
+ if (state.mode !== "fold" || state.autoFollow === false || state.animations === false) { snapOwn = false; return; }
1653
+ event.preventDefault();
1654
+ event.stopPropagation(); // React 的 onClick 在根上冒泡阶段,捕获阶段拦得住
1655
+ followOn = true;
1656
+ glide = null; // 点了回底就是要到底:别让没跑完的滑行继续往旧目标带
1657
+ releaseSettleHold();
1658
+ document.documentElement.removeAttribute("data-dshsf-jumpgate"); // 按钮立刻收起:我们已经在往回走了
1659
+ ensureTickLoop();
1660
+ }
1661
+
1662
+ /**
1663
+ * 主窗口的回底按钮**不自绘**:外观、位置、点击行为都用官方那颗(`[class*='_toBottomSlot']` 里的按钮),
1664
+ * 我们只接管"它什么时候出现"——CSS 默认把它藏起来,判定要显示时才给 html 挂 `data-dshsf-jumpgate`。
1665
+ * 官方自己是"gap>25 就渲染"(无滞回),跟随中的正常落后会让它一开一关地闪;我们按带滞回的规则给:
1666
+ * 点亮 = 读者接管(!followOn)且确实离底(gap>24);熄灭 = 回到贴底(gap<12)/ 关掉开关。
1667
+ * 元素在不在 DOM 里仍由官方决定,但那只影响"能不能显示";闪不闪由我们说了算。
1668
+ */
1669
+ /** 官方那颗按钮的显隐(带滞回;状态只有一处=html 上的 data-dshsf-jumpgate)。
1670
+ * 点亮 = 读者接管(!followOn)且确实离底(gap>24);熄灭 = 回到贴底(gap<12)或关掉开关。
1671
+ * 单独抽出来是因为"跟随关掉"那条早退路径也必须维护它——否则 CSS 默认藏着的官方按钮永远不会露面。 */
1672
+ function updateJumpGate(gap, follow) {
1673
+ const root = document.documentElement;
1674
+ if (state.showJumpButton === false) { root.removeAttribute("data-dshsf-jumpgate"); return; }
1675
+ if (root.hasAttribute("data-dshsf-jumpgate")) { if (gap < 12) root.removeAttribute("data-dshsf-jumpgate"); }
1676
+ else if (gap > 24 && !follow) root.setAttribute("data-dshsf-jumpgate", "");
1677
+ }
1678
+
1679
+ /**
1680
+ * 官方吸底写在它自己的 ResizeObserver 回调里(observe 的正是 [data-chat-flow]),一帧就把我们的滞后抹平。
1681
+ * 我们的 observer 注册得晚 → 同一轮投递里排在它后面、仍在 layout 之后 paint 之前:在这一刻把位置改回
1682
+ * 我方追赶点,那一跳根本画不出去(与 anchorRO 同一条路子)。只回补"本来就在贴底、且只被抢走一小段"的:
1683
+ * 换会话 / 导航跳转这类大位移不碰(距离上限),读者自己滚(followOn 已翻假)也不碰。
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
+ * 撒手(读者接管 / 切官方档 / 换会话 / 放行官方按钮)时所有权立刻归还,官方行为原样恢复。
1699
+ */
1700
+ let ownWrite = false; // 我方写入:setter 直接放行
1701
+ let snapOwn = false; // 贴底位置归不归我们管。不能用"最近 800ms 写过"当判据:停在底部时帧循环本来就停了,
1702
+ // 这一停就把接管判据晃没了——输出一停顿,下一个 chunk 到达时官方的瞬跳正好从空档漏过去。
1703
+ let snapTaken = null; // 已接管的容器:{ el }
1704
+ let snapBlocked = 0; // 诊断:被丢掉的官方贴底写入次数
1705
+ function writeTop(el, value) {
1706
+ ownWrite = true;
1707
+ try { el.scrollTop = value; } finally { ownWrite = false; }
1708
+ if (followOn || glide !== null) snapOwn = true; // 我们在带这段滚动 → 所有权跟着跟随状态走
1709
+ }
1710
+ const snapSetters = new WeakMap(); // el -> 我们装上去的那个访问器(认亲:旧实例 dispose 迟到会把它删掉)
1711
+ function takeOverSnap(el) {
1712
+ // 只在"当前装着的确实还是我们自己那个访问器"时早退:旧实例的 disposer 迟到会 delete 掉它,而 snapTaken
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
+ }
1718
+ const desc = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop");
1719
+ if (!desc || typeof desc.get !== "function" || typeof desc.set !== "function") return;
1720
+ if (snapTaken !== null) { try { delete snapTaken.el.scrollTop; } catch { /* ignore */ } }
1721
+ const taken = { el };
1722
+ const setter = function (value) {
1723
+ const own = snapOwn && state.autoFollow !== false && (followOn || glide !== null);
1724
+ if (!own) snapOwn = false; // 撒手(读者接管/切档/换会话)→ 位置立刻还给官方
1725
+ // 判据一:只挡「落到(夹住后的)底部」的写入 —— 而且目标值必须按 **floor 认,不能按 scrollHeight**:0.1.7 官方 toBottom 走的是
1726
+ // `ScrollFollow.jump → toBottom → ChatViewport.scrollToBottom`,写进来的是 `scrollHeight - clientHeight`
1727
+ // (一手调用栈 + 实测值 4474 = 5703 - 1229)。旧判据 `value >= scrollHeight - 1` 因此永不命中
1728
+ // (blocked 恒 0):官方每来一坨字把我们的追赶滞后一次抹平,帧循环下一帧再回补,位置一上一下 ——
1729
+ // 就是「切会话后正文流式输出时整段对话上下跳」(steals 与 undo 成对增长)。
1730
+ const floor = Math.max(0, el.scrollHeight - el.clientHeight);
1731
+ // 判据二:接管期间(跟随中 / 滑行中)**一律**不许官方落底,不再受「离底 ≤600px」窗口限制 ——
1732
+ // 那个窗口本意是区分「官方小步追赶」与「有人真在跳转」,但本会话内的导航/换会话/切档已由 snapOwn=false
1733
+ // 覆盖;留着它,「单帧长高 >600px」和「滑行超过 600px」都会被当成跳转放行(一帧吸到底 / 半路被拽回底部)。
1734
+ if (!ownWrite && own && value >= floor - 1
1735
+ && (followOn || glide !== null || floor - el.scrollTop <= SNAP_UNDO_MAX)) { snapBlocked += 1; return; }
1736
+ desc.set.call(el, value);
1737
+ };
1738
+ Object.defineProperty(el, "scrollTop", {
1739
+ configurable: true,
1740
+ get() { return desc.get.call(el); },
1741
+ set: setter,
1742
+ });
1743
+ snapSetters.set(el, setter);
1744
+ snapTaken = taken;
1745
+ }
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
+
1773
+ /* -------------------------------------------------------------- 小窗打标 -- */
1774
+ /** 某一轮里最后一个匹配元素(运行中"最新那一个"就是它)。 */
1775
+ function newestInTurn(flow, selector, turn) {
1776
+ const all = Array.from(flow.querySelectorAll(selector));
1777
+ for (let i = all.length - 1; i >= 0; i -= 1) {
1778
+ const row = all[i].closest(ROW);
1779
+ if (row && row.getAttribute("data-chat-turn") === turn) return all[i];
1780
+ }
1781
+ return null;
1782
+ }
1783
+
1784
+ function syncWindows(flow, running, collected, bodies, heights) {
1785
+ const now = Date.now();
1786
+ // ① 只有"正文已挂载"的块才需要小窗打标 / 揭示动画 / 收起拦截(收起只可能发生在展开着的块上)。
1787
+ // 扫描开始时一次遍历就拿到这些块,而不是对每个卡片各查一次子树——会话越长,这笔账越贵。
1788
+ const wiredTools = new Set();
1789
+ const windowQueue = []; // 先只读、后集中写:量一个写一个会让下一次量强制重排(布局抖动)
1790
+ for (const body of bodies || flow.querySelectorAll(BODY_ANY)) {
1791
+ if (body.matches(THINK_BODY)) {
1792
+ const root = body.closest(THINK);
1793
+ if (root && !root.closest("[data-dshsf-folded]")) {
1794
+ wireDisclosure(root, thinkOpen, THINK_BODY, (el) => { el.dataset.dshsfKeepOpen = "1"; });
1795
+ if (heights && heights.has(body)) windowQueue.push([body, heights.get(body)]);
1796
+ }
1797
+ continue;
1798
+ }
1799
+ if (body.hasAttribute("data-context-injection-body")) continue; // 上下文注入行交给 syncContextRows
1800
+ const prev = body.previousElementSibling;
1801
+ const root = body.closest(TOOL_CARD) || (prev && prev.matches && prev.matches(TOOL_CARD) ? prev : null); // bash 卡:正文是根的下一个兄弟
1802
+ if (!root || wiredTools.has(root)) continue;
1803
+ wiredTools.add(root);
1804
+ if (root.closest("[data-dshsf-folded]")) continue;
1805
+ if (heights && heights.has(body)) revealBody(body, heights.get(body)); // 官方自己展开 / 键盘展开:扫描到就补播(高度已在只读阶段量好)
1806
+ wireDisclosure(root, toolOpen, TOOL_BODY, (el) => { el.dataset.dshsfKeepClosed = "1"; delete el.dataset.dshsfToolAuto; });
1807
+ }
1808
+ 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; } // 跑完就不再磨金属:火星自然熄灭后画布一并摘掉
1813
+ // ② 运行中只处理"本轮":自动展开最新那一个、被取代的我方窗口延迟折回。
1814
+ // 原来是扫整个会话的思考/工具根,会话一长就是几百次 closest + 子树查询。
1815
+ const lastTurn = collected ? collected.lastTurn : collectTurns().lastTurn;
1816
+ const rows = (collected && lastTurn !== null ? collected.turns.get(lastTurn) : null) || [];
1817
+ // 只找"最新的那一个":从最后一行往前查,找到就停(原来是每行各查两次、再把整轮所有根攒成数组)
1818
+ let newestThink = null;
1819
+ let newestTool = null;
1820
+ for (let i = rows.length - 1; i >= 0 && (newestThink === null || newestTool === null); i -= 1) {
1821
+ if (newestThink === null) {
1822
+ const hit = rows[i].querySelectorAll(THINK);
1823
+ if (hit.length) newestThink = hit[hit.length - 1];
1824
+ }
1825
+ if (newestTool === null) {
1826
+ const hit = rows[i].querySelectorAll(TOOL_CARD);
1827
+ if (hit.length) newestTool = hit[hit.length - 1];
1828
+ }
1829
+ }
1830
+ let budget = 3; // 每次扫描最多展开 3 个,避免加载时重渲染风暴
1831
+ if (newestThink !== null && !newestThink.closest("[data-dshsf-folded]") && state.autoExpandReasoning !== false
1832
+ && !thinkOpen(newestThink) && budget > 0 && inViewport(newestThink)) {
1833
+ if (openThink(newestThink)) budget -= 1;
1834
+ // 只有"还会变"的失败才值得续扫:用户收起过(keepOpen)/重试次数用满 = 永久不可达,
1835
+ // 再排下去就是"屏幕上什么都没变也每 300ms 全量重扫"。
1836
+ else if (newestThink.dataset.dshsfKeepOpen !== "1" && Number(newestThink.dataset.dshsfTries || "0") < 8) busyWork = true;
1837
+ }
1838
+ if (newestTool !== null && !newestTool.closest("[data-dshsf-folded]") && state.autoExpandTools !== false
1839
+ && !toolOpen(newestTool) && budget > 0 && inViewport(newestTool)) {
1840
+ if (openTool(newestTool)) budget -= 1;
1841
+ else if (newestTool.dataset.dshsfKeepClosed !== "1" && Number(newestTool.dataset.dshsfToolTries || "0") < 6) busyWork = true;
1842
+ }
1843
+ // 火星只跟着"正在被磨的那一个窗":本轮最新的思考小窗(没展开就没有火星)
1844
+ sparks.focus(newestThink ? newestThink.querySelector(THINK_BODY) : null);
1845
+ // 被取代的我方窗口:只遍历"我们自己开过的那几个"(通常 1~3 个),不再遍历本轮所有思考/工具根
1846
+ for (const root of autoThinks) {
1847
+ if (!root.isConnected) { autoThinks.delete(root); continue; }
1848
+ if (root === newestThink) continue;
1849
+ if (!thinkOpen(root) || root.dataset.dshsfKeepOpen === "1") { autoThinks.delete(root); continue; }
1850
+ if (Number(root.dataset.dshsfSupersededAt || "0")) continue; // 已经起了宽限定时器
1851
+ root.dataset.dshsfSupersededAt = String(now); // 刚被取代:起定时器,到点再折
1852
+ scheduleSupersedeClose(root, supersedeDelay() * 1000);
1853
+ }
1854
+ for (const root of autoTools) {
1855
+ if (!root.isConnected) { autoTools.delete(root); continue; }
1856
+ if (root === newestTool) continue;
1857
+ if (!toolOpen(root)) { autoTools.delete(root); continue; }
1858
+ if (closeBudget <= 0) { busyWork = true; continue; }
1859
+ if (closeTool(root)) closeBudget -= 1;
1860
+ }
1861
+ }
1862
+
1863
+ /* -------------------------------------------------------- 上下文注入行 -- */
1864
+ /** 上下文注入行(回忆/上下文注入)也是官方 DisclosureRow:正文在 = 展开。挂同一套揭示 + 收起动效。 */
1865
+ function syncContextRows(flow, bodies, heights) {
1866
+ for (const body of (bodies ? Array.from(bodies).filter((el) => el.hasAttribute("data-context-injection-body")) : flow.querySelectorAll(CTX_BODY))) {
1867
+ if (!heights || heights.has(body)) revealBody(body, heights ? heights.get(body) : undefined);
1868
+ const root = body.parentElement; // DisclosureRow 的 {open && children} → 正文是根的直接子节点
1869
+ if (root) wireDisclosure(root, (el) => el.querySelector(CTX_BODY) !== null, CTX_BODY, null);
1870
+ }
1871
+ }
1872
+
1873
+ /* -------------------------------------------------------- 穿插正文小窗 -- */
1874
+ function clearTextWindows(row) {
1875
+ if (!row.hasAttribute("data-dshsf-tw")) return; // 绝大多数行没有穿插正文小窗:省掉一次子树查询
1876
+ row.removeAttribute("data-dshsf-tw");
1877
+ for (const el of row.querySelectorAll("[data-dshsf-textwindow]")) {
1878
+ el.removeAttribute("data-dshsf-textwindow");
1879
+ el.style.maxHeight = "";
1880
+ el.style.overflow = "";
1881
+ liveWindows.delete(el);
1882
+ }
1883
+ }
1884
+ /** 过程块:思考、工具卡(含 bash 卡)、上下文注入、内联过程。它们有自己的折叠/内联滚动,
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
+ /** 折叠后仍保留的穿插正文行进限高小窗(答案行正文不套窗,只用一条线跟它分开)。 */
1896
+ function syncTextWindows(flow, collected) {
1897
+ const keepText = state.keepInterleavedText !== false;
1898
+ for (const turnRows of (collected ? collected.turns : collectTurns().turns).values()) {
1899
+ const assistantRows = turnRows.filter((row) => rowKind(row) === "assistant-step");
1900
+ const answerRow = answerRowOf(turnRows);
1901
+ let keptText = false;
1902
+ for (const row of assistantRows) {
1903
+ if (!keepText || row === answerRow || row.hasAttribute("data-dshsf-folded") || !hasText(row)) {
1904
+ clearTextWindows(row);
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");
1915
+ }
1916
+ if (answerRow) answerRow.toggleAttribute("data-dshsf-answer-sep", keptText); // 有穿插正文才画线
1917
+ }
1918
+ }
1919
+
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
+
1937
+ /* ------------------------------------------------------ 火星特效(独立) -- */
1938
+ /**
1939
+ * 思考小窗底部的火星:小窗自己在滚动时才溅(像滚筒碾过金属),贴着小窗底边整条往上飞,additive 混合出冷蓝的光。
1940
+ * 火星只往上去(单边),底边一道热光带跟着流量抖动、迸发时整条闪。
1941
+ * 独立模块:只认「一个元素 + 一份配置」——不读插件 state、不碰折叠/滚动/扫描,整块删掉不影响其余功能。
1942
+ * 主帧循环每帧喂一次 tick(dt):熄灭且不发光时返回 false,主循环自然停手(空闲零成本)。
1943
+ */
1944
+ const SPARK_BAND = 64; // 火星"band"高度(px):肉眼看的那一段(渐隐也发生在这里)
1945
+ const SPARK_DRIFT = 96; // 画布在 band 之上多留的余量:画布随写头下移时粒子会被反向平移顶到上方,不留就被裁掉上沿
1946
+ const SPARK_DROP = 44; // 画布在地面线之下多留的余量(给"朝下飘"的火星;不留就只剩半个扇面)
1947
+ const SPARK_H = SPARK_BAND + SPARK_DRIFT;
1948
+ const SPARK_FORGE_RATE = 30; // 锻打火星的发射速率(颗/秒,满强度;密度滑条再乘上去)
1949
+ const SPARK_END_RATE = 0.45; // 两端端点额外的火星量(相对主流量的比例):端点更密、且朝窗口内迸
1950
+ const SPARK_END_W = 12; // 端点的横向范围(px):这个宽度内算"端点"
1951
+ // 找"正文"时要跳过的子树:思考块、工具卡(bash 卡)、以及我们自己挂的火星层 —— 锻打只属于答案正文
1952
+ const SPARK_SKIP = "[data-variant='think'], [data-tool], [data-sample='bash']"; // 找正文时跳过:思考块 / 工具卡(bash 卡)
1953
+ const SPARK_MAX = 96; // 单个小窗的粒子上限(预算;满了就不再生成)
1954
+ const SPARK_RATE = 42; // 满强度下每秒生成几颗
1955
+ /* 摩擦亮度 ↔ 滚动速度的映射(形状是常数,旋钮在设置里只有颜色/密度/速度): */
1956
+ const SPARK_LIT_MAX = 2.4; // 亮度曲线满值(画的时候映射到描边的 1.35 上限)
1957
+ const SPARK_LIT_LO = 0.35; // 曲线起点:u(速度/基准)低于它基本不发光
1958
+ const SPARK_LIT_HI = 5; // 曲线顶端:u 到这里算拉满
1959
+ const SPARK_LIT_CURVE = 0.6; // 曲线形状:<1 = 低段抬起来(压缩),1 = 线性
1960
+ const SPARK_EMIT_MAX = 1.8; // 满强度发射率倍率(与旧版那个恒定值一致,密度观感不变)
1961
+ const SPARK_EMIT_BASE = 0.55; // 最慢时的发射率占比:速度只做温和的疏密变化,慢输出也不至于没火星
1962
+ const SPARK_REF_MIN = 0.35; // 基准速度下限(px/16ms):输出很慢时也保留灵敏度
1963
+ const SPARK_REF_MAX = 2.5; // 基准速度上限:到顶后不再按输出速度归一化 → 亮度继续随绝对速度涨
1964
+ const SPARK_TAU_FLOW = 200; // 输出速度 EMA 时间常数(ms)
1965
+ const SPARK_TAU_LIT = 90; // 亮度 EMA 时间常数(ms)
1966
+ const SPARK_COOL_AFTER = 80; // 停手多久开始退温(ms)
1967
+ const SPARK_TAU_COOL = 140; // 余温衰减时间常数(ms)
1968
+ const SPARK_TRAIL = 0.04; // 拖尾时长(秒):金属火星是短划线,不是点
1969
+ const SPARK_GLOW_H = 26; // 底边热光带的高度(px,向上渐隐;渐变只在宽度变化时重建,一帧一次 fillRect)
1970
+ /** "#rgb" / "#rrggbb" / "rgb(...)" -> [r,g,b];认不出就用兜底色。 */
1971
+ function sparkRgb(value, fallback) {
1972
+ const text = String(value == null ? "" : value).trim();
1973
+ const hex = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text);
1974
+ if (hex) {
1975
+ const s = hex[1].length === 3 ? hex[1].replace(/./g, (c) => c + c) : hex[1];
1976
+ return [0, 2, 4].map((i) => parseInt(s.slice(i, i + 2), 16));
1977
+ }
1978
+ const fn = /^rgba?\(([^)]+)\)$/i.exec(text);
1979
+ const rgb = fn ? fn[1].split(",").slice(0, 3).map((x) => Math.round(Number(x))) : null;
1980
+ return rgb && rgb.every((n) => Number.isFinite(n) && n >= 0 && n <= 255) ? rgb : fallback;
1981
+ }
1982
+ function createSparkFx() {
1983
+ const fields = new Map(); // el -> field(只给"当前聚焦的那个窗"挂画布)
1984
+ const opt = { on: true, color: "#4fa8ff", density: 1, forge: true, forgeSpeed: 1, forgeLife: 1.3 };
1985
+ const cost = { frames: 0, ms: 0, lastMs: 0, maxMs: 0, strikes: 0, draws: 0, resizes: 0, places: 0 };
1986
+ let rgb = sparkRgb(opt.color, [79, 168, 255]);
1987
+ let core = "";
1988
+ let focusEl = null; // 磨(思考小窗):连续,带地面倒影
1989
+ let forgeEl = null; // 锻(正文写头):每前移一次砸一锤,火星从落点扇形溅出
1990
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
1991
+ const tone = () => { core = rgb.map((c) => Math.round(c + (255 - c) * 0.75)).join(","); };
1992
+ tone();
1993
+ // 注意:这一句**不能**缓存成一次性结果 —— 系统"减弱动态效果"中途变化必须立刻生效(test/spark.mjs 有断言)。
1994
+ const reduced = () => { try { return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch { return false; } };
1995
+ const enabled = () => opt.on && !reduced();
1996
+ const enabledForge = () => enabled() && opt.forge;
1997
+
1998
+ function fieldFor(el, mode) {
1999
+ let known = fields.get(el);
2000
+ if (known) { known.mode = mode; return known; }
2001
+ const canvas = document.createElement("canvas");
2002
+ const ctx = canvas.getContext ? canvas.getContext("2d") : null;
2003
+ if (!ctx) return null; // 拿不到 2d 上下文(无头/降级):整块特效当它不存在
2004
+ canvas.dataset.dshsfSpark = "";
2005
+ // 画布挂在 body 的固定层上,不插进正文(插进去会被 React 重排、被滚动带走,还要"必须挂在最后"的自愈),
2006
+ // 改成挂在 body 上的固定层:每帧按视口坐标摆位,正文怎么动都与它无关。
2007
+ (document.body || document.documentElement).appendChild(canvas);
2008
+ known = { el, canvas, ctx, w: 0, px: null, py: null, dpr: 0, parts: [], emit: 0, glow: 0, idle: 0, painted: false,
2009
+ mode, ground: mode === "forge" ? SPARK_H - SPARK_DROP : SPARK_H,
2010
+ burst: 0, nextBurst: 0.25 + Math.random() * 0.5, flash: 0, jitter: 0.5, level: 0, lastLevel: 0, flow: 0, lit: 0, emitEnd: 0,
2011
+ grad: null, gradW: 0,
2012
+ caretNode: null, caretTextLen: -1,
2013
+ strikeX: 0, strikeY: 0, forgeWarm: 0, wasFresh: false, lastWrite: 0, emitForge: 0, range: null,
2014
+ top: el.scrollTop, height: el.scrollHeight };
2015
+ fields.set(el, known);
2016
+ return known;
2017
+ }
2018
+ function drop(el) {
2019
+ const f = fields.get(el);
2020
+ if (!f) return;
2021
+ fields.delete(el);
2022
+ if (f.canvas.parentNode) f.canvas.remove();
2023
+ }
2024
+ function focus(el) {
2025
+ const next = el && el.isConnected ? el : null;
2026
+ if (next === focusEl) return;
2027
+ focusEl = next;
2028
+ if (next && enabled()) fieldFor(next, "floor");
2029
+ }
2030
+ /** 正文写头:attach 到这个元素,效果自己盯着它的最后一行末尾砸。 */
2031
+ function forge(el) {
2032
+ const next = el && el.isConnected ? el : null;
2033
+ if (next === forgeEl) return;
2034
+ // 撒手(跑完/换行):不立刻摘画布,让还在飞的火星烧完;烧完后 tick 会自己把画布摘掉。
2035
+ forgeEl = next;
2036
+ if (next && enabledForge()) fieldFor(next, "forge");
2037
+ }
2038
+ function setOptions(next) {
2039
+ if (!next) return;
2040
+ if ("on" in next) opt.on = next.on !== false;
2041
+ if ("color" in next) { rgb = sparkRgb(next.color, rgb); tone(); }
2042
+ if ("density" in next) { const n = Number(next.density); opt.density = clamp(Number.isFinite(n) ? n : 1, 0.2, 3); }
2043
+ if ("forge" in next) opt.forge = next.forge !== false;
2044
+ if ("forgeSpeed" in next) { const n = Number(next.forgeSpeed); opt.forgeSpeed = Number.isFinite(n) ? clamp(n, 0.2, 4) : 1; }
2045
+ if ("forgeLife" in next) { const n = Number(next.forgeLife); opt.forgeLife = Number.isFinite(n) ? clamp(n, 0.2, 5) : 1.3; }
2046
+ if (enabled() && focusEl) fieldFor(focusEl, "floor");
2047
+ if (enabledForge() && forgeEl) fieldFor(forgeEl, "forge");
2048
+ }
2049
+ function reset() { for (const el of Array.from(fields.keys())) drop(el); focusEl = null; forgeEl = null; }
2050
+ function resetCost() { cost.frames = 0; cost.ms = 0; cost.lastMs = 0; cost.maxMs = 0; cost.strikes = 0; cost.draws = 0; cost.resizes = 0; cost.places = 0; }
2051
+ function stats() {
2052
+ let parts = 0;
2053
+ let one = null;
2054
+ for (const f of fields.values()) { parts += f.parts.length; if (!one) one = f; }
2055
+ const box = one ? one.canvas.getBoundingClientRect() : null;
2056
+ return { on: enabled(), focus: focusEl !== null, forge: forgeEl !== null && enabledForge(), fields: fields.size, parts,
2057
+ w: one ? one.w : 0, elW: one ? one.el.clientWidth : 0, cssW: box ? Math.round(box.width) : 0, dpr: one ? one.dpr : 0,
2058
+ heating: one ? !!one.parts.length || one.forgeWarm > 0.01 : false,
2059
+ lit: one ? +(one.lit || 0).toFixed(2) : 0, level: one ? +(one.level || 0).toFixed(2) : 0, // 摩擦当前亮度 / 发射率(随速度实时变)
2060
+ caretLen: one ? one.caretTextLen : -1, el: one ? (one.el.tagName + "." + String(one.el.className || "").split(" ")[0]).slice(0, 44) : "",
2061
+ color: opt.color, density: opt.density,
2062
+ frames: cost.frames, strikes: cost.strikes, lastMs: cost.lastMs, maxMs: cost.maxMs, avgMs: cost.frames ? +(cost.ms / cost.frames).toFixed(3) : 0,
2063
+ draws: cost.draws, resizes: cost.resizes, places: cost.places };
2064
+ }
2065
+
2066
+ /** 火星的"燃料"是小窗自己在滚:滚得越快越亮越密,不滚就没有(没有基础量)。
2067
+ *
2068
+ * 亮度 ↔ 速度的映射(两段自适应 + 一条压缩曲线):
2069
+ * speed = |ΔscrollTop| / dt16 实际滚动速度(px / 16ms,dt 归一)
2070
+ * flow = EMA(|ΔscrollHeight| / dt16) 当前输出速度(内容长得多快,τ=200ms)
2071
+ * ref = clamp(flow*0.6, 0.35, 2.5) **基准速度只在小速度侧跟着输出速度走**:
2072
+ * 输出慢 → 基准小,一点点滚动就看得出;输出快到 ref 封顶后就不再归一化,
2073
+ * 亮度继续随绝对速度上涨(快输出更烫)。旧版 ref 与输出速度等比(上限 6),等于把速度
2074
+ * 信息除掉了 —— 快慢都稳稳顶在天花板上,这才是"看不出随速度变化"的原因。
2075
+ * u = speed / ref 1 ≈ 恰好跟上输出
2076
+ * k = clamp((u-0.35)/(5-0.35),0,1)^0.6 压缩曲线:低段抬起来、高段放缓,单调、无台阶、无硬顶
2077
+ * lit = EMA(2.4 * k) 亮度(τ=90ms)→ 画的时候乘 0.56 映射到描边的 1.35 上限
2078
+ * emit = 1.8 * (0.55 + 0.45*k) 发射率:跟着速度走,但只做温和的疏密变化(不跟亮度一起暴走)
2079
+ * 停手(>80ms 不滚)后按 τ=140ms 指数退温、低于 0.02 归零 —— 平滑熄灭,同时保证帧循环能停(空闲零成本)。
2080
+ * 所有 EMA 一律按 dt 归一(τ 恒定),换刷新率、换输出速度手感一致。 */
2081
+ function readLevel(f, dt, top, height) {
2082
+ const per = Math.max(1, dt / 16);
2083
+ const moved = top - f.top;
2084
+ const speed = Math.abs(moved) / per; // 实际滚动速度(px / 16ms)
2085
+ const flow = Math.max(0, height - f.height) / per; // 当前输出速度(内容长得多快)
2086
+ const dir = clamp(moved / per / 8, -1, 1);
2087
+ f.top = top;
2088
+ f.height = height;
2089
+ f.flow += (flow - f.flow) * (1 - Math.exp(-dt / SPARK_TAU_FLOW)); // 输出速度慢速 EMA(不跟单帧抖)
2090
+ const ref = clamp(f.flow * 0.6, SPARK_REF_MIN, SPARK_REF_MAX);
2091
+ const u = speed / ref; // 归一化:1 ≈ 正好跟上输出
2092
+ const v = clamp((u - SPARK_LIT_LO) / (SPARK_LIT_HI - SPARK_LIT_LO), 0, 1);
2093
+ const k = Math.pow(v, SPARK_LIT_CURVE);
2094
+ f.idle = speed > 0.05 ? 0 : f.idle + dt;
2095
+ const cool = f.idle <= SPARK_COOL_AFTER ? 1 : Math.exp(-(f.idle - SPARK_COOL_AFTER) / SPARK_TAU_COOL);
2096
+ f.lit += (SPARK_LIT_MAX * k * cool - f.lit) * (1 - Math.exp(-dt / SPARK_TAU_LIT));
2097
+ const raw = SPARK_EMIT_MAX * (SPARK_EMIT_BASE + (1 - SPARK_EMIT_BASE) * k) * cool;
2098
+ const level = raw < 0.02 ? 0 : raw; // 归零阈值:低于它就彻底撒手,帧循环才停得下来
2099
+ const resume = level > 0.05 && f.lastLevel <= 0.05; // 停过又滚起来:滚筒重新咬上金属
2100
+ f.lastLevel = level;
2101
+ return { level, dir, resume };
2102
+ }
2103
+ /** 正文写头:最后一个"有字"的文本节点末尾,用 Range 取最后一个字的矩形(一帧一次读,复用同一个 Range)。 */
2104
+ /** 子树里最后一个"有字"的文本节点(从后往前走,碰到就收工;跳过我们自己的火星层)。 */
2105
+ function lastTextIn(root) {
2106
+ const walk = (el, depth) => {
2107
+ if (depth > 24) return null;
2108
+ for (let kid = el.lastChild; kid; kid = kid.previousSibling) {
2109
+ if (kid.nodeType === 3) { if (kid.nodeValue && kid.nodeValue.trim()) return kid; continue; }
2110
+ if (kid.nodeType !== 1) continue;
2111
+ if (kid.matches && kid.matches(SPARK_SKIP)) continue; // 思考/工具/自家层:不是正文,跳过整棵子树
2112
+ const hit = walk(kid, depth + 1);
2113
+ if (hit) return hit;
2114
+ }
2115
+ return null;
2116
+ };
2117
+ return walk(root, 0);
2118
+ }
2119
+ function caretPoint(f) {
2120
+ const node = lastTextIn(f.el);
2121
+ if (!node) return null;
2122
+ f.caretNode = node;
2123
+ f.caretTextLen = node.nodeValue.length;
2124
+ try {
2125
+ const range = f.range || (f.range = document.createRange());
2126
+ const len = node.nodeValue.length;
2127
+ range.setStart(node, Math.max(0, len - 1));
2128
+ range.setEnd(node, len);
2129
+ const rect = range.getBoundingClientRect();
2130
+ return rect && (rect.width || rect.height) ? rect : null;
2131
+ } catch { return null; }
2132
+ }
2133
+ /** 一颗锻打火星:从"写头那一小片"(spot = 画布局部坐标)向四周缓慢扩散。 */
2134
+ function one(f, spot, power) {
2135
+ const count = f.parts.length >= SPARK_MAX ? 0 : 1;
2136
+ for (let i = 0; i < count; i += 1) {
2137
+ // 锻打:从落点**向四周**缓慢扩散(不是朝上一个扇面),像溅出来的一小团火星
2138
+ const angle = Math.random() * Math.PI * 2;
2139
+ const speed = (20 + Math.random() * 60) * power * opt.forgeSpeed; // 慢:飘出去而不是溅出去(速度可配)
2140
+ const ttl = opt.forgeLife * (0.7 + Math.random() * 0.6); // 寿命可配(默认 1.3s → 0.9~1.7s)
2141
+ // 落点只在"写头那一行"上抖:不要用"最近写过的一片字"的并集包围盒 —— 换行时它横跨两行,
2142
+ // 火星会跑到新行还没写字的地方去。
2143
+ const x = spot ? spot.x + (Math.random() - 0.5) * 56 : Math.random() * f.w;
2144
+ const y = spot ? spot.y : f.ground - 1;
2145
+ f.parts.push({
2146
+ x: clamp(x, 1, f.w - 1), y: clamp(y - 1 - Math.random() * 3, 2, f.ground - 1),
2147
+ vx: Math.cos(angle) * speed,
2148
+ vy: Math.sin(angle) * speed,
2149
+ g: 0.22, // 锻造的火星是飘的:几乎不吃重力,慢慢散开再淡掉
2150
+ ttl, life: ttl,
2151
+ r: 0.6 + Math.random() * 1.1,
2152
+ heat: 0.7 + Math.random() * 0.3,
2153
+ });
2154
+ }
2155
+ f.flash = 1;
2156
+ cost.strikes += 1;
2157
+ return count > 0;
2158
+ }
2159
+ /** 锻打:写头(只当"字在出现"的传感器用,火星不是从它那一个点出来的)前移够多、且离上一锤够久 → 砸一锤。 */
2160
+ function forgeTick(f, caret, spot, dtms) {
2161
+ const now = performance.now();
2162
+ f.jitter = f.jitter * 0.72 + Math.random() * 0.28;
2163
+ if (!caret) { f.forgeWarm = 0; return f.forgeWarm > 0.01; }
2164
+ // 火星:**连续细流**(不要攒成"一批一批")—— 团状发射会让整团火星以 ~7Hz 明暗脉动,
2165
+ // 在 160Hz 屏上就是肉眼可见的"闪",而它只在写字时出现(不写字就没有团),正好对上"写完就不闪"。
2166
+ const moved = Math.abs(caret.right - f.strikeX) + Math.abs(caret.bottom - f.strikeY);
2167
+ if (moved > 0.6) { f.strikeX = caret.right; f.strikeY = caret.bottom; f.lastWrite = now; }
2168
+ const fresh = now - (f.lastWrite || 0) < 350; // 最近 350ms 还在写字
2169
+ f.forgeWarm = clamp((f.forgeWarm || 0) + (fresh ? dtms : -dtms * 1.4) / 200, 0, 1); // 起手 ~0.2s 爬到满速,停手 ~0.14s 退完
2170
+ if (fresh && !f.wasFresh && spot) for (let i = 0; i < 4; i += 1) one(f, spot, 0.7); // 重新开写:补一小撮
2171
+ f.wasFresh = fresh;
2172
+ if (!spot) return f.forgeWarm > 0.01;
2173
+ const rate = SPARK_FORGE_RATE * opt.density * (0.65 + 0.7 * f.jitter) * f.forgeWarm;
2174
+ f.emitForge = (f.emitForge || 0) + rate * (dtms / 1000);
2175
+ let n = Math.floor(f.emitForge);
2176
+ f.emitForge -= n;
2177
+ const power = clamp(0.7 + moved / 60, 0.7, 1.6);
2178
+ while (n > 0) { n -= 1; if (!one(f, spot, power)) break; }
2179
+ return f.forgeWarm > 0.01; // 还在发射才续帧(否则帧循环会被这里永远吊住)
2180
+ }
2181
+ /** 把画布摆到视口坐标(x = 左边缘,y = 底边)。画布一挪,粒子要反向平移 ——
2182
+ * 否则整团火星会跟着布局一起跳(换行、滚动、抽屉开合都会)。 */
2183
+ function place(f, x0, y0) {
2184
+ const x = Math.round(x0);
2185
+ const top = Math.round(y0 - SPARK_H);
2186
+ if (f.px === x && f.py === top) return;
2187
+ cost.places += 1;
2188
+ if (f.px !== null) {
2189
+ const dx = x - f.px;
2190
+ const dy = top - f.py;
2191
+ if (dx || dy) for (const p of f.parts) { p.x -= dx; p.y -= dy; }
2192
+ }
2193
+ f.px = x;
2194
+ f.py = top;
2195
+ f.canvas.style.transform = "translate(" + x + "px," + top + "px)";
2196
+ }
2197
+ function resize(f, w) {
2198
+ const dpr = Math.min(1.5, window.devicePixelRatio || 1);
2199
+ if (Math.abs(f.w - w) < 2 && f.dpr === dpr) return; // ±1px 抖动不重设位图:重设会清空画布 = 火星闪一下
2200
+ if (f.parts.length && f.w > 0 && f.dpr === dpr && Math.abs(f.w - w) < 12) return; // 有火星在飞就先别重设(位图重建会清屏)
2201
+ f.w = w;
2202
+ f.dpr = dpr;
2203
+ cost.resizes += 1;
2204
+ f.canvas.style.width = w + "px";
2205
+ f.canvas.style.height = SPARK_H + "px";
2206
+ f.canvas.width = Math.max(1, Math.round(w * dpr));
2207
+ f.canvas.height = Math.max(1, Math.round(SPARK_H * dpr));
2208
+ f.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2209
+ }
2210
+ /** 一颗火星:从底边上随机一点溅起(整条边都在磨);摩擦流是浅角度、贴地擦行的碎火,迸发是陡而快的直冲。 */
2211
+ function emitSpark(f, dir, boom) {
2212
+ if (f.parts.length >= SPARK_MAX) { f.emit = 0; f.burst = 0; return; }
2213
+ const skid = !boom && Math.random() < 0.45;
2214
+ const ttl = (boom ? 0.36 : 0.3) + Math.random() * 0.5;
2215
+ f.parts.push({
2216
+ x: clamp(Math.random() * f.w, 1, f.w - 1),
2217
+ y: f.ground - 1 - Math.random() * 3,
2218
+ vx: (Math.random() - 0.5) * (boom ? 170 : skid ? 230 : 70) - dir * (boom ? 60 : 26),
2219
+ vy: boom ? -(130 + Math.random() * 190) : skid ? -(8 + Math.random() * 46) : -(90 + Math.random() * 210),
2220
+ ttl, life: ttl,
2221
+ r: (boom ? 0.8 : 0.6) + Math.random() * 1.2,
2222
+ heat: (boom ? 0.85 : 0.6) + Math.random() * 0.4,
2223
+ });
2224
+ }
2225
+ /** 底边摩擦:连续细流(一半是贴地擦行的碎火)+ 随机的一阵急火(流量越大越频繁)。 */
2226
+ /** 两端端点:比别处更密,而且朝窗口内飞(左端往右、右端往左)。 */
2227
+ function emitSparkEnd(f, side, boom) {
2228
+ if (f.parts.length >= SPARK_MAX) { f.emit = 0; f.emitEnd = 0; f.burst = 0; return; }
2229
+ const inward = side < 0 ? 1 : -1;
2230
+ const boost = boom ? 1.4 : 1;
2231
+ const ttl = (boom ? 0.36 : 0.3) + Math.random() * 0.5;
2232
+ f.parts.push({
2233
+ x: side < 0 ? Math.random() * SPARK_END_W : f.w - Math.random() * SPARK_END_W,
2234
+ y: f.ground - 1 - Math.random() * 3,
2235
+ vx: inward * (70 + Math.random() * 170) * boost,
2236
+ vy: -(70 + Math.random() * 190) * boost,
2237
+ ttl, life: ttl,
2238
+ r: (boom ? 0.8 : 0.6) + Math.random() * 1.1,
2239
+ heat: (boom ? 0.85 : 0.65) + Math.random() * 0.35,
2240
+ });
2241
+ }
2242
+ function emit(f, s, level, dir, resume) {
2243
+ if (level <= 0 || f.w < 8) return;
2244
+ f.level = level;
2245
+ f.jitter = f.jitter * 0.72 + Math.random() * 0.28; // 整条边的亮度抖动:摩擦的"沙沙"感
2246
+ f.emit += SPARK_RATE * opt.density * level * s;
2247
+ f.nextBurst -= s;
2248
+ if (resume) { f.burst = Math.max(f.burst, Math.round((5 + Math.random() * 7) * opt.density)); f.nextBurst = 0.6; f.flash = 1; }
2249
+ if (f.nextBurst <= 0) {
2250
+ f.burst = Math.max(4, Math.round((6 + Math.random() * 12) * opt.density));
2251
+ f.nextBurst = 0.65 + Math.random() * 0.85 - 0.3 * Math.min(1, level); // 一阵之后先安静下来,下一阵才有"迸"的感觉
2252
+ f.flash = 1;
2253
+ }
2254
+ let count = Math.floor(f.emit);
2255
+ f.emit -= count;
2256
+ let boom = 0;
2257
+ if (f.burst > 0) { boom = Math.min(f.burst, Math.max(2, Math.round(9 * Math.min(2, opt.density)))); f.burst -= boom; } // 一阵急火:一两帧内放完,才有"迸"的对比
2258
+ for (let i = 0; i < count; i += 1) emitSpark(f, dir, false);
2259
+ for (let i = 0; i < boom; i += 1) emitSpark(f, dir, true);
2260
+ f.emitEnd += SPARK_RATE * opt.density * level * s * SPARK_END_RATE; // 两端再加一股,朝内
2261
+ let ends = Math.floor(f.emitEnd);
2262
+ f.emitEnd -= ends;
2263
+ if (boom > 0) ends += Math.min(4, boom); // 迸发时两端跟着炸
2264
+ for (let i = 0; i < ends; i += 1) emitSparkEnd(f, i % 2 === 0 ? -1 : 1, boom > 0);
2265
+ }
2266
+ function step(f, s) {
2267
+ const parts = f.parts;
2268
+ const drag = Math.pow(0.25, s);
2269
+ f.flash = Math.max(0, f.flash - s * 3);
2270
+ for (let i = parts.length - 1; i >= 0; i -= 1) {
2271
+ const p = parts[i];
2272
+ p.life -= s;
2273
+ if (p.life <= 0) { parts[i] = parts[parts.length - 1]; parts.pop(); continue; }
2274
+ p.vy += 620 * (p.g === undefined ? 1 : p.g) * s;
2275
+ p.vx *= drag;
2276
+ p.vy *= drag;
2277
+ p.x += p.vx * s;
2278
+ p.y += p.vy * s;
2279
+ if (f.mode === "forge") {
2280
+ if (p.y > SPARK_H) { parts[i] = parts[parts.length - 1]; parts.pop(); continue; } // 落出画布下沿才回收:地面线以下那段留白里,朝下的火星照样看得见
2281
+ } else if (p.y > f.ground - 2 && p.vy > 0) { // 擦到地面线:跳一下然后很快熄灭(只有"磨"有地面;锻打的火星是飘的,不弹)
2282
+ p.y = f.ground - 2;
2283
+ p.vy *= -0.28;
2284
+ p.vx *= 0.7;
2285
+ p.life = Math.min(p.life, 0.1 + Math.random() * 0.12);
2286
+ }
2287
+ }
2288
+ }
2289
+ function draw(f) {
2290
+ const ctx = f.ctx;
2291
+ const parts = f.parts;
2292
+ cost.draws += 1;
2293
+ ctx.clearRect(0, 0, f.w, SPARK_H);
2294
+ f.painted = parts.length > 0;
2295
+ if (!parts.length) return;
2296
+ ctx.globalCompositeOperation = "lighter";
2297
+ ctx.lineCap = "round";
2298
+ // 摩擦接触点:贴着小窗底边的一道热(连续滚时轻微抖,迸发时整条闪)
2299
+ // 整条底边在发热:一条向上渐隐的热光带 + 贴边芯线(只有"磨"有;正文的锻打不画这个)
2300
+ // 亮度取自速度曲线(f.lit,0~2.4)× 摩擦抖动;flash 是"迸发/重新咬上"额外的一闪
2301
+ const lit = Math.min(1.35, f.flash * 1.1 + (f.lit || 0) * 0.56 * (0.75 + 0.25 * f.jitter));
2302
+ if (f.mode !== "forge" && lit > 0.02 && f.w > 4) {
2303
+ if (!f.grad || f.gradW !== f.w) { // 渐变只在宽度变了才重建(每帧建渐变是最贵的画法之一)
2304
+ const g = ctx.createLinearGradient(0, f.ground + 7, 0, f.ground - SPARK_GLOW_H);
2305
+ g.addColorStop(0, "rgba(" + core + ",0.40)");
2306
+ g.addColorStop(0.42, "rgba(" + rgb.join(",") + ",0.13)");
2307
+ g.addColorStop(1, "rgba(" + rgb.join(",") + ",0)");
2308
+ f.grad = g;
2309
+ f.gradW = f.w;
2310
+ }
2311
+ ctx.globalAlpha = Math.min(1, lit);
2312
+ ctx.fillStyle = f.grad;
2313
+ ctx.fillRect(0, f.ground - SPARK_GLOW_H, f.w, SPARK_GLOW_H + 8);
2314
+ ctx.strokeStyle = "rgb(" + core + ")";
2315
+ ctx.globalAlpha = Math.min(1, lit * 0.9);
2316
+ ctx.lineWidth = 1.5;
2317
+ ctx.beginPath();
2318
+ ctx.moveTo(0, f.ground - 0.75);
2319
+ ctx.lineTo(f.w, f.ground - 0.75);
2320
+ ctx.stroke();
2321
+ ctx.fillStyle = "#000";
2322
+ }
2323
+ for (let pass = 0; pass < 2; pass += 1) {
2324
+ ctx.strokeStyle = pass === 0 ? "rgb(" + rgb.join(",") + ")" : "rgb(" + core + ")";
2325
+ for (const p of parts) {
2326
+ const a = p.life / p.ttl;
2327
+ const below = p.y - f.ground; // >0 = 在地面线**以下**(只有锻打的火星会到这里:写头那行下面留了一段)
2328
+ const edge = below > 0
2329
+ ? Math.max(0, (SPARK_H - p.y) / 20) // 画布下沿 20px 内渐隐:飘出画布的瞬间不能"啪"地断掉
2330
+ : Math.min(1, Math.max(0, (SPARK_BAND - (f.ground - p.y)) / 24)); // 往上越远越淡(与画布高度无关)
2331
+ ctx.globalAlpha = Math.max(0, (pass === 0 ? a * a * 0.5 : a * 0.9) * edge * p.heat);
2332
+ ctx.lineWidth = pass === 0 ? p.r * 2.6 : p.r;
2333
+ ctx.beginPath();
2334
+ ctx.moveTo(p.x, p.y);
2335
+ ctx.lineTo(p.x - p.vx * SPARK_TRAIL, p.y - p.vy * SPARK_TRAIL);
2336
+ ctx.stroke();
2337
+ }
2338
+ }
2339
+ ctx.globalAlpha = 1;
2340
+ ctx.globalCompositeOperation = "source-over";
2341
+ }
2342
+ /** 先只读收集、后集中写(与主循环同一条规矩);返回"还有火星或还在发光"。 */
2343
+ function tick(dt) {
2344
+ // 需要发光却没有画布(关掉又打开、切了窗、被摘过):自动重挂
2345
+ if (enabled() && focusEl && focusEl.isConnected && focusEl.clientHeight > 0 && !fields.has(focusEl)) fieldFor(focusEl, "floor");
2346
+ if (enabledForge() && forgeEl && forgeEl.isConnected && !fields.has(forgeEl)) fieldFor(forgeEl, "forge");
2347
+ if (fields.size === 0) return false;
2348
+ const dtms = dt || 16;
2349
+ const s = clamp(dtms / 1000, 0.002, 0.064);
2350
+ const t0 = performance.now();
2351
+ const plan = [];
2352
+ for (const [el, f] of Array.from(fields)) {
2353
+ if (!el.isConnected) { drop(el); continue; }
2354
+ // 宽度取火星层自己的(= 窗口内容宽度,已扣掉滚动条槽与左右内边距),画布位图才不会左右被挤
2355
+ const rect = el.getBoundingClientRect(); // 画布要贴到哪儿:视口坐标(这一帧只读一次)
2356
+ if (el === f.el && f.mode === "forge") {
2357
+ // 已经没有新字、但火星还在飞:继续跑帧把它烧完(跑完底下会把画布摘掉)
2358
+ const cooling = f.parts.length > 0;
2359
+ const caret = el === forgeEl && enabledForge() ? caretPoint(f) : null;
2360
+ if (caret || (el === forgeEl && enabledForge())) plan.push({ f, hot: true, w: el.clientWidth, x: rect.left, y: (caret ? caret.bottom : rect.bottom) + SPARK_DROP, caret });
2361
+ else if (cooling) plan.push({ f, hot: true, cooling: true }); // 已经撒手但还有火星在飞:继续跑帧把它们烧完
2362
+ else { f.level = 0; f.lit = 0; plan.push({ f, hot: false }); }
2363
+ } else if (el === focusEl && enabled() && el.clientHeight > 0) {
2364
+ plan.push({ f, hot: true, w: el.clientWidth, x: rect.left, y: rect.bottom, top: el.scrollTop, height: el.scrollHeight });
2365
+ } else { f.level = 0; f.lit = 0; plan.push({ f, hot: false }); }
2366
+ }
2367
+ let busy = false;
2368
+ for (const item of plan) {
2369
+ const f = item.f;
2370
+ if (item.hot && f.mode === "forge") {
2371
+ if (!item.cooling) {
2372
+ resize(f, item.w);
2373
+ place(f, item.x, item.y); // 地面线(= 画布底边往上 SPARK_DROP)= 写头那一行的底边
2374
+ }
2375
+ const caret = item.caret;
2376
+ const spot = caret ? { // 写头 → 画布局部坐标(不额外读布局)
2377
+ x: caret.left - f.px + (caret.right - caret.left) * 0.5,
2378
+ y: clamp(caret.bottom - f.py, 4, f.ground - 1),
2379
+ } : null;
2380
+ if (forgeTick(f, caret, spot, dtms)) busy = true;
2381
+ } else if (item.hot) {
2382
+ const { level, dir, resume } = readLevel(f, dtms, item.top, item.height);
2383
+ resize(f, item.w);
2384
+ place(f, item.x, item.y); // 画布底边 = 窗口底边(贴边)
2385
+ emit(f, s, level, dir, resume);
2386
+ if (level > 0) busy = true;
2387
+ }
2388
+ step(f, s);
2389
+ if (f.parts.length) busy = true;
2390
+ if (f.parts.length) draw(f);
2391
+ else if (item.hot) { if (f.painted) draw(f); } // 刚烧完:清一次屏,别把上一帧的火星冻在画布上
2392
+ else drop(f.el); // 不再发光(关掉/失焦/跑完):画布摘掉,DOM 不留东西
2393
+ // 火星烧完、而且**已经不是当前锚点**:这一帧就撤干净(否则帧循环一停手它会一直留在表里)。
2394
+ // ⚠ 当前锚点不能在这里摘:字段一重建,"写头移动量"就从头算,会把一次大位移当成刚写了字(停写后凭空砸火星)。
2395
+ if (!f.parts.length && f.el !== focusEl && f.el !== forgeEl) drop(f.el);
2396
+ }
2397
+ cost.frames += 1;
2398
+ const ms = performance.now() - t0;
2399
+ cost.lastMs = +ms.toFixed(3);
2400
+ cost.ms += ms;
2401
+ if (ms > cost.maxMs) cost.maxMs = +ms.toFixed(3);
2402
+ return busy;
2403
+ }
2404
+ return { focus, forge, setOptions, tick, reset, resetCost, stats };
2405
+ }
2406
+ /* 逐行现场:折叠与小窗的判定都建立在这些事实上,出问题时先看它。
2407
+ 必须在 sparks 工厂**之外**声明 —— startController 里的 __dshStreamfold.rows 要引用它。
2408
+ (曾经被写在 createSparkFx 内部,导致 startController 抛 ReferenceError、整个插件起不来。) */
2409
+ const sparks = createSparkFx();
2410
+
2411
+ /* ----------------------------------------------------------- 控制器 -- */
2412
+ let scheduled = false;
2413
+ let scanTimer = 0; // schedule 里那个补扫定时器
2414
+ let readyTimer = 0; // [streamfold] ready 那条日志的句柄:卸载后不该再打
2415
+ let lastScan = 0;
2416
+ let allowAnim = false; // 首屏不做动效(几十个过渡是加载卡顿主因)
2417
+ let stoodDown = false; // 官方档位下是否已撒手(重活只做一次)
2418
+ let observer = null;
2419
+ const SCAN_INTERVAL = 300;
2420
+
2421
+ function scanStructure() {
2422
+ if (disposed) return;
2423
+ try { scanStructureInner(); }
2424
+ catch (error) { cost.lastError = String((error && error.message) || error); throw error; } // 扫描里抛错要看得见,而不是静默少记一次
2425
+ finally { scanAnimateAll = false; justFinishedTurn = null; } // 官方档早退 / 抛错都不许把这个「本次扫描」标志留给下一次(否则切回折叠档整批历史行会播动画)
2426
+ }
2427
+ function scanStructureInner() {
2428
+ const t0 = performance.now();
2429
+ cost.scans += 1; // 和 phases 一起记,避免"有阶段数据却没有计数"
2430
+ animBudget = ANIM_BUDGET; // 每次扫描重置动画配额
2431
+ cost.counts = { hasText: 0, cards: 0, layouts: 0, opens: 0 };
2432
+ cost.phases.openThink = 0;
2433
+ cost.phases.openTool = 0;
2434
+ layoutReads = 0;
2435
+ cost.phases.hasText = 0;
2436
+ cost.phases.cards = 0;
2437
+ answerRowCache = new Map(); // 每次扫描重算:行还在变(流式),不能跨扫描复用
2438
+ // eslint-disable-next-line no-use-before-define
2439
+ rootVars();
2440
+ syncSession(); // 换会话先把串号状态清掉(轮号复用会让运行中的轮次被折)
2441
+ const flow = chatScope();
2442
+ if (state.mode !== "fold") {
2443
+ // 官方档位 = 插件整体撒手:不留任何我们的标记/自绘控件/跟随接管/置顶条(重活只在切过来的那一次做)。
2444
+ if (!stoodDown) { stoodDown = true; standDown(flow); }
2445
+ // busyWork 留着会让 schedule 每 300ms 自续(永久空转);wasRunning 留着会在切回折叠时误触发"跑完回到提问处"。
2446
+ busyWork = false;
2447
+ wasRunning = false;
2448
+ syncNativeRow(); // 设置页那一行仍由我们接管:两种模式都只显示一个下拉
2449
+ return;
2450
+ }
2451
+ stoodDown = false;
2452
+ if (!flow) return;
2453
+ // 运行态带粘性:标记一消失就当成跑完,会在步骤/工具交接的空档里折一次(用户看到"运行中全折起来")。
2454
+ // 只有连续 RUN_GRACE_MS 看不到任何运行标记,才算这一轮结束 —— 收尾的折叠只做这一次。
2455
+ const stamp = performance.now();
2456
+ const collected = collectTurns(); // 提前取一次:运行判定要用最后一轮
2457
+ // 契约自检:官方每一轮都会渲染一个 [data-turn-tail](收尾标记就挂在它上面)。已经有 2 轮以上却一个都没有,
2458
+ // 说明是内核换了 DOM(而不是"首轮还没跑完")——此时 openLastTurn 恒真、最后一轮永不折叠,只报警不猜。
2459
+ if (!contractWarned && collected.turns.size >= 2 && flow.querySelector("[data-turn-tail]") === null) {
2460
+ contractWarned = true;
2461
+ console.warn("[streamfold] 找不到官方的 [data-turn-tail] 收尾标记:运行态判定可能与内核不符,请跑 __dshStreamfold.rows() 核对");
2462
+ }
2463
+ const markRunning = flow.querySelector(RUNNING) !== null;
2464
+ if (markRunning) lastRunningAt = stamp;
2465
+ else if (runningSticky) {
2466
+ // 标记已经没了、粘性还在:跑完那一刻之后没有 DOM 变化就不会再有扫描,
2467
+ // 所以这里自己排一次 —— 到点做收尾(折叠 + 回到提问处),只做一次。
2468
+ clearTimeout(runGraceTimer);
2469
+ runGraceTimer = setTimeout(schedule, Math.max(32, lastRunningAt + RUN_GRACE_MS - stamp + 32));
2470
+ }
2471
+ runningSticky = lastRunningAt !== 0 && stamp - lastRunningAt < RUN_GRACE_MS;
2472
+ // 最后一轮还没结束(官方没给它加 tail actions)→ 一定还在跑,哪怕此刻没有任何流式标记
2473
+ // (模型在两步之间、工具在跑、刚提交还没出字,这些空档都不该被当成"跑完")
2474
+ const openLastTurn = collected.lastTurn !== null && !turnClosed(collected.lastTurn);
2475
+ const running = markRunning || runningSticky || openLastTurn;
2476
+ if (settleHoldTurn !== null && stamp > settleHoldUntil) { settleHoldTurn = null; settleHoldUntil = 0; scanAnimateAll = true; } // 兜底放行:滑行被别处取消时别把折叠无限期拖住(这仍是收尾折叠,照样播动画)
2477
+ const justFinished = wasRunning && !running;
2478
+ justFinishedTurn = justFinished && collected.lastTurn !== null ? String(collected.lastTurn) : null; // 收尾折叠的强制动画对象
2479
+ if (justFinished) settleToPrompt(flow, collected.rows); // 刚跑完这一轮(「移到提问处」在这里排一次)
2480
+ wasRunning = running;
2481
+
2482
+ busyWork = false;
2483
+ closeBudget = 2;
2484
+ const tC = performance.now();
2485
+ const bodies = flow.querySelectorAll(BODY_ANY); // 一次遍历供三处共用
2486
+ // 只读一遍:所有"要动高度"的正文在这里量完(读一个写一个是重排放大器,一次扫描只该有一遍布局)
2487
+ const heights = new Map();
2488
+ const wantReveal = state.animations !== false && allowAnim;
2489
+ for (const body of bodies) {
2490
+ if (body.getAttribute("hidden") === SKIP) continue; // 为搜索藏起的正文不渲染,量了也是 0
2491
+ if (body.matches(THINK_BODY)) {
2492
+ if (body.dataset.dshsfWindow !== "reasoning") heights.set(body, body.getBoundingClientRect().height);
2493
+ } else if (wantReveal && body.dataset.dshsfReveal !== "1" && body.dataset.dshsfNoReveal !== "1" && body.dataset.dshsfClosing !== "1") {
2494
+ heights.set(body, body.getBoundingClientRect().height);
2495
+ }
2496
+ }
2497
+ pinRows = collected.rows.filter((el) => el.getAttribute("data-chat-flow-kind") === "user"); // 置顶条数据源:复用这份行快照,不再自己遍历一遍
2498
+ if (pinPick >= pinRows.length) pinPick = -1;
2499
+ cost.phases.collect = +(performance.now() - tC).toFixed(2);
2500
+ const turnKeys = Array.from(collected.turns.keys());
2501
+ hotRows = new WeakSet();
2502
+ for (const key of turnKeys.slice(-2)) for (const row of collected.turns.get(key)) hotRows.add(row);
2503
+ let t1 = performance.now();
2504
+ syncWindows(flow, running, collected, bodies, heights);
2505
+ cost.phases.windows = +(performance.now() - t1).toFixed(2);
2506
+ t1 = performance.now();
2507
+ syncTurns(flow, running, collected);
2508
+ cost.phases.turns = +(performance.now() - t1).toFixed(2);
2509
+ t1 = performance.now();
2510
+ syncTextWindows(flow, collected);
2511
+ cost.phases.text = +(performance.now() - t1).toFixed(2);
2512
+ t1 = performance.now();
2513
+ syncContextRows(flow, bodies, heights);
2514
+ cost.phases.ctx = +(performance.now() - t1).toFixed(2);
2515
+ const ms = performance.now() - t0;
2516
+ cost.lastMs = +ms.toFixed(2);
2517
+ cost.counts.layouts = layoutReads;
2518
+ cost.scanMs += ms;
2519
+ scanSamples.push(ms);
2520
+ if (scanSamples.length > SCAN_SAMPLES) scanSamples.shift();
2521
+ if (ms > cost.maxMs) {
2522
+ cost.maxMs = +ms.toFixed(2);
2523
+ // 记住最贵那一次:分阶段 + 调用次数 + 当时的 DOM 规模(判断"是不是那一刻 DOM 特别大")
2524
+ cost.worst = {
2525
+ ms: cost.lastMs,
2526
+ phases: { ...cost.phases },
2527
+ top: topPhases(cost.phases),
2528
+ counts: { ...cost.counts, layouts: layoutReads },
2529
+ dom: { rows: collected.rows.length, bodies: bodies.length, nodes: flow.getElementsByTagName("*").length },
2530
+ };
2531
+ }
2532
+ syncNativeRow();
2533
+ }
2534
+ function scan() {
2535
+ lastScan = performance.now();
2536
+ scanStructure();
2537
+ anchorSync(); // 扫描刚改过布局:同一个任务里把被点行补回去,别等到绘制之后
2538
+ ensureTickLoop();
2539
+ }
2540
+ function schedule() {
2541
+ if (disposed || scheduled) return;
2542
+ scheduled = true;
2543
+ requestAnimationFrame(() => {
2544
+ scheduled = false;
2545
+ const now = performance.now();
2546
+ const wait = SCAN_INTERVAL - (now - lastScan);
2547
+ if (disposed) return;
2548
+ if (scanTimer) { clearTimeout(scanTimer); scanTimer = 0; } // 同一个节流窗口会被排很多次:先撤旧的,只留一个待触发
2549
+ if (wait <= 0) { lastScan = now; scanStructure(); }
2550
+ else scanTimer = setTimeout(schedule, wait); // 被节流挡下的这次变化,到点补扫一遍(别丢)
2551
+ if (busyWork) { if (scanTimer) clearTimeout(scanTimer); scanTimer = setTimeout(schedule, SCAN_INTERVAL); } // 还有没做完的展开/收起,继续排队
2552
+ ensureTickLoop(); // 跟随统一走帧循环,一帧只推进一次
2553
+ });
2554
+ }
2555
+ function rowsReport() {
2556
+ const flow = chatScope();
2557
+ const { turns, lastTurn, runningTurn } = collectTurns();
2558
+ const mark = flow.querySelector(RUNNING);
2559
+ return {
2560
+ mode: state.mode,
2561
+ runningMark: mark === null ? null : (mark.getAttribute("data-state") || "streaming"),
2562
+ runningTurn,
2563
+ lastTurn,
2564
+ lastTurnClosed: turnClosed(lastTurn),
2565
+ tailNodes: flow.querySelectorAll("[data-turn-tail]").length, // 官方收尾标记节点数(本会话,≥2 轮时不该是 0)
2566
+ turns: [...turns.entries()].slice(-6).map(([turn, list]) => ({
2567
+ turn,
2568
+ rows: list.length,
2569
+ kinds: list.map((r) => rowKind(r)).join(","),
2570
+ folded: list.filter((r) => r.hasAttribute("data-dshsf-folded")).length,
2571
+ })),
2572
+ rows: chatRows().slice(-14).map((r) => ({
2573
+ kind: rowKind(r),
2574
+ turn: r.getAttribute("data-chat-turn"),
2575
+ part: r.getAttribute("data-chat-group-part"),
2576
+ row: r.hasAttribute("data-dshsf-row") ? 1 : 0,
2577
+ folded: r.hasAttribute("data-dshsf-folded") ? 1 : 0,
2578
+ think: r.querySelectorAll(THINK).length,
2579
+ tool: r.querySelectorAll(TOOL_CARD).length,
2580
+ body: r.querySelectorAll(BODY_ANY).length,
2581
+ h: Math.round(r.getBoundingClientRect().height),
2582
+ })),
2583
+ };
2584
+ }
2585
+ function stats() {
2586
+ const flow = chatScope();
2587
+ const { turns, lastTurn } = collectTurns();
2588
+ const lastRows = turns.get(lastTurn) || [];
2589
+ return {
2590
+ mode: state.mode,
2591
+ running: flow.querySelector(RUNNING) !== null,
2592
+ lastTurn,
2593
+ windows: document.querySelectorAll('[data-dshsf-window="reasoning"]').length,
2594
+ folded: document.querySelectorAll("[data-dshsf-folded]").length,
2595
+ foldedLastTurn: lastRows.filter((row) => row.hasAttribute("data-dshsf-folded")).length,
2596
+ openThink: document.querySelectorAll(THINK + " " + THINK_BODY).length,
2597
+ chips: document.querySelectorAll("[data-dshsf-chip]").length,
2598
+ anim: document.documentElement.hasAttribute("data-dshsf-anim") ? 1 : 0,
2599
+ };
2600
+ }
2601
+ /** 被祖先裁掉后真正露出的高度(视口外也为 0):折叠行/官方 hidden 里的思考行不算"看得见"。 */
2602
+ function visibleHeight(el) {
2603
+ const rect = el.getBoundingClientRect();
2604
+ let top = rect.top, bottom = rect.bottom;
2605
+ for (let node = el.parentElement; node; node = node.parentElement) {
2606
+ const style = getComputedStyle(node);
2607
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0" || node.hasAttribute("hidden")) return 0;
2608
+ if (style.overflowY !== "visible" || style.overflowX !== "visible") {
2609
+ const box = node.getBoundingClientRect();
2610
+ top = Math.max(top, box.top);
2611
+ bottom = Math.min(bottom, box.bottom);
2612
+ }
2613
+ }
2614
+ return Math.max(0, bottom - top);
2615
+ }
2616
+ function probe() {
2617
+ const flow = chatScope();
2618
+ const { rows, turns, lastTurn } = collectTurns();
2619
+ if (rows.length === 0) return { error: "no chat rows found" };
2620
+ const answerRows = new Set();
2621
+ for (const turnRows of turns.values()) {
2622
+ const asst = turnRows.filter((row) => rowKind(row) === "assistant-step");
2623
+ if (asst.length) answerRows.add(asst[asst.length - 1]);
2624
+ }
2625
+ const style = document.querySelector("style[data-plugin-css='" + STYLE_ID + "']");
2626
+ const wrap = document.querySelector("[data-dshsf-reveal]") || document.querySelector("[class*='_bodyWrap']");
2627
+ const css = wrap ? getComputedStyle(wrap) : null;
2628
+ return {
2629
+ rows: rows.length,
2630
+ lastTurn,
2631
+ runningMark: flow.querySelector(RUNNING) ? "yes" : "no",
2632
+ officialView: nativeValue, // 折叠模式下应为官方「不折叠」档(旧内核 normal,0.1.7 起 verbose),不能是 compact
2633
+ bound: lowerBound(), // 小窗底边不许越过的下边界(composer 上沿,视口坐标);null = 没认到 composer 座位
2634
+
2635
+ // 回底按钮自检:n=我方注入的按钮数、vis=当前可见数,detail=每个小窗的状态
2636
+ jumps: {
2637
+ n: document.querySelectorAll("[data-dshsf-jump]").length,
2638
+ vis: document.querySelectorAll("[data-dshsf-jump][data-dshsf-visible]").length,
2639
+ detail: Array.from(liveWindows).slice(0, 5).map((el) => ({
2640
+ kind: el.dataset.dshsfWindow || el.dataset.dshsfTextwindow || "?",
2641
+ has: el.querySelector(":scope > [data-dshsf-jump]") ? 1 : 0,
2642
+ sub: el.querySelectorAll("[data-dshsf-jump]").length,
2643
+ gap: Math.round(el.scrollHeight - el.clientHeight - el.scrollTop),
2644
+ bottom: Math.round(el.getBoundingClientRect().bottom), // 应与 probe().bound 对齐(不许超过它)
2645
+ follow: el.dataset.dshsfFollow === "1" ? 1 : 0,
2646
+ vis: el.querySelector(":scope > [data-dshsf-jump][data-dshsf-visible]") ? 1 : 0,
2647
+ })),
2648
+ },
2649
+ perf: { ticks: tickCount, announces: announceCount }, // 帧循环次数 / 补发 scroll 次数
2650
+ // 我们自己的 JS 成本:帧循环平均每帧多少毫秒、结构扫描平均每次多少毫秒(不含浏览器布局/绘制)
2651
+ cost: {
2652
+ frames: cost.frames,
2653
+ tickAvgMs: +(cost.tickMs / Math.max(1, cost.frames)).toFixed(3),
2654
+ scans: cost.scans,
2655
+ scanAvgMs: +(cost.scanMs / Math.max(1, cost.scans)).toFixed(2), // 含加载时那一次全量折叠,会被它拉高
2656
+ scanLastMs: cost.lastMs, // 最近一次扫描
2657
+ scanMaxMs: cost.maxMs, // 最差的一次(样本少/远程桌面下不可信:线程被挂起也算进来)
2658
+ pinAvgMs: +(cost.pinMs / Math.max(1, cost.pinFrames)).toFixed(4), // 置顶条每帧成本(常态应≈几微秒)
2659
+ scanP50: percentile(scanSamples, 0.5), // ← 看这个:我们自己的典型成本
2660
+ scanP90: percentile(scanSamples, 0.9),
2661
+ samples: scanSamples.length,
2662
+ phases: cost.phases, // 最近一次扫描的分阶段耗时
2663
+ top: topPhases(cost.phases), // 同上,但按耗时排序取前 5(一眼看贵在哪)
2664
+ counts: cost.counts, // 最近一次扫描里的真实调用次数
2665
+ worst: cost.worst, // 最差那一次:分阶段 + 调用次数 + 当时 DOM 规模
2666
+ lastError: cost.lastError, // 扫描里抛过什么错(没抛是 null)
2667
+ },
2668
+ skipped: document.querySelectorAll('[hidden="until-found"]').length, // 已跳过渲染的折叠行(应能 Ctrl+F 搜到)
2669
+ searchBodies: document.querySelectorAll("[data-dshsf-search]").length, // 为搜索而挂载并藏起的思考正文数
2670
+ heapMb: (() => { const m = performance.memory; return m ? Math.round(m.usedJSHeapSize / 1048576) : null; })(),
2671
+ // 主窗口贴底自检:on=是否跟随、gap=离底像素、intentAgo=最近一次读者输入距今毫秒
2672
+ settleLog: settleLog.slice(-8),
2673
+ foldLog: foldLog.slice(-8),
2674
+ settleHold: settleHoldTurn,
2675
+ animAll: scanAnimateAll, // 本次扫描是否被标记为「用户正看着的折叠」(诊断:跨扫描残留会看得见)
2676
+ mainFollow: (() => {
2677
+ const el = mainScroller();
2678
+ if (!el) return null;
2679
+ return { on: followOn, gap: Math.round(el.scrollHeight - el.clientHeight - el.scrollTop), intentAgo: Math.round(performance.now() - scrollIntentAt), height: followHeight, wrote: followWrote === null ? null : Math.round(followWrote), steals: stealCount, undo: undoCount, undoRO: guardCount, moves: moveCount, stealMax, stealTail, blocked: snapBlocked, flips: followFlips.slice(-6),
2680
+ officialJump: document.querySelector("[class*='_toBottomSlot']") === null ? 0 : 1, // 官方那颗在不在 DOM(由官方 state 决定)
2681
+ jumpGate: document.documentElement.hasAttribute("data-dshsf-jumpgate") ? 1 : 0, // 我们的放行开关(= 我们判定该显示)
2682
+ 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) } };
2685
+ })(),
2686
+ keepText: state.keepInterleavedText !== false ? 1 : 0,
2687
+ 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
+ ctxBodies: document.querySelectorAll(CTX_BODY).length,
2698
+ // 各轮所有"带正文"的行:answer=1 是答案行(设计上不套窗);wins 是套上的正文窗数
2699
+ textRows: rows.filter((row) => rowKind(row) === "assistant-step" && hasText(row)).slice(-12).map((row) => ({
2700
+ turn: row.getAttribute("data-chat-turn"),
2701
+ answer: answerRows.has(row) ? 1 : 0,
2702
+ folded: row.hasAttribute("data-dshsf-folded") ? 1 : 0,
2703
+ wins: row.querySelectorAll("[data-dshsf-textwindow]").length,
2704
+ len: rowText(assistantBody(row) || row).length,
2705
+ })),
2706
+ // 非 assistant-step 却带文字的行(若穿插正文挂在这类行上,这里会露出来)
2707
+ otherTextKinds: rows.reduce((acc, row) => {
2708
+ if (rowKind(row) === "assistant-step") return acc;
2709
+ const text = rowText(row);
2710
+ if (text === "") return acc;
2711
+ const key = rowKind(row) || "?";
2712
+ acc[key] = (acc[key] || 0) + 1;
2713
+ return acc;
2714
+ }, {}),
2715
+ // 结构快照:正文到底挂在哪一层(最后 6 行)
2716
+ shapes: rows.slice(-6).map((row) => {
2717
+ const body = assistantBody(row);
2718
+ return {
2719
+ kind: rowKind(row),
2720
+ body: body ? (body.className || body.tagName) : null,
2721
+ kids: body ? Array.from(body.children).map((el) => (el.className || el.tagName) + "|" + (el.textContent || "").trim().length + (el.querySelector && el.querySelector(THINK) ? "*t" : "")) : [],
2722
+ };
2723
+ }),
2724
+ // 「还看得见一个思考」时看这里:每一行都是一个已挂载的思考正文,附上没被收掉的原因
2725
+ openThinks: Array.from(flow.querySelectorAll(THINK + " " + THINK_BODY)).map((body) => {
2726
+ const root = body.closest(THINK);
2727
+ const row = body.closest(ROW);
2728
+ return {
2729
+ turn: row && row.getAttribute("data-chat-turn"),
2730
+ kind: row && rowKind(row),
2731
+ rowFolded: row && row.hasAttribute("data-dshsf-folded") ? 1 : 0,
2732
+ thinkFolded: root && root.hasAttribute("data-dshsf-folded") ? 1 : 0,
2733
+ keepOpen: root && root.dataset.dshsfKeepOpen === "1" ? 1 : 0,
2734
+ officialHidden: root && root.closest("[hidden]") ? 1 : 0,
2735
+ h: Math.round(body.getBoundingClientRect().height),
2736
+ };
2737
+ }),
2738
+ // 「还看得见一行思考」看这里:折叠后应该是 [](摘要行与展开正文都算,被折叠行裁掉的不算)
2739
+ visibleThinks: Array.from(flow.querySelectorAll(THINK)).map((root) => {
2740
+ const row = root.closest(ROW);
2741
+ return {
2742
+ vis: Math.round(visibleHeight(root)),
2743
+ h: Math.round(root.getBoundingClientRect().height),
2744
+ turn: row && row.getAttribute("data-chat-turn"),
2745
+ kind: row && rowKind(row),
2746
+ expanded: root.hasAttribute("data-expanded") ? 1 : 0,
2747
+ rowFolded: row && row.hasAttribute("data-dshsf-folded") ? 1 : 0,
2748
+ thinkFolded: root.hasAttribute("data-dshsf-folded") ? 1 : 0,
2749
+ keepOpen: root.dataset.dshsfKeepOpen === "1" ? 1 : 0,
2750
+ officialHidden: root.closest("[hidden]") ? 1 : 0,
2751
+ officialInline: root.closest("[data-turn-process-inline]") ? 1 : 0,
2752
+ };
2753
+ }).filter((item) => item.vis > 1),
2754
+ anim: {
2755
+ attr: document.documentElement.hasAttribute("data-dshsf-anim"), // = 动效开关 && (交互或 4 秒后)
2756
+ lastClose, // 最近一次收起:anim=是否播了动画、以及每个判据当时的值
2757
+ lastReveal,
2758
+ lastWindow, // 最近一次思考小窗:h=实测高度 cap=动画目标 now=40ms 时高度 anim=计算动画名
2759
+ setting: state.animations !== false,
2760
+ allowAnim,
2761
+ styleTag: style !== null,
2762
+ currentCss: style !== null && style.textContent.indexOf("dshsf-body-in") >= 0,
2763
+ reveals: document.querySelectorAll("[data-dshsf-reveal]").length,
2764
+ bodyWraps: document.querySelectorAll("[class*='_bodyWrap']").length,
2765
+ wrapClass: wrap ? wrap.className : null,
2766
+ wrapAnim: css ? css.animationName + " / " + css.animationDuration : null,
2767
+ },
2768
+ sparks: sparks.stats(),
2769
+ sample: rows.slice(-10).map((row) => ({
2770
+ kind: rowKind(row),
2771
+ turn: row.getAttribute("data-chat-turn"),
2772
+ think: row.querySelectorAll(THINK).length,
2773
+ thinkBody: row.querySelectorAll(THINK_BODY).length,
2774
+ text: hasText(row) ? 1 : 0,
2775
+ textWin: row.querySelectorAll("[data-dshsf-textwindow]").length,
2776
+ folded: row.hasAttribute("data-dshsf-folded") ? 1 : 0,
2777
+ hidden: row.hasAttribute("hidden") ? 1 : 0,
2778
+ })),
2779
+ };
2780
+ }
2781
+ function startController() {
2782
+ // 热重载/重复注入时,上一个实例可能还活着(disposer 没被调到)——那样按钮、帧循环、画布都会翻倍。
2783
+ // 先把上一个实例收掉,保证同一时刻只有一个控制器在前台。
2784
+ 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
+ for (const old of document.querySelectorAll("[data-dshsf-pin]")) { try { old.remove(); } catch { /* ignore */ } } // 同上:置顶条只留本实例那一个
2790
+ 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();
2792
+ // 开动效 = 标志位 + 立刻把 html[data-dshsf-anim] 置位(只改标志位要等下一次扫描,首次点开卡片常常赶不上)
2793
+ const wakeAnim = () => { if (!allowAnim) { allowAnim = true; rootVars(); } };
2794
+ const timer = setTimeout(wakeAnim, 4000);
2795
+ window.addEventListener("pointerdown", wakeAnim, { capture: true, passive: true });
2796
+ window.addEventListener("keydown", wakeAnim, true);
2797
+ // 读者滚动的输入信号(滚轮/触摸/翻页键):贴底跟随只认这些,布局变化不取消跟随
2798
+ for (const type of ["wheel", "touchstart", "touchmove"]) window.addEventListener(type, noteScrollIntent, { capture: true, passive: true });
2799
+ window.addEventListener("keydown", noteScrollIntent, true);
2800
+ window.addEventListener("resize", ensureTickLoop, { passive: true }); // 视口一变:回底按钮该不该出现、置顶条摆哪儿都得重算
2801
+ // 用户自己点开工具卡时,React 提交后给"新出现的正文"补播揭示动画。
2802
+ // 不能按 [data-tool] 找:bash 卡由官方 BashRow 渲染,根上没有 data-tool,只能按正文类名认。
2803
+ const onUserActivate = (event) => {
2804
+ if (state.mode !== "fold" || synthetic || !(event.target instanceof Element)) return; // 官方档位:不拦点击、不播揭示动效
2805
+ const flow = chatScope();
2806
+ if (!flow.contains(event.target)) return; // 点的不在对话流里(侧栏/设置)→ 不扫
2807
+ if (event.target.closest(TOOL_BODY)) return; // 点的是正文内部(复制/选字/开文件),不是展开动作
2808
+ // 展开/收起 = 我准备读这一处:先撒手(这一步必须赶在 React 提交之前,否则跟随先把它拖到底部),
2809
+ // 再把被点的行钉在原来的屏幕位置,覆盖整段动画。
2810
+ const toggle = event.target.closest(TOGGLE);
2811
+ if (toggle) {
2812
+ const anchorEl = toggle.closest(ROW) || toggle;
2813
+ const thinkRoot = toggle.closest(THINK);
2814
+ const prepped = thinkRoot !== null && thinkRoot.querySelector("[data-dshsf-search]") !== null;
2815
+ clearSearchHidden(thinkRoot || anchorEl);
2816
+ if (prepped) { event.preventDefault(); event.stopPropagation(); } // 放行 = 官方收起 = 正文被卸载,搜索又白做
2817
+ followOn = false;
2818
+ anchorDuring(anchorEl, anchorEl.getBoundingClientRect().top, 500);
2819
+ queueMicrotask(anchorSync); // React 在同一个 click 里提交,微任务阶段补一次(仍在绘制之前)
2820
+ }
2821
+ const before = new Set(flow.querySelectorAll("[class*='_bodyWrap']"));
2822
+ const beforeCtx = new Set(flow.querySelectorAll(CTX_BODY));
2823
+ requestAnimationFrame(() => { // rAF 在 paint 之前,避免先闪一帧原始样子
2824
+ for (const body of flow.querySelectorAll("[class*='_bodyWrap']")) if (!before.has(body)) revealBody(body);
2825
+ for (const body of flow.querySelectorAll(CTX_BODY)) if (!beforeCtx.has(body)) revealBody(body);
2826
+ for (const body of flow.querySelectorAll(THINK_BODY)) tagThinkWindow(body);
2827
+ });
2828
+ };
2829
+ const onUserKey = (event) => { if (event.key === "Enter" || event.key === " ") onUserActivate(event); };
2830
+ // Ctrl+F 搜到被折起来(content-visibility 跳过渲染)的内容时,浏览器在"即将滚到那一处"前派发 beforematch:
2831
+ // 在这里把那一轮瞬时展开——搜索照样能命中,折叠省下的布局也不用还回去。
2832
+ const onBeforeMatch = (event) => {
2833
+ const target = event.target instanceof Element ? event.target : null;
2834
+ const skipped = state.mode === "fold" && target && (target.closest('[hidden="until-found"]') || target.closest("[data-dshsf-folded]"));
2835
+ if (!skipped) return;
2836
+ const row = skipped.closest(ROW) || skipped;
2837
+ clearSearchHidden(row); // 命中:把"为搜索而藏"的正文亮出来
2838
+ const turn = row.getAttribute("data-chat-turn");
2839
+ if (turn) manualState.set(turn, "open");
2840
+ followOn = false; // 搜到哪就看哪,别被贴底拖回底部
2841
+ const prev = allowAnim;
2842
+ allowAnim = false; // 瞬时展开:高度动画会把"滚到匹配处"带偏
2843
+ try { scan(); } finally { allowAnim = prev; }
2844
+ };
2845
+ const onFindKey = (event) => { if ((event.ctrlKey || event.metaKey) && (event.key === "f" || event.key === "F")) searchPrep(); };
2846
+ document.addEventListener("click", onUserActivate, true);
2847
+ document.addEventListener("click", onToBottomClick, true);
2848
+ document.addEventListener("keydown", onUserKey, true);
2849
+ document.addEventListener("keydown", onFindKey, true);
2850
+ document.addEventListener("beforematch", onBeforeMatch, true);
2851
+ // 行子树一变就把该行的「有没有正文」缓存作废:React 复用同一行节点换内容时,轮号可能不变、
2852
+ // 只认轮号挡不住 —— 缓存说「没正文」会让承载正式回答的行被当过程行折掉(B3)。
2853
+ observer = new MutationObserver((records) => {
2854
+ for (const rec of records) {
2855
+ const node = rec.target;
2856
+ const el = node && node.nodeType === 1 ? node : (node ? node.parentElement : null);
2857
+ const row = el && el.closest ? el.closest(ROW) : null;
2858
+ if (row) textCache.delete(row);
2859
+ // 新挂上来的思考正文**当场**打小窗(MutationObserver 回调是微任务,赶在这一帧绘制之前):等 300ms 的节流扫描的话,
2860
+ // 这段时间它会按原始高度铺开 —— 底边先掉到输入框下面,下一帧再"啪"地收成小窗(用户看到的"生成时掉一下")。
2861
+ if (rec.type === "childList" && state.mode === "fold") {
2862
+ for (const added of rec.addedNodes || []) {
2863
+ if (!added || added.nodeType !== 1) continue;
2864
+ const bodies = added.matches && added.matches(THINK_BODY) ? [added] : Array.from(added.querySelectorAll ? added.querySelectorAll(THINK_BODY) : []);
2865
+ for (const body of bodies) {
2866
+ const root = body.closest(THINK);
2867
+ if (root && root.closest("[data-dshsf-folded]")) continue; // 折起来的行不打(与扫描同一判据)
2868
+ tagThinkWindow(body);
2869
+ }
2870
+ }
2871
+ }
2872
+ }
2873
+ schedule();
2874
+ });
2875
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
2876
+ const unsubscribe = subscribe(schedule);
2877
+ const dispose = () => {
2878
+ disposed = true;
2879
+ ticking = false;
2880
+ if (rafId) { try { cancelAnimationFrame(rafId); } catch { /* ignore */ } rafId = 0; }
2881
+ clearTimeout(scanTimer);
2882
+ clearTimeout(readyTimer);
2883
+ clearTimeout(runGraceTimer);
2884
+ clearTimeout(searchPrepTimer); searchPrepTimer = 0; searchPrepRunning = false;
2885
+ liveWindows.clear();
2886
+ unsubscribe();
2887
+ clearTimeout(timer);
2888
+ window.removeEventListener("pointerdown", wakeAnim, true);
2889
+ window.removeEventListener("keydown", wakeAnim, true);
2890
+ for (const type of ["wheel", "touchstart", "touchmove"]) window.removeEventListener(type, noteScrollIntent, true);
2891
+ window.removeEventListener("keydown", noteScrollIntent, true);
2892
+ window.removeEventListener("resize", ensureTickLoop);
2893
+ document.removeEventListener("click", onUserActivate, true);
2894
+ document.removeEventListener("click", onToBottomClick, true);
2895
+ document.removeEventListener("keydown", onUserKey, true);
2896
+ document.removeEventListener("keydown", onFindKey, true);
2897
+ document.removeEventListener("beforematch", onBeforeMatch, true);
2898
+ if (followScroller) { followScroller.removeEventListener("scroll", onMainScroll); followScroller.removeEventListener("pointerdown", onScrollerPointerDown); followScroller = null; }
2899
+ try { anchorStop(); } catch { /* ignore */ } // 锚定接管(anchorRO + overflowAnchor)也必须回收
2900
+ try { if (guardRO && guardRO !== false) guardRO.disconnect(); } catch { /* ignore */ }
2901
+ guardRO = null; guardFlow = null; guardComposer = undefined;
2902
+ if (snapTaken !== null) { try { delete snapTaken.el.scrollTop; } catch { /* ignore */ } snapTaken = null; }
2903
+ 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
+ if (pinBar !== null) { try { pinBar.remove(); } catch { /* ignore */ } pinBar = null; }
2908
+ pinRows = []; pinRow = null;
2909
+ if (observer) observer.disconnect();
2910
+ 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
+ sparks.reset();
2924
+ if (window.__dshStreamfold && window.__dshStreamfold.dispose === dispose) delete window.__dshStreamfold;
2925
+ };
2926
+ window.__dshStreamfold = { stats, rows: rowsReport, probe, scan, set: setSettings, get: snapshot, chooseDisplay, resetCost, state: () => ({ ...state, nativeValue }),
2927
+ fx: sparks, dispose }; // 调参/排障入口:__dshStreamfold.fx 可实时调火花参数
2928
+ readyTimer = setTimeout(() => { if (disposed) return; try { console.info("[streamfold] ready", JSON.stringify(stats())); } catch { /* ignore */ } }, 2500);
2929
+ return dispose;
2930
+ }
2931
+
2932
+ /* ------------------------------------------------------------ 设置 UI -- */
2933
+ function Toggle(props) {
2934
+ return h("button", { type: "button", className: "dshsf-switch", role: "switch", "aria-checked": props.value ? "true" : "false", onClick: () => props.onChange(!props.value) });
2935
+ }
2936
+ function Row(props) {
2937
+ return h("div", { className: "dshsf-row" },
2938
+ h("div", { className: "dshsf-rowMain" },
2939
+ h("div", { className: "dshsf-rowTitle" }, props.title),
2940
+ props.desc ? h("div", { className: "dshsf-rowDesc" }, props.desc) : null),
2941
+ props.children);
2942
+ }
2943
+ function NumberInput(props) {
2944
+ return h("input", {
2945
+ className: "dshsf-num", type: "number", min: props.min, max: props.max, step: props.step || 1, value: props.value,
2946
+ onChange: (event) => { const next = Number(event.target.value); if (Number.isFinite(next)) props.onChange(next); },
2947
+ });
2948
+ }
2949
+ function Range(props) {
2950
+ const label = Number.isFinite(Number(props.value)) ? Number(props.value).toFixed(props.digits === undefined ? 1 : props.digits).replace(/\.0$/, "") : "";
2951
+ return h("div", { className: "dshsf-rangeWrap" },
2952
+ h("input", {
2953
+ className: "dshsf-range", type: "range", min: props.min, max: props.max, step: props.step || 0.1, value: props.value,
2954
+ onChange: (event) => { const next = Number(event.target.value); if (Number.isFinite(next)) props.onChange(next); },
2955
+ }),
2956
+ h("span", { className: "dshsf-rangeVal" }, label + (props.unit || "")));
2957
+ }
2958
+ /** 官方「工作步骤展示」那一行的下拉:官方四档 + 本插件的「折叠」,选中项与官方当前取值一致。 */
2959
+ function Select(props) {
2960
+ const [open, setOpen] = React.useState(false);
2961
+ const wrapRef = React.useRef(null);
2962
+ React.useEffect(() => {
2963
+ if (!open) return undefined;
2964
+ const close = (event) => { if (wrapRef.current && !wrapRef.current.contains(event.target)) setOpen(false); };
2965
+ const escape = (event) => { if (event.key === "Escape") setOpen(false); };
2966
+ document.addEventListener("pointerdown", close, true);
2967
+ document.addEventListener("keydown", escape, true);
2968
+ return () => {
2969
+ document.removeEventListener("pointerdown", close, true);
2970
+ document.removeEventListener("keydown", escape, true);
2971
+ };
2972
+ }, [open]);
2973
+ return h("div", { className: "dshsf-select", ref: wrapRef },
2974
+ h("button", {
2975
+ type: "button", className: "dshsf-selectBtn", "aria-haspopup": "menu", "aria-expanded": open ? "true" : "false",
2976
+ onClick: () => setOpen((was) => !was),
2977
+ }, h("span", null, props.options.find((o) => o.value === props.value)?.label ?? ""), h("span", { className: "dshsf-caret" }, "▾")),
2978
+ open ? h("div", { className: "dshsf-menu", role: "menu" }, props.options.map((option) =>
2979
+ h("button", {
2980
+ key: option.value, type: "button", role: "menuitemradio", "aria-checked": props.value === option.value ? "true" : "false",
2981
+ className: "dshsf-menuItem", "data-active": props.value === option.value ? "" : undefined,
2982
+ onClick: () => { setOpen(false); props.onChange(option.value); },
2983
+ }, option.label))) : null);
2984
+ }
2985
+ function DisplayRow() {
2986
+ const [choice, setChoice] = React.useState(displayChoice);
2987
+ React.useEffect(() => subscribe(() => setChoice(displayChoice())), []);
2988
+ React.useEffect(() => {
2989
+ if (!nativeScope) return undefined;
2990
+ try { return nativeScope.subscribe(() => setChoice(displayChoice())); } catch { return undefined; }
2991
+ }, []);
2992
+ const options = NATIVE_MODES.map((mode) => ({ value: mode, label: modeLabel(mode) })).concat([{ value: "fold", label: modeLabel("fold") }]);
2993
+ return h(Row, { title: uiText("工作步骤展示", "Work details"), desc: uiText("选择希望看到多少工具调用细节;「折叠」是「流式折叠」提供的模式", "How much tool-call detail to show. Fold is provided by Streamfold.") },
2994
+ h(Select, { value: choice, options, onChange: (value) => { chooseDisplay(value); setChoice(displayChoice()); } }));
2995
+ }
2996
+ function SettingsPage() {
2997
+ const s = useSettings();
2998
+ return h("div", { className: "dshsf-page" },
2999
+ h("div", { className: "dshsf-intro" }, "折叠模式:正在跑的一轮里思考与工具进限高小窗,其余轮次全量折叠;展开某轮时思考保持一行摘要。" ),
3000
+ h("div", { className: "dshsf-group" },
3001
+ h(Row, { title: "折叠历史轮次", desc: "关闭后:加载页面不再自动折叠历史轮次(只在跑动时折前面的)" },
3002
+ 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 }) }))),
3005
+ h("div", { className: "dshsf-group" },
3006
+ h(Row, { title: "窗口高度", desc: "运行中思考小窗的最大高度(px)" },
3007
+ h(NumberInput, { value: s.windowHeight, min: 80, max: 1200, onChange: (v) => setSettings({ windowHeight: v }) })),
3008
+ h(Row, { title: "运行中自动展开思考", desc: "只自动展开本轮最新的那一个;被取代的旧窗按下面的宽限折回一行摘要" },
3009
+ h(Toggle, { value: s.autoExpandReasoning !== false, onChange: (v) => setSettings({ autoExpandReasoning: v }) })),
3010
+ h(Row, { title: "旧窗折叠宽限", desc: "运行中新的思考窗出现后,旧窗再等多久才自动折回(秒)" },
3011
+ h(NumberInput, { value: s.supersedeDelay, min: 0, max: 60, step: 0.5, onChange: (v) => setSettings({ supersedeDelay: v }) })),
3012
+ h(Row, { title: "运行中自动展开工具", desc: "开:运行中最新的工具卡自动展开(用官方卡片自己的折叠/内滚,不套我们的限高窗)" },
3013
+ h(Toggle, { value: s.autoExpandTools !== false, onChange: (v) => setSettings({ autoExpandTools: v }) }))),
3014
+ h("div", { className: "dshsf-group" },
3015
+ h(Row, { title: "触底自动跟随", desc: "内容增长时自动贴底;你上滚即停跟" },
3016
+ h(Toggle, { value: s.autoFollow !== false, onChange: (v) => setSettings({ autoFollow: v }) })),
3017
+ h(Row, { title: "平滑系数", desc: "跟随底部时每帧吃掉多少差距,越小越柔(默认 0.15)" },
3018
+ 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
+ h(Row, { title: "离底显示「回到底部」" }, h(Toggle, { value: s.showJumpButton !== false, onChange: (v) => setSettings({ showJumpButton: v }) })),
3022
+ h(Row, { title: "跑完回到提问处", desc: "一轮结束后平滑移到本轮你那句话的位置;你已经自己上滚过就不动你。动效过渡关掉时直接到位" },
3023
+ h(Toggle, { value: s.settleToPrompt !== false, onChange: (v) => setSettings({ settleToPrompt: v }) })),
3024
+ h(Row, { title: "置顶显示提问", desc: "把「你正在看的那一轮」的提问钉在会话顶端,往上滚动会跟着换成那一轮(超过一行只显示一行);它自己在视口里时自动让位,点它滑回那句话" },
3025
+ h(Toggle, { value: s.pinPrompt !== false, onChange: (v) => setSettings({ pinPrompt: v }) })),
3026
+ h(Row, { title: "动效过渡" }, h(Toggle, { value: s.animations !== false, onChange: (v) => setSettings({ animations: v }) }))),
3027
+ h("div", { className: "dshsf-group" },
3028
+ h(Row, { title: "小窗火花", desc: "运行中思考小窗底部溅起火星(像滚筒碾过金属);关闭即完全不运行" },
3029
+ h(Toggle, { value: s.sparks !== false, onChange: (v) => setSettings({ sparks: v }) })),
3030
+ h(Row, { title: "火花颜色", desc: "默认冷蓝;火星核心自动提亮成白热" },
3031
+ h("input", { type: "color", className: "dshsf-color", value: s.sparkColor || "#4fa8ff", onChange: (e) => setSettings({ sparkColor: e.target.value }) })),
3032
+ h(Row, { title: "正文锻打火花", desc: "流式正文每写一段,就从写头(最后一行末尾)砸出一扇火星;停顿不写就不砸" },
3033
+ h(Toggle, { value: s.forgeSparks !== false, onChange: (v) => setSettings({ forgeSparks: v }) })),
3034
+ h(Row, { title: "锻打火花速度", desc: "火星飘出去的速度倍率(0.2~4,默认 1)" },
3035
+ h(Range, { value: s.forgeSpeed === undefined ? 1 : s.forgeSpeed, min: 0.2, max: 4, step: 0.1, unit: "×", onChange: (v) => setSettings({ forgeSpeed: v }) })),
3036
+ h(Row, { title: "锻打火花寿命", desc: "单颗火星活多久(0.2~5 秒,默认 1.3;实际在 ±30% 内随机)" },
3037
+ h(Range, { value: s.forgeLife === undefined ? 1.3 : s.forgeLife, min: 0.2, max: 5, step: 0.1, unit: "s", onChange: (v) => setSettings({ forgeLife: v }) })),
3038
+ h(Row, { title: "火花密度", desc: "火星数量倍率(0.2~3):越高越密,绘制开销也越大" },
3039
+ h(Range, { value: s.sparkDensity, min: 0.2, max: 3, step: 0.1, unit: "×", onChange: (v) => setSettings({ sparkDensity: v }) }))),
3040
+ h("div", { className: "dshsf-actions" },
3041
+ h("button", { type: "button", className: "dshsf-btn", onClick: () => setSettings({ ...DEFAULTS }) }, "恢复默认")));
3042
+ }
3043
+
3044
+ /* ------------------------------------------------------------- apply -- */
3045
+ /** 客户端 fiber 一旦 failed,GUI 只显示一行 `dsh-streamfold: failed`,原始错误既不进页面也不进控制台
3046
+ * (2026-09-25 就因为这个多花了一整轮才定位到 rowsReport 引用错误)——所以这里自己把错误打出来。 */
3047
+ function guardStart(label, start) {
3048
+ try { return start(); }
3049
+ catch (error) { console.error("[streamfold] client half failed: " + label, error); throw error; }
3050
+ }
3051
+ function apply(ctx) {
3052
+ ctx.effect(() => guardStart("styles", injectStyles), "dsh-streamfold: styles");
3053
+ ctx.effect(() => guardStart("controller", startController), "dsh-streamfold: controller");
3054
+ // 档位名沿用官方 chat 词典,下拉文案与官方设置页一致(取不到就退回内置中文)
3055
+ ctx.inject(["locale"], (cctx) => {
3056
+ try { tChat = cctx.locale.bind("chat"); } catch { /* ignore */ }
3057
+ });
3058
+ // 官方的对话显示档位由设置服务托管:0.1.7 起是 configForms(按 profile 条目 id 取 section),
3059
+ // 旧内核的 settingsScope 已不存在 —— 这里用可选注入,服务到位才接,插件本身不因它缺席而卡住。
3060
+ ctx.inject(["configForms"], (cctx) => {
3061
+ try {
3062
+ nativeScope = cctx.configForms.get(NATIVE_NS);
3063
+ const adopt = () => {
3064
+ const section = nativeScope.getSnapshot();
3065
+ const raw = section && section.value ? section.value[NATIVE_FIELD] : undefined;
3066
+ const mode = NATIVE_ALIAS[raw] || raw;
3067
+ if (NATIVE_MODES.includes(mode)) nativeValue = mode;
3068
+ };
3069
+ adopt();
3070
+ 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");
3075
+ } catch (error) { console.warn("[streamfold] native settings scope unavailable", error); }
3076
+ });
3077
+ if (ctx.slots) {
3078
+ ctx.slots.inject("settings.general.item", () => ctx.slots.register({ name: "settings.general.item", id: "streamfold", order: 12 }, DisplayRow));
3079
+ ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "streamfold", order: 42, label: () => uiText("流式折叠", "Streamfold") }, SettingsPage));
3080
+ }
3081
+ }
3082
+ exports.apply = apply;
3083
+ exports.inject = ["slots"];
3084
+ exports.defaults = DEFAULTS;
3085
+ return module.exports; } });