dsh-toolfold 0.1.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/CHANGELOG.md +63 -0
- package/LICENSE +21 -0
- package/README.en.md +136 -0
- package/README.md +120 -0
- package/cordis.patch.yml +23 -0
- package/lib/build-dynamic.cjs +69 -0
- package/lib/client.js +1838 -0
- package/lib/dynamic-body.js +1732 -0
- package/lib/index.js +137 -0
- package/package.json +44 -0
- package/scripts/install-dsh.cjs +53 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,1838 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-toolfold — CLIENT half (browser).
|
|
3
|
+
*
|
|
4
|
+
* Folding of consecutive tool calls and thinking in the DSH Web GUI chat
|
|
5
|
+
* flow, without replacing any built-in renderer:
|
|
6
|
+
*
|
|
7
|
+
* 1. Runs of consecutive `tool-call` rows fold into ONE compact bar: all
|
|
8
|
+
* cards of the run hide and the bar shows the LAST call's own one-line
|
|
9
|
+
* summary (cloned from the product card) plus "已折叠 N 个工具调用 · 点击展开".
|
|
10
|
+
* Clicking the bar toggles the run open/closed (state keyed by the run's
|
|
11
|
+
* first call). With "思考分隔" (splitThink, DEFAULT ON) ANY other row —
|
|
12
|
+
* settled thinking, in-progress thinking, AI text output, user
|
|
13
|
+
* messages — ends a run, so completed thinking SEPARATES runs:
|
|
14
|
+
* [read][thinking][write] folds into two independent bars ([read] and
|
|
15
|
+
* [write]) instead of one merged bar. With splitThink OFF the original
|
|
16
|
+
* behavior returns: a step that only settled Think rows occupy is
|
|
17
|
+
* transparent to the merge, so [read][thinking][write] folds the same
|
|
18
|
+
* way [read][write] does.
|
|
19
|
+
* 2. Settled Think rows (`[data-variant="think"][data-state="ok"]`, the
|
|
20
|
+
* product's own disclosure marker) are hidden entirely by default (the
|
|
21
|
+
* emptied row is removed from layout so no gap remains). With
|
|
22
|
+
* "保留思考" on they stay visible: between the folded bars in split
|
|
23
|
+
* mode, or folded with the run and re-inserted in their original order
|
|
24
|
+
* between the calls on expand in merge mode.
|
|
25
|
+
* 3. Streaming Think rows (`data-state="running"`) stay fully visible as
|
|
26
|
+
* their own independent row — in-progress thinking is never folded and
|
|
27
|
+
* keeps the calls around it apart until it completes.
|
|
28
|
+
* 4. Animations: expanding cascades the run's rows downward in a
|
|
29
|
+
* waterfall (staggered spring fall — rows land with a slight overshoot
|
|
30
|
+
* bounce); collapsing cascades them back up (staggered rise — each
|
|
31
|
+
* row fades and lifts on the SAME easing curve as its height shrink,
|
|
32
|
+
* so a tall card never looks "stuck" while its opacity is already
|
|
33
|
+
* gone) while each row's HEIGHT simultaneously shrinks to zero, so
|
|
34
|
+
* the content below the run follows upward continuously — no blank
|
|
35
|
+
* hole and no end snap. Cleanup runs exactly one step after the LAST
|
|
36
|
+
* row's animation finishes — no dead wait before the run disappears.
|
|
37
|
+
* When the reader is pinned near the bottom, expanding keeps the
|
|
38
|
+
* bar's viewport position fixed (the product's sticky-bottom follow
|
|
39
|
+
* would otherwise shove the bar out of the top).
|
|
40
|
+
*
|
|
41
|
+
* Performance (near-zero steady-state cost):
|
|
42
|
+
* - NO page-wide observer. The old body-subtree observer fired on every DOM
|
|
43
|
+
* mutation anywhere in the app; it is gone. Flow mounts/unmounts are
|
|
44
|
+
* caught by tiny childList-only observers on the flow parents (direct
|
|
45
|
+
* children only, no subtree) plus a visible-only safety rescan that
|
|
46
|
+
* backs off (3s → 10s) while the page is quiet.
|
|
47
|
+
* - Two-phase flow observers. A flow with no tool calls and no think
|
|
48
|
+
* blocks keeps a childList-only observer that fires only on row
|
|
49
|
+
* add/remove/reorder — while text streams inside its rows it costs
|
|
50
|
+
* nothing. Subtree + `data-state` attribute coverage engages only once
|
|
51
|
+
* the flow actually contains foldable content, and never downgrades.
|
|
52
|
+
* - Classified mutations: row adds/removes and think `data-state` flips
|
|
53
|
+
* run a full merge pass; content changes inside a tool-call row refresh
|
|
54
|
+
* ONLY the bar of the run whose LAST row was mutated, and only while
|
|
55
|
+
* that run is collapsed (throttled ~8/s). Expanded runs and non-last
|
|
56
|
+
* rows cost nothing. The single-appended-text-node case short-circuits
|
|
57
|
+
* with zero DOM walks, so assistant/think text streaming costs nothing.
|
|
58
|
+
* - Passes are rAF-batched, one per frame, dirty flows only; row
|
|
59
|
+
* assessments are cached per row (zero layout reads in steady state);
|
|
60
|
+
* bar parts are cached on the bar (no per-update selector lookups).
|
|
61
|
+
* - Visibility gating: while the tab is hidden the engine holds NO
|
|
62
|
+
* observers and NO timers — literally zero work. On return to
|
|
63
|
+
* visibility everything re-attaches and one refresh catches up.
|
|
64
|
+
*
|
|
65
|
+
* Settings (Settings → 插件 → 工具折叠, persisted through the DSH settings
|
|
66
|
+
* service into ~/.dsh/settings.yaml):
|
|
67
|
+
* - durMs: expand/collapse animation duration (0–2000, default 240);
|
|
68
|
+
* - keepThink: settled think stays visible instead of hidden (default off);
|
|
69
|
+
* - splitThink: settled think separates tool-call groups into independent
|
|
70
|
+
* bars (default on; off restores the old merge-across-thinking fold);
|
|
71
|
+
* - stats: live performance meter in the card (default off).
|
|
72
|
+
*
|
|
73
|
+
* Persistence is layered, in order of preference:
|
|
74
|
+
* 1. the official `settingsScope` transport (when the deployment exposes
|
|
75
|
+
* the `toolfold` namespace to the web client);
|
|
76
|
+
* 2. this plugin's host route `GET/POST /api/dsh-toolfold/settings`
|
|
77
|
+
* (registered by the host half, backed by the DSH settings service);
|
|
78
|
+
* 3. browser localStorage — degraded fallback for pages without the host
|
|
79
|
+
* half (remote browsers, dynamic-plugin dev mode).
|
|
80
|
+
*
|
|
81
|
+
* The engine works on the rendered DOM only (stable product attributes:
|
|
82
|
+
* `[data-chat-flow]`, `[data-chat-flow-kind]`, `[data-variant="think"]`,
|
|
83
|
+
* `data-state`, `[data-disclosure-row]`), so it degrades gracefully when the
|
|
84
|
+
* product markup changes: the worst case is that folding stops applying.
|
|
85
|
+
*
|
|
86
|
+
* Lifecycle: `installCollapseEngine(document, settings, timers)` returns a
|
|
87
|
+
* disposer; the plugin wires it through `ctx.effect` so stop/update removes
|
|
88
|
+
* the style tag, the observers, the timers, the bars and every injected
|
|
89
|
+
* class. Timers come from the Cordis `timer` service (ctx.timeout /
|
|
90
|
+
* ctx.interval disposers) because the dynamic client environment shadows the
|
|
91
|
+
* browser timer globals.
|
|
92
|
+
*/
|
|
93
|
+
window.__ModuleLoader__.load({
|
|
94
|
+
id: 'dsh-toolfold',
|
|
95
|
+
factory: function (require) {
|
|
96
|
+
var module = { exports: {} };
|
|
97
|
+
var exports = module.exports;
|
|
98
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
99
|
+
|
|
100
|
+
// React is a platform seed word in the web shell; the settings card
|
|
101
|
+
// needs it, the folding engine does not.
|
|
102
|
+
var React = null;
|
|
103
|
+
try {
|
|
104
|
+
React = require('react');
|
|
105
|
+
} catch (err) {
|
|
106
|
+
React = null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ------------------------------------------------------------------
|
|
110
|
+
// Settings. View preferences, persisted in localStorage; shared by the
|
|
111
|
+
// folding engine and the settings card through one snapshot store.
|
|
112
|
+
// ------------------------------------------------------------------
|
|
113
|
+
var SETTINGS_KEY = 'dsh-toolfold.settings.v1';
|
|
114
|
+
/** Pre-rename key; migrated once so existing preferences survive. */
|
|
115
|
+
var LEGACY_SETTINGS_KEY = 'dsh-codex-collapse.settings.v1';
|
|
116
|
+
var DEFAULT_SETTINGS = { durMs: 240, keepThink: false, splitThink: true, stats: false };
|
|
117
|
+
|
|
118
|
+
function loadSettings() {
|
|
119
|
+
var base = { durMs: DEFAULT_SETTINGS.durMs, keepThink: DEFAULT_SETTINGS.keepThink, splitThink: DEFAULT_SETTINGS.splitThink };
|
|
120
|
+
if (typeof localStorage === 'undefined') return base;
|
|
121
|
+
var raw = null;
|
|
122
|
+
try {
|
|
123
|
+
raw = localStorage.getItem(SETTINGS_KEY);
|
|
124
|
+
if (raw === null) {
|
|
125
|
+
var legacy = localStorage.getItem(LEGACY_SETTINGS_KEY);
|
|
126
|
+
if (legacy !== null) {
|
|
127
|
+
raw = legacy;
|
|
128
|
+
localStorage.setItem(SETTINGS_KEY, legacy);
|
|
129
|
+
localStorage.removeItem(LEGACY_SETTINGS_KEY);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return base;
|
|
134
|
+
}
|
|
135
|
+
if (raw === null) return base;
|
|
136
|
+
try {
|
|
137
|
+
var parsed = JSON.parse(raw);
|
|
138
|
+
var dur = Number(parsed.durMs);
|
|
139
|
+
return {
|
|
140
|
+
durMs: Number.isFinite(dur) ? Math.max(0, Math.min(2000, Math.round(dur))) : DEFAULT_SETTINGS.durMs,
|
|
141
|
+
keepThink: parsed.keepThink === true,
|
|
142
|
+
splitThink: parsed.splitThink !== false,
|
|
143
|
+
stats: parsed.stats === true
|
|
144
|
+
};
|
|
145
|
+
} catch (err) {
|
|
146
|
+
return base;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Minimal SnapshotStore: stable getSnapshot until update replaces it. */
|
|
151
|
+
function createSettingsStore() {
|
|
152
|
+
var snapshot = loadSettings();
|
|
153
|
+
var listeners = [];
|
|
154
|
+
return {
|
|
155
|
+
getSnapshot: function () { return snapshot; },
|
|
156
|
+
subscribe: function (listener) {
|
|
157
|
+
listeners.push(listener);
|
|
158
|
+
return function () {
|
|
159
|
+
var index = listeners.indexOf(listener);
|
|
160
|
+
if (index !== -1) listeners.splice(index, 1);
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
update: function (patch) {
|
|
164
|
+
snapshot = {
|
|
165
|
+
durMs: patch.durMs === undefined ? snapshot.durMs : patch.durMs,
|
|
166
|
+
keepThink: patch.keepThink === undefined ? snapshot.keepThink : patch.keepThink,
|
|
167
|
+
splitThink: patch.splitThink === undefined ? snapshot.splitThink : patch.splitThink === true,
|
|
168
|
+
stats: patch.stats === undefined ? snapshot.stats : patch.stats
|
|
169
|
+
};
|
|
170
|
+
if (typeof localStorage !== 'undefined') {
|
|
171
|
+
try {
|
|
172
|
+
localStorage.setItem(SETTINGS_KEY, JSON.stringify(snapshot));
|
|
173
|
+
} catch (err) { /* storage full/blocked: settings stay in-memory */ }
|
|
174
|
+
}
|
|
175
|
+
for (var i = 0; i < listeners.length; i++) listeners[i]();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ------------------------------------------------------------------
|
|
181
|
+
// DSH settings bridge. The folding engine and the settings card read one
|
|
182
|
+
// store; this bridge decides WHERE the store's values come from and go:
|
|
183
|
+
// the official settingsScope transport when the deployment exposes the
|
|
184
|
+
// namespace, else the host half's route (backed by the DSH settings
|
|
185
|
+
// service → ~/.dsh/settings.yaml), else localStorage. `status()` tells
|
|
186
|
+
// the card which tier is live.
|
|
187
|
+
// ------------------------------------------------------------------
|
|
188
|
+
var DSH_API = '/api/dsh-toolfold/settings';
|
|
189
|
+
|
|
190
|
+
function clampDur(value) {
|
|
191
|
+
var dur = Number(value);
|
|
192
|
+
return Number.isFinite(dur) ? Math.max(0, Math.min(2000, Math.round(dur))) : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Merge one fully-resolved DSH section into the local store. */
|
|
196
|
+
function adoptSection(store, section) {
|
|
197
|
+
store.update({
|
|
198
|
+
durMs: clampDur(section.durMs),
|
|
199
|
+
keepThink: section.keepThink === undefined ? undefined : section.keepThink === true,
|
|
200
|
+
splitThink: section.splitThink === undefined ? undefined : section.splitThink === true,
|
|
201
|
+
stats: section.stats === undefined ? undefined : section.stats === true
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function createSettingsBridge(ctx, store) {
|
|
206
|
+
var scope = null;
|
|
207
|
+
var scopeReady = false;
|
|
208
|
+
var routeOk = false;
|
|
209
|
+
var disposers = [];
|
|
210
|
+
|
|
211
|
+
// 1) Official settingsScope transport (settingsScope / webUiSettings).
|
|
212
|
+
var binder = null;
|
|
213
|
+
try {
|
|
214
|
+
if (typeof ctx.get === 'function') {
|
|
215
|
+
binder = ctx.get('webUiSettings') || ctx.get('settingsScope') || null;
|
|
216
|
+
}
|
|
217
|
+
} catch (err) {
|
|
218
|
+
binder = null;
|
|
219
|
+
}
|
|
220
|
+
if (binder !== null && typeof binder.bind === 'function') {
|
|
221
|
+
try {
|
|
222
|
+
scope = binder.bind({ namespace: 'toolfold' });
|
|
223
|
+
} catch (err) {
|
|
224
|
+
scope = null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (scope !== null && typeof scope.subscribe === 'function' && typeof scope.getSnapshot === 'function') {
|
|
228
|
+
disposers.push(scope.subscribe(function () {
|
|
229
|
+
var snap = scope.getSnapshot();
|
|
230
|
+
if (snap === null || typeof snap !== 'object') return;
|
|
231
|
+
if (snap.status === 'ready' && snap.value !== null && typeof snap.value === 'object') {
|
|
232
|
+
scopeReady = true;
|
|
233
|
+
routeOk = false; // the official transport outranks the route
|
|
234
|
+
adoptSection(store, snap.value);
|
|
235
|
+
} else if (snap.status === 'unavailable') {
|
|
236
|
+
scopeReady = false;
|
|
237
|
+
void routeLoad();
|
|
238
|
+
}
|
|
239
|
+
}));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 2) The host half's route (registered by lib/index.js).
|
|
243
|
+
function routeLoad() {
|
|
244
|
+
if (typeof fetch !== 'function') return Promise.resolve(false);
|
|
245
|
+
return fetch(DSH_API, { method: 'GET', cache: 'no-store' })
|
|
246
|
+
.then(function (res) { return res.ok ? res.json() : null; })
|
|
247
|
+
.then(function (body) {
|
|
248
|
+
if (body === null || body.ok !== true || body.value === null || typeof body.value !== 'object') {
|
|
249
|
+
routeOk = false;
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
routeOk = true;
|
|
253
|
+
adoptSection(store, body.value.value === undefined ? body.value : body.value.value);
|
|
254
|
+
return true;
|
|
255
|
+
})
|
|
256
|
+
.catch(function () { routeOk = false; return false; });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function routeWrite(field, value, op) {
|
|
260
|
+
if (typeof fetch !== 'function') return Promise.resolve(false);
|
|
261
|
+
var payload = op === 'unset'
|
|
262
|
+
? { op: 'unset', field: field }
|
|
263
|
+
: { op: 'set', field: field, value: value };
|
|
264
|
+
return fetch(DSH_API, {
|
|
265
|
+
method: 'POST',
|
|
266
|
+
cache: 'no-store',
|
|
267
|
+
headers: { 'content-type': 'application/json' },
|
|
268
|
+
body: JSON.stringify(payload)
|
|
269
|
+
})
|
|
270
|
+
.then(function (res) { return res.ok ? res.json() : null; })
|
|
271
|
+
.then(function (body) {
|
|
272
|
+
if (body === null || body.ok !== true || body.value === null || typeof body.value !== 'object') return false;
|
|
273
|
+
routeOk = true;
|
|
274
|
+
adoptSection(store, body.value.value === undefined ? body.value : body.value.value);
|
|
275
|
+
return true;
|
|
276
|
+
})
|
|
277
|
+
.catch(function () { return false; });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
/** Apply one field change: optimistic local update, then persist through the live tier. */
|
|
282
|
+
write: function (field, value) {
|
|
283
|
+
var patch = {};
|
|
284
|
+
patch[field] = value;
|
|
285
|
+
store.update(patch);
|
|
286
|
+
if (scopeReady && scope !== null && typeof scope.set === 'function') {
|
|
287
|
+
scope.set(field, value).catch(function () {});
|
|
288
|
+
} else {
|
|
289
|
+
void routeWrite(field, value, 'set');
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
unset: function (field) {
|
|
293
|
+
if (scopeReady && scope !== null && typeof scope.unset === 'function') {
|
|
294
|
+
scope.unset(field).catch(function () {});
|
|
295
|
+
} else {
|
|
296
|
+
void routeWrite(field, undefined, 'unset');
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
/** 'dsh' when a host-backed tier is live, 'local' otherwise. */
|
|
300
|
+
status: function () { return scopeReady || routeOk ? 'dsh' : 'local'; },
|
|
301
|
+
load: routeLoad,
|
|
302
|
+
dispose: function () {
|
|
303
|
+
for (var i = 0; i < disposers.length; i++) disposers[i]();
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ------------------------------------------------------------------
|
|
309
|
+
// Styles. Class names carry the `ccx` prefix; colors reuse the
|
|
310
|
+
// product's --dsw-* semantic aliases with plain fallbacks. Durations
|
|
311
|
+
// ride --ccx-dur / --ccx-step custom properties (set by the engine).
|
|
312
|
+
// ------------------------------------------------------------------
|
|
313
|
+
var STYLE_ID = 'dsh-toolfold/style';
|
|
314
|
+
var css = [
|
|
315
|
+
'[data-chat-flow] [data-chat-flow-kind].ccxMerged{display:none}',
|
|
316
|
+
'[data-chat-flow] [data-chat-flow-kind="assistant-step"].ccxEmpty{display:none}',
|
|
317
|
+
'[data-chat-flow]:not(.ccxKeepThink) [data-variant="think"][data-state="ok"]{display:none}',
|
|
318
|
+
'[data-chat-flow] .ccxBar{display:flex;align-items:center;gap:8px;box-sizing:border-box;width:100%;padding:3px 0;color:var(--dsw-alias-label-secondary);font-family:inherit;font-size:13px;line-height:20px;cursor:pointer;text-align:left}',
|
|
319
|
+
'[data-chat-flow] .ccxBar:focus-visible{outline:2px solid var(--dsw-static-deepseek-500,#4d6bfe);outline-offset:1px}',
|
|
320
|
+
'.ccxBarCall{flex:1 1 auto;min-width:0;display:flex;align-items:center;pointer-events:none;max-width:1400px;overflow:hidden;transition:max-width var(--ccx-dur,260ms) ease,opacity calc(var(--ccx-dur,260ms)*0.77) ease}',
|
|
321
|
+
'[data-chat-flow] .ccxBar.ccxExpanded .ccxBarCall{max-width:0;opacity:0}',
|
|
322
|
+
'.ccxBarCall > *{flex:1 1 auto;min-width:0;width:100%}',
|
|
323
|
+
'.ccxBarIcon{flex:none;font-size:10px;line-height:20px;opacity:.75;transition:transform var(--ccx-dur,240ms) ease}',
|
|
324
|
+
'.ccxBarLabel{flex:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:transform var(--ccx-dur,240ms) ease}',
|
|
325
|
+
'@keyframes ccxFall{from{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:translateY(0)}}',
|
|
326
|
+
'[data-chat-flow] [data-chat-flow-kind].ccxFalling{animation:ccxFall var(--ccx-dur,240ms) cubic-bezier(0.34,1.56,0.64,1) both;animation-delay:calc(var(--ccx-i,0)*var(--ccx-step,45ms))}',
|
|
327
|
+
'@keyframes ccxRise{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-20px)}}',
|
|
328
|
+
'[data-chat-flow] [data-chat-flow-kind].ccxCollapsing{animation:ccxRise var(--ccx-dur,240ms) cubic-bezier(0,0,0.58,1) both;animation-delay:calc(var(--ccx-i,0)*var(--ccx-step,45ms))}',
|
|
329
|
+
'@media (prefers-reduced-motion:reduce){[data-chat-flow] [data-chat-flow-kind].ccxFalling,[data-chat-flow] [data-chat-flow-kind].ccxCollapsing{animation:none}.ccxBarCall,.ccxBarIcon,.ccxBarLabel{transition:none}}',
|
|
330
|
+
// Settings card chrome — mirrors the product's PluginCard / field
|
|
331
|
+
// styles token-for-token, so the card is indistinguishable from the
|
|
332
|
+
// built-in ones (bordered 12px card, header with name over
|
|
333
|
+
// description, rotating chevron, field rows with label/hint).
|
|
334
|
+
'.ccxCard{list-style:none;border:1px solid var(--dsw-alias-border-l2,#e4e4e7);border-radius:12px;background:var(--dsw-alias-bg-layer-3,#fff);transition:border-color .16s,background .16s}',
|
|
335
|
+
'.ccxCard:hover{border-color:var(--dsw-alias-label-dimmed,#a1a1aa)}',
|
|
336
|
+
'.ccxCardOpen{background:var(--dsw-alias-bg-layer-2,#fafafa);border-color:var(--dsw-alias-label-dimmed,#a1a1aa)}',
|
|
337
|
+
'.ccxHeader{width:100%;appearance:none;border:0;background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:12px}',
|
|
338
|
+
'.ccxHeader:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4d6bfe);outline-offset:-2px}',
|
|
339
|
+
'.ccxHeadText{flex:1;min-width:0;display:flex;flex-direction:column;gap:4px}',
|
|
340
|
+
'.ccxName{font-size:15px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary,#222)}',
|
|
341
|
+
'.ccxDescription{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#888)}',
|
|
342
|
+
'.ccxChevron{flex:none;font-size:14px;line-height:1;color:var(--dsw-alias-label-tertiary,#888);transition:transform .16s}',
|
|
343
|
+
'.ccxChevronOpen{transform:rotate(180deg)}',
|
|
344
|
+
'.ccxBody{border-top:1px solid var(--dsw-alias-border-l2,#e4e4e7);margin:0 16px;padding:4px 0 8px}',
|
|
345
|
+
'.ccxField{display:flex;flex-direction:column;gap:6px;padding:12px 0}',
|
|
346
|
+
'.ccxField + .ccxField{border-top:1px solid var(--dsw-alias-border-l2,#e4e4e7)}',
|
|
347
|
+
'.ccxFieldHead{display:flex;align-items:center;gap:8px}',
|
|
348
|
+
'.ccxFieldLabel{flex:1;min-width:0;font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary,#222)}',
|
|
349
|
+
'.ccxFieldHint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#888)}',
|
|
350
|
+
'.ccxBadge{flex:none;border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px;white-space:nowrap;font-weight:500;background:var(--dsw-alias-bg-module-platform,#f0f0f2);color:var(--dsw-alias-label-secondary,#666)}',
|
|
351
|
+
'.ccxRange{width:100%;height:34px;margin:0;accent-color:var(--dsw-alias-brand-primary,#4d6bfe);cursor:pointer}',
|
|
352
|
+
'.ccxToggle{width:16px;height:16px;margin:0;flex:none;accent-color:var(--dsw-alias-brand-primary,#4d6bfe);cursor:pointer}',
|
|
353
|
+
'.ccxStatRow{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 0;font-size:12px;line-height:1.5}',
|
|
354
|
+
'.ccxStatValue{flex:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary,#666)}'
|
|
355
|
+
].join('');
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Install the folding engine on a document.
|
|
359
|
+
* @param doc - the browser document of the Web GUI page.
|
|
360
|
+
* @param settings - the shared settings store (getSnapshot/subscribe).
|
|
361
|
+
* @returns a disposer that removes every side effect.
|
|
362
|
+
*/
|
|
363
|
+
function installCollapseEngine(doc, settings, timers) {
|
|
364
|
+
var FLOW_SELECTOR = '[data-chat-flow]';
|
|
365
|
+
var BADGE_ATTR = 'data-ccx-badge';
|
|
366
|
+
var MERGED_CLASS = 'ccxMerged';
|
|
367
|
+
var EMPTY_CLASS = 'ccxEmpty';
|
|
368
|
+
var FALLING_CLASS = 'ccxFalling';
|
|
369
|
+
var COLLAPSING_CLASS = 'ccxCollapsing';
|
|
370
|
+
var KEEP_CLASS = 'ccxKeepThink';
|
|
371
|
+
/** Ease-out for the row height shrink (no overshoot: heights clamp). */
|
|
372
|
+
var COLLAPSE_EASE = 'cubic-bezier(0,0,0.58,1)';
|
|
373
|
+
|
|
374
|
+
var styleTag = null;
|
|
375
|
+
var adoptedStyle = false;
|
|
376
|
+
/** flow element -> observation mode ('light' | 'full') */
|
|
377
|
+
var flows = new Map();
|
|
378
|
+
/** flow element -> its attached MutationObserver (absent while hidden) */
|
|
379
|
+
var flowObservers = new Map();
|
|
380
|
+
/** parent element -> childList-only observer (flow mounts/unmounts) */
|
|
381
|
+
var parentObservers = new Map();
|
|
382
|
+
/** flow element -> Set of expanded run node keys */
|
|
383
|
+
var expanded = new Map();
|
|
384
|
+
/** flow element -> Map<run head key, bar element> */
|
|
385
|
+
var barsByFlow = new Map();
|
|
386
|
+
/** flow element -> Set of run head keys mid collapse animation */
|
|
387
|
+
var collapsing = new Map();
|
|
388
|
+
/** flows whose DOM changed since the last pass (pending re-pass) */
|
|
389
|
+
var dirtyFlows = new Set();
|
|
390
|
+
/** per-flow reason for the pending pass: content | think | rows */
|
|
391
|
+
var flowReasons = new Map();
|
|
392
|
+
/** flow element -> Set of tool rows touched by content mutations */
|
|
393
|
+
var contentRowsByFlow = new Map();
|
|
394
|
+
/** per-flow counter for synthetic run keys (rows lacking a node key) */
|
|
395
|
+
var syntheticKeys = new Map();
|
|
396
|
+
/** flows may have appeared or disappeared since the last pass */
|
|
397
|
+
var structureDirty = true;
|
|
398
|
+
/** observers live only while the tab is visible */
|
|
399
|
+
var active = true;
|
|
400
|
+
/** slow safety rescan while visible (catches remounted flows) */
|
|
401
|
+
var rescanTimer = null;
|
|
402
|
+
/** rescan delay, backing off while nothing changes (3s..10s) */
|
|
403
|
+
var rescanDelay = 3000;
|
|
404
|
+
var rafId = 0;
|
|
405
|
+
var pending = false;
|
|
406
|
+
var disposed = false;
|
|
407
|
+
var offSettings = null;
|
|
408
|
+
/** deferred boot init (one frame after activation) */
|
|
409
|
+
var booted = false;
|
|
410
|
+
var bootRaf = 0;
|
|
411
|
+
/** cached MediaQueryList for prefers-reduced-motion */
|
|
412
|
+
var motionMql = null;
|
|
413
|
+
/** cache of confirmed tool-call rows (closestToolCall positive hits) */
|
|
414
|
+
var toolRowCache = new WeakMap();
|
|
415
|
+
/** opt-in live cost stats (settings card); created at engine init */
|
|
416
|
+
var statsObj = null;
|
|
417
|
+
|
|
418
|
+
function statsBegin() {
|
|
419
|
+
if (statsObj === null || !statsObj.enabled) return null;
|
|
420
|
+
return performance.now();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function statsEnd(key, count, t0) {
|
|
424
|
+
if (t0 === null) return;
|
|
425
|
+
statsObj[key] += count;
|
|
426
|
+
statsObj[key + 'Ms'] += performance.now() - t0;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function ensureStats() {
|
|
430
|
+
if (statsObj !== null) return statsObj;
|
|
431
|
+
statsObj = {
|
|
432
|
+
enabled: settings.getSnapshot().stats === true,
|
|
433
|
+
start: Date.now(),
|
|
434
|
+
obs: 0, obsMs: 0,
|
|
435
|
+
refresh: 0, refreshMs: 0,
|
|
436
|
+
pass: 0, passMs: 0,
|
|
437
|
+
scan: 0, scanMs: 0,
|
|
438
|
+
clone: 0, cloneMs: 0,
|
|
439
|
+
skip: 0
|
|
440
|
+
};
|
|
441
|
+
return statsObj;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function reducedMotion() {
|
|
445
|
+
if (motionMql === null) {
|
|
446
|
+
motionMql = (typeof matchMedia === 'function') ? matchMedia('(prefers-reduced-motion: reduce)') : null;
|
|
447
|
+
}
|
|
448
|
+
return motionMql !== null && motionMql.matches;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function durMs() {
|
|
452
|
+
var value = settings.getSnapshot().durMs;
|
|
453
|
+
return reducedMotion() ? 0 : value;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function stepMs() {
|
|
457
|
+
var value = settings.getSnapshot().durMs;
|
|
458
|
+
return reducedMotion() ? 0 : Math.max(0, Math.min(60, Math.round(value * 45 / 240)));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Push the current durations into --ccx-dur / --ccx-step. */
|
|
462
|
+
function applySettings() {
|
|
463
|
+
var style = doc.documentElement.style;
|
|
464
|
+
style.setProperty('--ccx-dur', durMs() + 'ms');
|
|
465
|
+
style.setProperty('--ccx-step', stepMs() + 'ms');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ----------------------------------------------------------------
|
|
469
|
+
// Style tag (self-managed; removed on dispose).
|
|
470
|
+
// ----------------------------------------------------------------
|
|
471
|
+
function insertStyle() {
|
|
472
|
+
var existing = doc.querySelector('style[data-plugin-css="' + STYLE_ID + '"]');
|
|
473
|
+
if (existing !== null) {
|
|
474
|
+
styleTag = existing;
|
|
475
|
+
adoptedStyle = true;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
var tag = doc.createElement('style');
|
|
479
|
+
tag.setAttribute('data-plugin', 'dsh-toolfold');
|
|
480
|
+
tag.setAttribute('data-plugin-css', STYLE_ID);
|
|
481
|
+
tag.textContent = css;
|
|
482
|
+
doc.head.appendChild(tag);
|
|
483
|
+
styleTag = tag;
|
|
484
|
+
adoptedStyle = false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ----------------------------------------------------------------
|
|
488
|
+
// Scheduling: dirty-flag based, rAF-batched, one pass per frame at
|
|
489
|
+
// most. Only flows whose DOM actually changed are re-passed.
|
|
490
|
+
// ----------------------------------------------------------------
|
|
491
|
+
var REASON_RANK = { content: 1, think: 2, rows: 3 };
|
|
492
|
+
|
|
493
|
+
function markDirty(flow, reason) {
|
|
494
|
+
if (disposed) return;
|
|
495
|
+
var prev = flowReasons.get(flow);
|
|
496
|
+
if (prev === undefined || REASON_RANK[prev] < REASON_RANK[reason]) {
|
|
497
|
+
flowReasons.set(flow, reason);
|
|
498
|
+
}
|
|
499
|
+
dirtyFlows.add(flow);
|
|
500
|
+
// Any flow event means the page is alive: keep the safety rescan
|
|
501
|
+
// prompt so newly mounted containers are discovered quickly.
|
|
502
|
+
if (rescanDelay > 3000) {
|
|
503
|
+
rescanDelay = 3000;
|
|
504
|
+
if (rescanTimer !== null) {
|
|
505
|
+
timers.cancel(rescanTimer);
|
|
506
|
+
rescanTimer = null;
|
|
507
|
+
scheduleNextRescan();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
schedule();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function schedule() {
|
|
514
|
+
if (pending || disposed) return;
|
|
515
|
+
pending = true;
|
|
516
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
517
|
+
rafId = requestAnimationFrame(function () {
|
|
518
|
+
pending = false;
|
|
519
|
+
refresh();
|
|
520
|
+
});
|
|
521
|
+
} else {
|
|
522
|
+
pending = false;
|
|
523
|
+
refresh();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ----------------------------------------------------------------
|
|
528
|
+
// Flow discovery and per-flow observers.
|
|
529
|
+
// ----------------------------------------------------------------
|
|
530
|
+
/**
|
|
531
|
+
* Whether an added/removed node can change folding outcomes: a row
|
|
532
|
+
* (`[data-chat-flow-kind]`) or a think block (`[data-variant="think"]`).
|
|
533
|
+
*/
|
|
534
|
+
function nodeHasMarker(node) {
|
|
535
|
+
if (node.nodeType !== 1) return false;
|
|
536
|
+
if (typeof node.matches === 'function'
|
|
537
|
+
&& node.matches('[data-chat-flow-kind],[data-variant="think"]')) return true;
|
|
538
|
+
if (typeof node.querySelector === 'function'
|
|
539
|
+
&& node.querySelector('[data-chat-flow-kind],[data-variant="think"]') !== null) return true;
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Nearest ancestor (or self) that is a tool-call row. Plain attribute
|
|
545
|
+
* walk — the MutationObserver hot path never invokes the selector
|
|
546
|
+
* engine. The RESULT is cached on the start element (both positive and
|
|
547
|
+
* negative), so streaming — which keeps mutating the same containers —
|
|
548
|
+
* amortizes the walk to O(1) per container after the first event.
|
|
549
|
+
* Staleness would require reparenting a container into a tool row,
|
|
550
|
+
* which the product never does; worst case one missed content refresh.
|
|
551
|
+
*/
|
|
552
|
+
function closestToolCall(el) {
|
|
553
|
+
var start = (el.nodeType === 1) ? el : el.parentElement;
|
|
554
|
+
if (start === null) return null;
|
|
555
|
+
var hit = toolRowCache.get(start);
|
|
556
|
+
if (hit !== undefined) return hit;
|
|
557
|
+
var node = start;
|
|
558
|
+
while (node !== null && node.nodeType === 1) {
|
|
559
|
+
var cached = toolRowCache.get(node);
|
|
560
|
+
if (cached !== undefined) {
|
|
561
|
+
node = cached;
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
if (node.getAttribute('data-chat-flow-kind') === 'tool-call') break;
|
|
565
|
+
node = node.parentElement;
|
|
566
|
+
}
|
|
567
|
+
toolRowCache.set(start, node);
|
|
568
|
+
return node;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Classify a mutation batch by what it can change:
|
|
573
|
+
* - 'rows': row-level add/remove/reorder, or added/removed nodes that
|
|
574
|
+
* are or contain rows / think blocks — the merge must be rebuilt;
|
|
575
|
+
* - 'think': a `data-state` flip on a think element (settled thinking
|
|
576
|
+
* starts hiding, empties its assistant row, and in merge mode stops
|
|
577
|
+
* separating runs) — the merge must be rebuilt;
|
|
578
|
+
* - 'content': a mutation inside a tool-call row (result streaming) —
|
|
579
|
+
* only that run's bar clone can be stale, no merge rebuild needed;
|
|
580
|
+
* - null: content streaming inside any other row (think text tokens,
|
|
581
|
+
* markdown spans) — cannot change any folding outcome.
|
|
582
|
+
* The single appended-text-node case short-circuits with zero DOM
|
|
583
|
+
* walks, so agent/think streaming costs nothing at all. Content
|
|
584
|
+
* mutations report the affected tool row(s) through `collect`, so the
|
|
585
|
+
* refresh touches only the bar of the run whose LAST row changed.
|
|
586
|
+
*/
|
|
587
|
+
function mutationReason(records, flow, collect) {
|
|
588
|
+
if (records.length === 1) {
|
|
589
|
+
var only = records[0];
|
|
590
|
+
if (only.type === 'childList' && only.removedNodes.length === 0
|
|
591
|
+
&& only.addedNodes.length === 1 && only.addedNodes[0].nodeType === 3) {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
var best = 0; // 0 none, 1 content, 2 think, 3 rows
|
|
596
|
+
for (var i = 0; i < records.length; i++) {
|
|
597
|
+
var record = records[i];
|
|
598
|
+
if (record.type === 'attributes') {
|
|
599
|
+
if (record.attributeName === 'data-state'
|
|
600
|
+
&& record.target !== null && record.target.nodeType === 1
|
|
601
|
+
&& typeof record.target.matches === 'function'
|
|
602
|
+
&& record.target.matches('[data-variant="think"]')) {
|
|
603
|
+
if (best < 2) best = 2;
|
|
604
|
+
}
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (record.type === 'characterData') {
|
|
608
|
+
var toolRow = closestToolCall(record.target);
|
|
609
|
+
if (toolRow !== null) {
|
|
610
|
+
if (collect !== undefined) collect(toolRow);
|
|
611
|
+
if (best < 1) best = 1;
|
|
612
|
+
}
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
if (record.type !== 'childList') continue;
|
|
616
|
+
var target = record.target;
|
|
617
|
+
if (target === flow) {
|
|
618
|
+
best = 3;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (target !== null && target.nodeType === 1) {
|
|
622
|
+
var row = closestToolCall(target);
|
|
623
|
+
if (row !== null) {
|
|
624
|
+
if (collect !== undefined) collect(row);
|
|
625
|
+
if (best < 1) best = 1;
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
var lists = [record.addedNodes, record.removedNodes];
|
|
630
|
+
for (var l = 0; l < lists.length && best < 3; l++) {
|
|
631
|
+
var nodes = lists[l];
|
|
632
|
+
for (var j = 0; j < nodes.length; j++) {
|
|
633
|
+
if (nodeHasMarker(nodes[j])) { best = 3; break; }
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (best === 0) return null;
|
|
638
|
+
if (best === 1) return 'content';
|
|
639
|
+
if (best === 2) return 'think';
|
|
640
|
+
return 'rows';
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Attach (or replace) the per-flow observer for a mode. 'light' is a
|
|
645
|
+
* childList-only observer on the flow's direct children — the only
|
|
646
|
+
* events that can change folding are row adds/removes/reorders, so a
|
|
647
|
+
* pure-chat flow costs nothing while text streams inside its rows.
|
|
648
|
+
* 'full' adds subtree childList and `data-state` attribute coverage,
|
|
649
|
+
* which foldable content (tool cards, think blocks) actually needs.
|
|
650
|
+
*/
|
|
651
|
+
function attachFlowObserver(flow, mode) {
|
|
652
|
+
var existing = flowObservers.get(flow);
|
|
653
|
+
if (existing !== null && existing !== undefined) existing.disconnect();
|
|
654
|
+
if (typeof MutationObserver !== 'function') {
|
|
655
|
+
flowObservers.set(flow, null);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
var observer;
|
|
659
|
+
if (mode === 'light') {
|
|
660
|
+
observer = new MutationObserver(function () {
|
|
661
|
+
var t0 = statsBegin();
|
|
662
|
+
markDirty(flow, 'rows');
|
|
663
|
+
statsEnd('obs', 1, t0);
|
|
664
|
+
});
|
|
665
|
+
observer.observe(flow, { childList: true });
|
|
666
|
+
} else {
|
|
667
|
+
observer = new MutationObserver(function (records) {
|
|
668
|
+
var t0 = statsBegin();
|
|
669
|
+
var contentSet = undefined;
|
|
670
|
+
var reason = mutationReason(records, flow, function (row) {
|
|
671
|
+
if (contentSet === undefined) {
|
|
672
|
+
contentSet = contentRowsByFlow.get(flow);
|
|
673
|
+
if (contentSet === undefined) {
|
|
674
|
+
contentSet = new Set();
|
|
675
|
+
contentRowsByFlow.set(flow, contentSet);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
contentSet.add(row);
|
|
679
|
+
});
|
|
680
|
+
if (reason !== null) {
|
|
681
|
+
markDirty(flow, reason);
|
|
682
|
+
} else if (statsObj !== null && statsObj.enabled) {
|
|
683
|
+
statsObj.skip += 1;
|
|
684
|
+
}
|
|
685
|
+
statsEnd('obs', 1, t0);
|
|
686
|
+
});
|
|
687
|
+
observer.observe(flow, {
|
|
688
|
+
childList: true,
|
|
689
|
+
subtree: true,
|
|
690
|
+
attributes: true,
|
|
691
|
+
attributeFilter: ['data-state']
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
flowObservers.set(flow, observer);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Switch a flow's observation mode (attaches only while visible). */
|
|
698
|
+
function setFlowMode(flow, mode) {
|
|
699
|
+
if (flows.get(flow) === mode) return;
|
|
700
|
+
flows.set(flow, mode);
|
|
701
|
+
if (active) attachFlowObserver(flow, mode);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function discover() {
|
|
705
|
+
var list = doc.querySelectorAll(FLOW_SELECTOR);
|
|
706
|
+
for (var i = 0; i < list.length; i++) {
|
|
707
|
+
var flow = list[i];
|
|
708
|
+
if (flows.has(flow)) continue;
|
|
709
|
+
var needFull = typeof flow.querySelector === 'function'
|
|
710
|
+
&& flow.querySelector('[data-chat-flow-kind="tool-call"],[data-variant="think"]') !== null;
|
|
711
|
+
flows.set(flow, needFull ? 'full' : 'light');
|
|
712
|
+
expanded.set(flow, new Set());
|
|
713
|
+
barsByFlow.set(flow, new Map());
|
|
714
|
+
collapsing.set(flow, new Set());
|
|
715
|
+
if (active) attachFlowObserver(flow, needFull ? 'full' : 'light');
|
|
716
|
+
// Newly discovered flows always need an initial pass.
|
|
717
|
+
markDirty(flow, 'rows');
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Flow mount/unmount detection WITHOUT a page-wide observer: each
|
|
723
|
+
* known flow's parent gets a childList-only observer (direct children
|
|
724
|
+
* only, no subtree), so it fires only when a sibling flow appears or
|
|
725
|
+
* disappears — never on chat churn. Parents that appeared while the
|
|
726
|
+
* engine was idle are covered by the slow visible-only rescan.
|
|
727
|
+
*/
|
|
728
|
+
function observeParents() {
|
|
729
|
+
if (!active || typeof MutationObserver !== 'function') return;
|
|
730
|
+
flows.forEach(function (mode, flow) {
|
|
731
|
+
var parent = flow.parentElement;
|
|
732
|
+
if (parent === null || parentObservers.has(parent)) return;
|
|
733
|
+
var observer = new MutationObserver(function (records) {
|
|
734
|
+
var t0 = statsBegin();
|
|
735
|
+
var relevant = false;
|
|
736
|
+
for (var i = 0; i < records.length && !relevant; i++) {
|
|
737
|
+
var record = records[i];
|
|
738
|
+
if (record.type !== 'childList') continue;
|
|
739
|
+
var lists = [record.addedNodes, record.removedNodes];
|
|
740
|
+
for (var l = 0; l < lists.length && !relevant; l++) {
|
|
741
|
+
var nodes = lists[l];
|
|
742
|
+
for (var j = 0; j < nodes.length; j++) {
|
|
743
|
+
var node = nodes[j];
|
|
744
|
+
if (node.nodeType !== 1 || typeof node.matches !== 'function') continue;
|
|
745
|
+
if (node.matches(FLOW_SELECTOR)
|
|
746
|
+
|| (typeof node.querySelector === 'function'
|
|
747
|
+
&& node.querySelector(FLOW_SELECTOR) !== null)) {
|
|
748
|
+
relevant = true;
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (relevant) {
|
|
755
|
+
structureDirty = true;
|
|
756
|
+
schedule();
|
|
757
|
+
}
|
|
758
|
+
statsEnd('obs', 1, t0);
|
|
759
|
+
});
|
|
760
|
+
observer.observe(parent, { childList: true });
|
|
761
|
+
parentObservers.set(parent, observer);
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ----------------------------------------------------------------
|
|
766
|
+
// Slow safety rescan + visibility gating. The rescan is discovery-
|
|
767
|
+
// only (one attribute scan + map diff), schedules a pass only when a
|
|
768
|
+
// flow actually appeared, and backs off 3s → 10s while the page is
|
|
769
|
+
// quiet (any flow event resets it). While the tab is hidden the
|
|
770
|
+
// engine holds NO observers and NO timers: literally zero work. On
|
|
771
|
+
// return to visibility everything re-attaches and one refresh pass
|
|
772
|
+
// catches up with whatever changed meanwhile.
|
|
773
|
+
// ----------------------------------------------------------------
|
|
774
|
+
function rescanTick() {
|
|
775
|
+
rescanTimer = null;
|
|
776
|
+
if (disposed || !active || doc.hidden) return;
|
|
777
|
+
var t0 = statsBegin();
|
|
778
|
+
// Discovery-only tick: one `[data-chat-flow]` scan + map diff. No
|
|
779
|
+
// pass is scheduled unless a flow was actually discovered (which
|
|
780
|
+
// marks it dirty), and the delay backs off (3s → 10s) while the
|
|
781
|
+
// page is stable — an idle page costs one attribute scan every
|
|
782
|
+
// ~10s at most. Light→full observer upgrades are driven by row
|
|
783
|
+
// events (pass outcomes), never by per-flow subtree scans.
|
|
784
|
+
var before = flows.size;
|
|
785
|
+
discover();
|
|
786
|
+
pruneFlows();
|
|
787
|
+
observeParents();
|
|
788
|
+
if (flows.size === before) {
|
|
789
|
+
// Nothing mounted/unmounted. Back off only while at least one
|
|
790
|
+
// flow is known — with none known, first discovery must stay
|
|
791
|
+
// prompt.
|
|
792
|
+
if (flows.size > 0) rescanDelay = Math.min(rescanDelay * 2, 10000);
|
|
793
|
+
} else {
|
|
794
|
+
rescanDelay = 3000;
|
|
795
|
+
}
|
|
796
|
+
statsEnd('scan', 1, t0);
|
|
797
|
+
scheduleNextRescan();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function scheduleNextRescan() {
|
|
801
|
+
if (rescanTimer !== null) return;
|
|
802
|
+
rescanTimer = timers.after(rescanTick, rescanDelay);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function startRescan() {
|
|
806
|
+
rescanDelay = 3000;
|
|
807
|
+
scheduleNextRescan();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function stopRescan() {
|
|
811
|
+
if (rescanTimer !== null) {
|
|
812
|
+
timers.cancel(rescanTimer);
|
|
813
|
+
rescanTimer = null;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function disconnectObservers() {
|
|
818
|
+
flowObservers.forEach(function (observer) {
|
|
819
|
+
if (observer !== null && observer !== undefined) observer.disconnect();
|
|
820
|
+
});
|
|
821
|
+
flowObservers.clear();
|
|
822
|
+
parentObservers.forEach(function (observer) {
|
|
823
|
+
observer.disconnect();
|
|
824
|
+
});
|
|
825
|
+
parentObservers.clear();
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function onVisibility() {
|
|
829
|
+
setActive(!doc.hidden);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function setActive(on) {
|
|
833
|
+
if (on === active) return;
|
|
834
|
+
active = on;
|
|
835
|
+
if (on) {
|
|
836
|
+
if (!booted) {
|
|
837
|
+
bootInit();
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
flows.forEach(function (mode, flow) {
|
|
841
|
+
if (doc.contains(flow)) attachFlowObserver(flow, mode);
|
|
842
|
+
});
|
|
843
|
+
observeParents();
|
|
844
|
+
startRescan();
|
|
845
|
+
structureDirty = true;
|
|
846
|
+
schedule();
|
|
847
|
+
} else {
|
|
848
|
+
stopRescan();
|
|
849
|
+
disconnectObservers();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ----------------------------------------------------------------
|
|
854
|
+
// Row helpers.
|
|
855
|
+
// ----------------------------------------------------------------
|
|
856
|
+
function rowList(flow) {
|
|
857
|
+
var rows = [];
|
|
858
|
+
var children = flow.children;
|
|
859
|
+
for (var i = 0; i < children.length; i++) {
|
|
860
|
+
var el = children[i];
|
|
861
|
+
if (el.nodeType === 1 && el.hasAttribute('data-chat-flow-kind')) rows.push(el);
|
|
862
|
+
}
|
|
863
|
+
return rows;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Per-row assessment of think-only content, cached by a signature of
|
|
868
|
+
* the row's think blocks (count + data-states + keepThink + splitThink
|
|
869
|
+
* flags). Reassessed only when the signature changes; the check is
|
|
870
|
+
* purely structural, so steady-state passes perform zero layout reads.
|
|
871
|
+
*
|
|
872
|
+
* In split mode (default) a row that only settled Think rows occupy
|
|
873
|
+
* ENDS the run it follows — completed thinking separates runs, it
|
|
874
|
+
* never merges across them. In merge mode it is transparent: it folds
|
|
875
|
+
* WITH the surrounding tool calls. Either way it is empty (hidden)
|
|
876
|
+
* when think is not preserved; with "保留思考" on it stays visible —
|
|
877
|
+
* between the bars in split mode, inside the run on expand in merge
|
|
878
|
+
* mode.
|
|
879
|
+
*/
|
|
880
|
+
var rowInfo = new WeakMap();
|
|
881
|
+
var NEUTRAL_ASSESSMENT = { sig: '', thinks: [], transparent: false, empty: false };
|
|
882
|
+
|
|
883
|
+
function thinkSig(thinks, keepThink, splitThink) {
|
|
884
|
+
var sig = (keepThink ? '1' : '0') + (splitThink ? '1' : '0');
|
|
885
|
+
for (var i = 0; i < thinks.length; i++) {
|
|
886
|
+
sig += ':' + (thinks[i].getAttribute('data-state') || '');
|
|
887
|
+
}
|
|
888
|
+
return sig;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Whether an element contains nothing but think blocks and empty
|
|
893
|
+
* wrappers: every text leaf outside a think block is whitespace-only
|
|
894
|
+
* and every non-think element recurses. Structural — no layout reads,
|
|
895
|
+
* immune to wrapper padding/margins that a height check would mistake
|
|
896
|
+
* for content.
|
|
897
|
+
*/
|
|
898
|
+
function thinkOnlyContent(el) {
|
|
899
|
+
var nodes = el.childNodes;
|
|
900
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
901
|
+
var node = nodes[i];
|
|
902
|
+
if (node.nodeType === 3) {
|
|
903
|
+
if (node.textContent.trim() !== '') return false;
|
|
904
|
+
} else if (node.nodeType === 1) {
|
|
905
|
+
if (node.matches('[data-variant="think"]')) continue;
|
|
906
|
+
if (!thinkOnlyContent(node)) return false;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function assessRow(row, keepThink, splitThink) {
|
|
913
|
+
if (row.getAttribute('data-chat-flow-kind') !== 'assistant-step') return NEUTRAL_ASSESSMENT;
|
|
914
|
+
var cached = rowInfo.get(row);
|
|
915
|
+
if (cached !== undefined && cached.sig === thinkSig(cached.thinks, keepThink, splitThink)) {
|
|
916
|
+
return cached;
|
|
917
|
+
}
|
|
918
|
+
var thinks = row.querySelectorAll('[data-variant="think"]');
|
|
919
|
+
var assessment = {
|
|
920
|
+
sig: thinkSig(thinks, keepThink, splitThink),
|
|
921
|
+
thinks: thinks,
|
|
922
|
+
transparent: false,
|
|
923
|
+
empty: false
|
|
924
|
+
};
|
|
925
|
+
if (thinks.length > 0 && thinkOnlyContent(row)) {
|
|
926
|
+
var allOk = true;
|
|
927
|
+
for (var i = 0; i < thinks.length; i++) {
|
|
928
|
+
if (thinks[i].getAttribute('data-state') !== 'ok') { allOk = false; break; }
|
|
929
|
+
}
|
|
930
|
+
if (allOk) {
|
|
931
|
+
// A row made of settled think alone: transparent (folds with
|
|
932
|
+
// the surrounding calls) only in merge mode; in split mode it
|
|
933
|
+
// ends the run. Hidden unless 保留思考 keeps it visible.
|
|
934
|
+
assessment.transparent = !splitThink;
|
|
935
|
+
assessment.empty = !keepThink;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
rowInfo.set(row, assessment);
|
|
939
|
+
return assessment;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ----------------------------------------------------------------
|
|
943
|
+
// The collapsed-run bar (flow-level element; replaces every card of
|
|
944
|
+
// the run when collapsed). Shows the LAST call's one-line summary.
|
|
945
|
+
// ----------------------------------------------------------------
|
|
946
|
+
/** Strip interactivity from a card-row clone so it is display-only. */
|
|
947
|
+
function sanitizeClone(node) {
|
|
948
|
+
node.removeAttribute('role');
|
|
949
|
+
node.removeAttribute('tabindex');
|
|
950
|
+
node.removeAttribute('aria-expanded');
|
|
951
|
+
node.removeAttribute('data-expandable');
|
|
952
|
+
var buttons = node.querySelectorAll('button');
|
|
953
|
+
for (var i = 0; i < buttons.length; i++) {
|
|
954
|
+
var button = buttons[i];
|
|
955
|
+
var span = doc.createElement('span');
|
|
956
|
+
span.className = button.className;
|
|
957
|
+
span.textContent = button.textContent;
|
|
958
|
+
button.replaceWith(span);
|
|
959
|
+
}
|
|
960
|
+
return node;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function createBar(flow, run) {
|
|
964
|
+
var bar = doc.createElement('div');
|
|
965
|
+
bar.className = 'ccxBar';
|
|
966
|
+
bar.setAttribute(BADGE_ATTR, '');
|
|
967
|
+
bar.setAttribute('role', 'button');
|
|
968
|
+
bar.setAttribute('tabindex', '0');
|
|
969
|
+
var call = doc.createElement('span');
|
|
970
|
+
call.className = 'ccxBarCall';
|
|
971
|
+
call.setAttribute('aria-hidden', 'true');
|
|
972
|
+
var icon = doc.createElement('span');
|
|
973
|
+
icon.className = 'ccxBarIcon';
|
|
974
|
+
var label = doc.createElement('span');
|
|
975
|
+
label.className = 'ccxBarLabel';
|
|
976
|
+
bar.appendChild(call);
|
|
977
|
+
bar.appendChild(icon);
|
|
978
|
+
bar.appendChild(label);
|
|
979
|
+
// Cached parts: updateBar/updateBarClone avoid selector lookups.
|
|
980
|
+
bar._ccxIcon = icon;
|
|
981
|
+
bar._ccxLabel = label;
|
|
982
|
+
bar._ccxCall = call;
|
|
983
|
+
var toggle = function () {
|
|
984
|
+
var set = expanded.get(flow);
|
|
985
|
+
if (set === undefined || disposed) return;
|
|
986
|
+
if (set.has(run.headKey)) {
|
|
987
|
+
startCollapse(flow, run, bar);
|
|
988
|
+
} else {
|
|
989
|
+
set.add(run.headKey);
|
|
990
|
+
cancelCollapse(flow, run);
|
|
991
|
+
pass(flow);
|
|
992
|
+
pinBar(bar, run);
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
bar.addEventListener('click', toggle);
|
|
996
|
+
bar.addEventListener('keydown', function (event) {
|
|
997
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
998
|
+
event.preventDefault();
|
|
999
|
+
toggle();
|
|
1000
|
+
});
|
|
1001
|
+
return bar;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/** Waterfall entrance: the run's rows cascade in top-down on expand. */
|
|
1005
|
+
function animateFall(bar, run) {
|
|
1006
|
+
var rows = run.rows;
|
|
1007
|
+
var dur = durMs();
|
|
1008
|
+
var step = stepMs();
|
|
1009
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1010
|
+
rows[i].classList.remove(FALLING_CLASS, COLLAPSING_CLASS);
|
|
1011
|
+
rows[i].style.setProperty('--ccx-i', String(i));
|
|
1012
|
+
}
|
|
1013
|
+
for (var j = 0; j < rows.length; j++) rows[j].classList.add(FALLING_CLASS);
|
|
1014
|
+
if (bar._ccxFallTimer !== undefined) timers.cancel(bar._ccxFallTimer);
|
|
1015
|
+
bar._ccxFallTimer = timers.after(function () {
|
|
1016
|
+
bar._ccxFallTimer = undefined;
|
|
1017
|
+
if (disposed || !bar.isConnected) return;
|
|
1018
|
+
for (var k = 0; k < rows.length; k++) rows[k].classList.remove(FALLING_CLASS);
|
|
1019
|
+
}, Math.max(0, rows.length - 1) * step + dur + 60);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Collapse each card's ROW in place: freeze the current height, then
|
|
1024
|
+
* transition height to 0 with the same stagger as the rise animation.
|
|
1025
|
+
* The measured trailing gap to the next visible row is cancelled via
|
|
1026
|
+
* margin-bottom, so everything below the run follows upward
|
|
1027
|
+
* continuously — no blank hole while the cards fade, no snap when the
|
|
1028
|
+
* merge finally applies. Styles are cleared on completion (and on
|
|
1029
|
+
* cancel/dispose) so rows return to auto layout.
|
|
1030
|
+
*/
|
|
1031
|
+
function animateRowShrink(rows, dur, step) {
|
|
1032
|
+
if (dur <= 0 || rows.length === 0) return;
|
|
1033
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1034
|
+
var row = rows[i];
|
|
1035
|
+
var h = row.offsetHeight;
|
|
1036
|
+
if (h <= 0) continue;
|
|
1037
|
+
var gap = 0;
|
|
1038
|
+
var next = row.nextElementSibling;
|
|
1039
|
+
while (next !== null && next.offsetHeight === 0) next = next.nextElementSibling;
|
|
1040
|
+
if (next !== null) {
|
|
1041
|
+
gap = Math.max(0, next.getBoundingClientRect().top - row.getBoundingClientRect().bottom);
|
|
1042
|
+
}
|
|
1043
|
+
row.style.height = h + 'px';
|
|
1044
|
+
row.style.overflow = 'hidden';
|
|
1045
|
+
row.style.marginBottom = '0px';
|
|
1046
|
+
row.style.transition = 'height ' + dur + 'ms ' + COLLAPSE_EASE + ',margin-bottom ' + dur + 'ms ' + COLLAPSE_EASE;
|
|
1047
|
+
row.style.transitionDelay = (i * step) + 'ms';
|
|
1048
|
+
void row.offsetHeight; // commit the frozen height before shrinking
|
|
1049
|
+
row.style.height = '0px';
|
|
1050
|
+
row.style.marginBottom = (-gap) + 'px';
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/** Remove the shrink animation's inline styles (restores auto layout). */
|
|
1055
|
+
function clearRowShrink(row) {
|
|
1056
|
+
row.style.removeProperty('height');
|
|
1057
|
+
row.style.removeProperty('overflow');
|
|
1058
|
+
row.style.removeProperty('margin-bottom');
|
|
1059
|
+
row.style.removeProperty('transition');
|
|
1060
|
+
row.style.removeProperty('transition-delay');
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Rise-out exit: cards cascade back up, then the run collapses. */
|
|
1064
|
+
function startCollapse(flow, run, bar) {
|
|
1065
|
+
var set = expanded.get(flow);
|
|
1066
|
+
if (set === undefined || !set.has(run.headKey)) return;
|
|
1067
|
+
set.delete(run.headKey);
|
|
1068
|
+
var per = collapsing.get(flow);
|
|
1069
|
+
if (per === undefined) {
|
|
1070
|
+
per = new Set();
|
|
1071
|
+
collapsing.set(flow, per);
|
|
1072
|
+
}
|
|
1073
|
+
per.add(run.headKey);
|
|
1074
|
+
var rows = run.rows;
|
|
1075
|
+
var dur = durMs();
|
|
1076
|
+
var step = stepMs();
|
|
1077
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1078
|
+
rows[i].classList.remove(FALLING_CLASS, COLLAPSING_CLASS);
|
|
1079
|
+
rows[i].style.setProperty('--ccx-i', String(i));
|
|
1080
|
+
}
|
|
1081
|
+
for (var j = 0; j < rows.length; j++) rows[j].classList.add(COLLAPSING_CLASS);
|
|
1082
|
+
animateRowShrink(rows, dur, step);
|
|
1083
|
+
// The bar flips to collapsed visuals now; the cards stay visible for
|
|
1084
|
+
// the rise (pass skips ccxMerged while the head key is collapsing).
|
|
1085
|
+
pass(flow);
|
|
1086
|
+
if (bar._ccxCollapseTimer !== undefined) timers.cancel(bar._ccxCollapseTimer);
|
|
1087
|
+
bar._ccxCollapseTimer = timers.after(function () {
|
|
1088
|
+
bar._ccxCollapseTimer = undefined;
|
|
1089
|
+
if (disposed || !flow.isConnected) return;
|
|
1090
|
+
var perNow = collapsing.get(flow);
|
|
1091
|
+
if (perNow !== undefined) {
|
|
1092
|
+
perNow.delete(run.headKey);
|
|
1093
|
+
if (perNow.size === 0) collapsing.delete(flow);
|
|
1094
|
+
}
|
|
1095
|
+
for (var k = 0; k < rows.length; k++) {
|
|
1096
|
+
rows[k].classList.remove(COLLAPSING_CLASS);
|
|
1097
|
+
clearRowShrink(rows[k]);
|
|
1098
|
+
}
|
|
1099
|
+
pass(flow); // applies ccxMerged now
|
|
1100
|
+
}, Math.max(0, rows.length - 1) * step + dur + 60);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Abort a pending collapse (the user re-expanded mid-animation). */
|
|
1104
|
+
function cancelCollapse(flow, run) {
|
|
1105
|
+
var per = collapsing.get(flow);
|
|
1106
|
+
if (per !== undefined) {
|
|
1107
|
+
per.delete(run.headKey);
|
|
1108
|
+
if (per.size === 0) collapsing.delete(flow);
|
|
1109
|
+
}
|
|
1110
|
+
var rows = run.rows;
|
|
1111
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1112
|
+
rows[i].classList.remove(COLLAPSING_CLASS);
|
|
1113
|
+
clearRowShrink(rows[i]);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ----------------------------------------------------------------
|
|
1118
|
+
// Scroll pinning. The product's chat view follows its own column
|
|
1119
|
+
// height growth while the reader is pinned to the bottom; expanding a
|
|
1120
|
+
// group near the bottom would therefore shove the bar out of the top
|
|
1121
|
+
// of the viewport. Keep the bar's viewport position fixed for the
|
|
1122
|
+
// duration of the fall animation; stop as soon as the user scrolls.
|
|
1123
|
+
// ----------------------------------------------------------------
|
|
1124
|
+
function findScrollport(el) {
|
|
1125
|
+
var host = el.closest('[data-conversation-scroll]');
|
|
1126
|
+
if (host !== null) return host;
|
|
1127
|
+
var node = el.parentElement;
|
|
1128
|
+
while (node !== null) {
|
|
1129
|
+
var style = getComputedStyle(node);
|
|
1130
|
+
if ((style.overflowY === 'auto' || style.overflowY === 'scroll')
|
|
1131
|
+
&& node.scrollHeight > node.clientHeight) return node;
|
|
1132
|
+
node = node.parentElement;
|
|
1133
|
+
}
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function pinBar(bar, run) {
|
|
1138
|
+
if (typeof requestAnimationFrame !== 'function') return;
|
|
1139
|
+
var sp = findScrollport(bar);
|
|
1140
|
+
if (sp === null) return;
|
|
1141
|
+
var savedTop = sp.scrollTop;
|
|
1142
|
+
var targetTop = bar.getBoundingClientRect().top - sp.getBoundingClientRect().top;
|
|
1143
|
+
// Restore the reader position immediately: the sticky-bottom follow
|
|
1144
|
+
// re-pins only while the reader is still flagged at-bottom, and the
|
|
1145
|
+
// scroll event our write queues clears that flag.
|
|
1146
|
+
sp.scrollTop = savedTop;
|
|
1147
|
+
var dur = durMs();
|
|
1148
|
+
var step = stepMs();
|
|
1149
|
+
var until = Date.now() + Math.max(0, run.rows.length - 1) * step + dur + 260;
|
|
1150
|
+
var stopped = false;
|
|
1151
|
+
var cancel = function () {
|
|
1152
|
+
stopped = true;
|
|
1153
|
+
sp.removeEventListener('wheel', cancel);
|
|
1154
|
+
sp.removeEventListener('touchstart', cancel);
|
|
1155
|
+
};
|
|
1156
|
+
sp.addEventListener('wheel', cancel, { passive: true });
|
|
1157
|
+
sp.addEventListener('touchstart', cancel, { passive: true });
|
|
1158
|
+
var frame = function () {
|
|
1159
|
+
if (stopped || disposed || !bar.isConnected) {
|
|
1160
|
+
cancel();
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
var current = bar.getBoundingClientRect().top - sp.getBoundingClientRect().top;
|
|
1164
|
+
var delta = current - targetTop;
|
|
1165
|
+
if (Math.abs(delta) > 0.5) sp.scrollTop += delta;
|
|
1166
|
+
if (Date.now() < until) requestAnimationFrame(frame);
|
|
1167
|
+
else cancel();
|
|
1168
|
+
};
|
|
1169
|
+
requestAnimationFrame(frame);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** Refresh the bar for one run: copy, aria, and the last-call clone. */
|
|
1173
|
+
function updateBar(bar, run) {
|
|
1174
|
+
// The run this bar stands in for, kept current by every full pass so
|
|
1175
|
+
// the light 'content' pass can refresh clones without rebuilding.
|
|
1176
|
+
bar._ccxRun = run;
|
|
1177
|
+
// The whole run folds into the bar, so the count is the run's TOTAL
|
|
1178
|
+
// tool-call count (every card of the group is hidden when collapsed).
|
|
1179
|
+
var total = run.toolRows.length;
|
|
1180
|
+
bar.setAttribute('aria-expanded', run.expanded ? 'true' : 'false');
|
|
1181
|
+
bar.setAttribute('aria-label', (run.expanded ? '收起' : '展开') + ' ' + total + ' 个工具调用');
|
|
1182
|
+
bar.title = run.expanded ? '点击收起' : '点击展开';
|
|
1183
|
+
bar._ccxIcon.textContent = run.expanded ? '▾' : '▸';
|
|
1184
|
+
bar._ccxLabel.textContent = run.expanded
|
|
1185
|
+
? '已展开 ' + total + ' 个工具调用 · 点击收起'
|
|
1186
|
+
: '已折叠 ' + total + ' 个工具调用 · 点击展开';
|
|
1187
|
+
// Expanding collapses the summary clone (fade + shrink) so the label
|
|
1188
|
+
// slides left, and the hidden cards cascade in below the bar.
|
|
1189
|
+
var wasExpanded = bar._ccxPrevExpanded === true;
|
|
1190
|
+
bar.classList.toggle('ccxExpanded', run.expanded);
|
|
1191
|
+
if (run.expanded && !wasExpanded) animateFall(bar, run);
|
|
1192
|
+
bar._ccxPrevExpanded = run.expanded;
|
|
1193
|
+
if (!run.expanded) updateBarClone(bar);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** Replace the bar's clone contents; counted as one clone op. */
|
|
1197
|
+
function replaceClone(call, node) {
|
|
1198
|
+
var t0 = statsBegin();
|
|
1199
|
+
while (call.firstChild !== null) call.removeChild(call.firstChild);
|
|
1200
|
+
call.appendChild(node);
|
|
1201
|
+
statsEnd('clone', 1, t0);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Clone the last call's collapsed one-line row (product chrome) so
|
|
1206
|
+
* the bar itself displays the last tool call. Re-clone only when the
|
|
1207
|
+
* source row's markup changed (e.g. a tool result just arrived), and
|
|
1208
|
+
* at most every 120ms while a result streams in — the clone's
|
|
1209
|
+
* freshness is invisible beyond that, but serialization plus clone
|
|
1210
|
+
* DOM work would otherwise run on every frame of the stream.
|
|
1211
|
+
*/
|
|
1212
|
+
function updateBarClone(bar) {
|
|
1213
|
+
var run = bar._ccxRun;
|
|
1214
|
+
if (run === undefined) return;
|
|
1215
|
+
var call = bar._ccxCall;
|
|
1216
|
+
var source = run.lastRow.querySelector('[data-disclosure-row]');
|
|
1217
|
+
if (source !== null) {
|
|
1218
|
+
var sig = source.innerHTML;
|
|
1219
|
+
if (bar._ccxSig === sig) return;
|
|
1220
|
+
var now = Date.now();
|
|
1221
|
+
if (bar._ccxCloneAt !== undefined && now - bar._ccxCloneAt < 120) return;
|
|
1222
|
+
bar._ccxCloneAt = now;
|
|
1223
|
+
bar._ccxSig = sig;
|
|
1224
|
+
replaceClone(call, sanitizeClone(source.cloneNode(true)));
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
// Bash (and any future sample) uses a custom header without
|
|
1228
|
+
// data-disclosure-row (data-sample="bash"). Without this branch the
|
|
1229
|
+
// fallback below finds no [data-tool] and the bar's call slot stays
|
|
1230
|
+
// empty — exactly the "WSL bash shows only the count" symptom.
|
|
1231
|
+
var bashSource = run.lastRow.querySelector('[data-sample="bash"]');
|
|
1232
|
+
if (bashSource !== null) {
|
|
1233
|
+
var bsig = bashSource.innerHTML;
|
|
1234
|
+
if (bar._ccxSig === bsig) return;
|
|
1235
|
+
var bnow = Date.now();
|
|
1236
|
+
if (bar._ccxCloneAt !== undefined && bnow - bar._ccxCloneAt < 120) return;
|
|
1237
|
+
bar._ccxCloneAt = bnow;
|
|
1238
|
+
bar._ccxSig = bsig;
|
|
1239
|
+
replaceClone(call, sanitizeClone(bashSource.cloneNode(true)));
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
// Generic fallback for custom cards (e.g. cordis run) that render
|
|
1243
|
+
// outside the standard DisclosureRow/tool patterns.
|
|
1244
|
+
var card = run.lastRow.querySelector('[data-tool]');
|
|
1245
|
+
var head = card !== null && card.firstElementChild !== null ? card.firstElementChild : null;
|
|
1246
|
+
if (head !== null) {
|
|
1247
|
+
var hsig = head.innerHTML;
|
|
1248
|
+
if (bar._ccxSig !== hsig) {
|
|
1249
|
+
var now2 = Date.now();
|
|
1250
|
+
if (bar._ccxCloneAt === undefined || now2 - bar._ccxCloneAt >= 120) {
|
|
1251
|
+
bar._ccxCloneAt = now2;
|
|
1252
|
+
bar._ccxSig = hsig;
|
|
1253
|
+
replaceClone(call, sanitizeClone(head.cloneNode(true)));
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
// Last resort: whatever the row rendered first (covers future custom
|
|
1259
|
+
// tool views that use neither marker).
|
|
1260
|
+
var fallback = run.lastRow.firstElementChild;
|
|
1261
|
+
if (fallback !== null) {
|
|
1262
|
+
var fsig = fallback.innerHTML;
|
|
1263
|
+
if (bar._ccxSig !== fsig) {
|
|
1264
|
+
var fnow = Date.now();
|
|
1265
|
+
if (bar._ccxCloneAt === undefined || fnow - bar._ccxCloneAt >= 120) {
|
|
1266
|
+
bar._ccxCloneAt = fnow;
|
|
1267
|
+
bar._ccxSig = fsig;
|
|
1268
|
+
replaceClone(call, sanitizeClone(fallback.cloneNode(true)));
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (bar._ccxSig !== '') {
|
|
1274
|
+
bar._ccxSig = '';
|
|
1275
|
+
while (call.firstChild !== null) call.removeChild(call.firstChild);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** Create/update/remove the flow-level bars for the merged runs. */
|
|
1280
|
+
function syncBars(flow, runs) {
|
|
1281
|
+
var registry = barsByFlow.get(flow);
|
|
1282
|
+
if (registry === undefined) {
|
|
1283
|
+
registry = new Map();
|
|
1284
|
+
barsByFlow.set(flow, registry);
|
|
1285
|
+
}
|
|
1286
|
+
var wanted = new Set();
|
|
1287
|
+
for (var r = 0; r < runs.length; r++) {
|
|
1288
|
+
var run = runs[r];
|
|
1289
|
+
if (run.headKey === null) continue;
|
|
1290
|
+
wanted.add(run.headKey);
|
|
1291
|
+
var bar = registry.get(run.headKey);
|
|
1292
|
+
if (bar === undefined || !flow.contains(bar)) {
|
|
1293
|
+
bar = createBar(flow, run);
|
|
1294
|
+
registry.set(run.headKey, bar);
|
|
1295
|
+
}
|
|
1296
|
+
updateBar(bar, run);
|
|
1297
|
+
// The bar is the group HEADER: it stays before the run's first row,
|
|
1298
|
+
// so on expand the cards cascade downward below it. Guard against
|
|
1299
|
+
// re-inserting when already in place (insertBefore always mutates).
|
|
1300
|
+
var anchor = run.toolRows[0];
|
|
1301
|
+
if (bar.nextSibling !== anchor) flow.insertBefore(bar, anchor);
|
|
1302
|
+
}
|
|
1303
|
+
registry.forEach(function (bar, key) {
|
|
1304
|
+
if (!wanted.has(key)) {
|
|
1305
|
+
bar.remove();
|
|
1306
|
+
registry.delete(key);
|
|
1307
|
+
}
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/**
|
|
1312
|
+
* Light pass for 'content'-dirty flows: the merge structure is
|
|
1313
|
+
* unchanged, so only bar clones can be stale. Touches ONLY the bars
|
|
1314
|
+
* of runs whose LAST row was mutated and which are collapsed — an
|
|
1315
|
+
* expanded run shows its own cards (no clone needed) and earlier
|
|
1316
|
+
* cards of a run are hidden (their content never reaches a bar).
|
|
1317
|
+
* No run rebuild, no class toggles, no layout.
|
|
1318
|
+
*/
|
|
1319
|
+
function barRefresh(flow, contentRows) {
|
|
1320
|
+
var registry = barsByFlow.get(flow);
|
|
1321
|
+
if (registry === undefined || contentRows === undefined) return;
|
|
1322
|
+
registry.forEach(function (bar) {
|
|
1323
|
+
var run = bar._ccxRun;
|
|
1324
|
+
if (run === undefined || run.expanded || !bar.isConnected) return;
|
|
1325
|
+
if (contentRows.has(run.lastRow)) updateBarClone(bar);
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// ----------------------------------------------------------------
|
|
1330
|
+
// The pass: merge runs, sync bars, clean empty rows.
|
|
1331
|
+
// ----------------------------------------------------------------
|
|
1332
|
+
function pass(flow) {
|
|
1333
|
+
var t0 = statsBegin();
|
|
1334
|
+
var rows = rowList(flow);
|
|
1335
|
+
var set = expanded.get(flow);
|
|
1336
|
+
var per = collapsing.get(flow);
|
|
1337
|
+
var keepThink = settings.getSnapshot().keepThink;
|
|
1338
|
+
var splitThink = settings.getSnapshot().splitThink;
|
|
1339
|
+
|
|
1340
|
+
// Prune expansion keys that no longer exist in this flow. Synthetic
|
|
1341
|
+
// keys are flow-local and never row-bound, so they are kept.
|
|
1342
|
+
if (set !== undefined && set.size > 0) {
|
|
1343
|
+
var stale = [];
|
|
1344
|
+
set.forEach(function (key) {
|
|
1345
|
+
if (key.indexOf('ccx-run-') === 0) return;
|
|
1346
|
+
var found = false;
|
|
1347
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1348
|
+
if (rows[i].getAttribute('data-chat-flow-key') === key) { found = true; break; }
|
|
1349
|
+
}
|
|
1350
|
+
if (!found) stale.push(key);
|
|
1351
|
+
});
|
|
1352
|
+
for (var s = 0; s < stale.length; s++) set.delete(stale[s]);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// Runs: maximal sequences of tool-call rows with only transparent
|
|
1356
|
+
// (settled-thinking-only) rows between them — in split mode (the
|
|
1357
|
+
// default) no row is transparent, so ANY other row (settled or
|
|
1358
|
+
// in-progress thinking, AI text, user/steering/command content)
|
|
1359
|
+
// ends a run and completed thinking separates the calls into
|
|
1360
|
+
// independent bars. sawTool/sawThink feed the light→full observer
|
|
1361
|
+
// upgrade: a flow with foldable content needs subtree + data-state
|
|
1362
|
+
// coverage.
|
|
1363
|
+
var runs = [];
|
|
1364
|
+
var current = [];
|
|
1365
|
+
var sawTool = false;
|
|
1366
|
+
var sawThink = false;
|
|
1367
|
+
for (var i = 0; i < rows.length; i++) {
|
|
1368
|
+
var row = rows[i];
|
|
1369
|
+
var kind = row.getAttribute('data-chat-flow-kind');
|
|
1370
|
+
if (kind === 'tool-call') {
|
|
1371
|
+
sawTool = true;
|
|
1372
|
+
current.push(row);
|
|
1373
|
+
} else if (kind === 'assistant-step') {
|
|
1374
|
+
var assessment = assessRow(row, keepThink, splitThink);
|
|
1375
|
+
if (assessment.thinks.length > 0) sawThink = true;
|
|
1376
|
+
if (current.length > 0 && assessment.transparent) {
|
|
1377
|
+
current.push(row);
|
|
1378
|
+
} else {
|
|
1379
|
+
if (current.length > 0) runs.push(current);
|
|
1380
|
+
current = [];
|
|
1381
|
+
}
|
|
1382
|
+
} else {
|
|
1383
|
+
if (current.length > 0) runs.push(current);
|
|
1384
|
+
current = [];
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
if (current.length > 0) runs.push(current);
|
|
1388
|
+
|
|
1389
|
+
var mergedRows = new Set();
|
|
1390
|
+
var mergedRuns = [];
|
|
1391
|
+
for (var r = 0; r < runs.length; r++) {
|
|
1392
|
+
var run = runs[r];
|
|
1393
|
+
var toolRows = [];
|
|
1394
|
+
var thinkRows = [];
|
|
1395
|
+
for (var t = 0; t < run.length; t++) {
|
|
1396
|
+
if (run[t].getAttribute('data-chat-flow-kind') === 'tool-call') toolRows.push(run[t]);
|
|
1397
|
+
else thinkRows.push(run[t]);
|
|
1398
|
+
}
|
|
1399
|
+
if (toolRows.length < 2) continue;
|
|
1400
|
+
// The node key is the stable expansion identity; rows without one
|
|
1401
|
+
// (defensive: the product always sets it) get a flow-local
|
|
1402
|
+
// synthetic key so a run can never hide without a bar.
|
|
1403
|
+
var headKey = toolRows[0].getAttribute('data-chat-flow-key');
|
|
1404
|
+
if (headKey === null || headKey === '') {
|
|
1405
|
+
var synth = syntheticKeys.get(flow);
|
|
1406
|
+
if (synth === undefined) synth = 0;
|
|
1407
|
+
syntheticKeys.set(flow, synth + 1);
|
|
1408
|
+
headKey = 'ccx-run-' + synth;
|
|
1409
|
+
}
|
|
1410
|
+
var expandedRun = set !== undefined && headKey !== null && set.has(headKey);
|
|
1411
|
+
var collapsingRun = per !== undefined && headKey !== null && per.has(headKey);
|
|
1412
|
+
// Every card of the run hides when collapsed — the bar stands in
|
|
1413
|
+
// for the whole group and displays the last call itself. In merge
|
|
1414
|
+
// mode the run's settled-think rows fold with it too: hidden
|
|
1415
|
+
// while collapsed, shown in their original order between the
|
|
1416
|
+
// calls when expanded (they ride the same cascade). In split mode
|
|
1417
|
+
// think rows never enter a run (they separate runs), so with
|
|
1418
|
+
// "保留思考" on they stay visible between the bars. A run mid
|
|
1419
|
+
// collapse animation keeps its rows visible for the rise.
|
|
1420
|
+
var hide = !expandedRun && !collapsingRun;
|
|
1421
|
+
for (var k = 0; k < toolRows.length; k++) {
|
|
1422
|
+
toolRows[k].classList.toggle(MERGED_CLASS, hide);
|
|
1423
|
+
if (hide) toolRows[k].classList.remove(FALLING_CLASS, COLLAPSING_CLASS);
|
|
1424
|
+
mergedRows.add(toolRows[k]);
|
|
1425
|
+
}
|
|
1426
|
+
for (var w = 0; w < thinkRows.length; w++) {
|
|
1427
|
+
thinkRows[w].classList.toggle(MERGED_CLASS, hide);
|
|
1428
|
+
if (hide) thinkRows[w].classList.remove(FALLING_CLASS, COLLAPSING_CLASS);
|
|
1429
|
+
mergedRows.add(thinkRows[w]);
|
|
1430
|
+
}
|
|
1431
|
+
mergedRuns.push({
|
|
1432
|
+
headKey: headKey,
|
|
1433
|
+
toolRows: toolRows,
|
|
1434
|
+
thinkRows: thinkRows,
|
|
1435
|
+
// The run's rows in DOM order: cards and (merge mode) settled-
|
|
1436
|
+
// think rows interleaved exactly as they appear, used by the
|
|
1437
|
+
// waterfall.
|
|
1438
|
+
rows: run,
|
|
1439
|
+
lastRow: toolRows[toolRows.length - 1],
|
|
1440
|
+
expanded: expandedRun
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Remove stale merge/animation classes from rows that left a run.
|
|
1445
|
+
for (var m = 0; m < rows.length; m++) {
|
|
1446
|
+
if (!mergedRows.has(rows[m])) rows[m].classList.remove(MERGED_CLASS, FALLING_CLASS, COLLAPSING_CLASS);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Drop collapse-animation markers whose run no longer merges.
|
|
1450
|
+
if (per !== undefined && per.size > 0) {
|
|
1451
|
+
var keys = [];
|
|
1452
|
+
per.forEach(function (key) { keys.push(key); });
|
|
1453
|
+
for (var p = 0; p < keys.length; p++) {
|
|
1454
|
+
var still = false;
|
|
1455
|
+
for (var q = 0; q < mergedRuns.length; q++) {
|
|
1456
|
+
if (mergedRuns[q].headKey === keys[p]) { still = true; break; }
|
|
1457
|
+
}
|
|
1458
|
+
if (!still) per.delete(keys[p]);
|
|
1459
|
+
}
|
|
1460
|
+
if (per.size === 0) collapsing.delete(flow);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
syncBars(flow, mergedRuns);
|
|
1464
|
+
|
|
1465
|
+
// Hide assistant rows that became empty after settled-think folding
|
|
1466
|
+
// (the column's 16px flex gap would otherwise leave a hole). With
|
|
1467
|
+
// "保留思考" on nothing is hidden so nothing can be empty — remove
|
|
1468
|
+
// any stale marker instead. Decisions come from the cached row
|
|
1469
|
+
// assessment, so this loop performs no layout reads.
|
|
1470
|
+
for (var x = 0; x < rows.length; x++) {
|
|
1471
|
+
var candidate = rows[x];
|
|
1472
|
+
if (candidate.getAttribute('data-chat-flow-kind') !== 'assistant-step') continue;
|
|
1473
|
+
if (keepThink) {
|
|
1474
|
+
candidate.classList.remove(EMPTY_CLASS);
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
var assessment = assessRow(candidate, keepThink, splitThink);
|
|
1478
|
+
if (assessment.empty) candidate.classList.add(EMPTY_CLASS);
|
|
1479
|
+
else candidate.classList.remove(EMPTY_CLASS);
|
|
1480
|
+
}
|
|
1481
|
+
statsEnd('pass', 1, t0);
|
|
1482
|
+
return { sawTool: sawTool, sawThink: sawThink };
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/** Drop observers/registries for flows no longer in the document. */
|
|
1486
|
+
function pruneFlows() {
|
|
1487
|
+
var flowsNow = [];
|
|
1488
|
+
flows.forEach(function (mode, flow) { flowsNow.push(flow); });
|
|
1489
|
+
for (var i = 0; i < flowsNow.length; i++) {
|
|
1490
|
+
var flow = flowsNow[i];
|
|
1491
|
+
if (doc.contains(flow)) continue;
|
|
1492
|
+
var observer = flowObservers.get(flow);
|
|
1493
|
+
if (observer !== null && observer !== undefined) observer.disconnect();
|
|
1494
|
+
flowObservers.delete(flow);
|
|
1495
|
+
flows.delete(flow);
|
|
1496
|
+
expanded.delete(flow);
|
|
1497
|
+
barsByFlow.delete(flow);
|
|
1498
|
+
collapsing.delete(flow);
|
|
1499
|
+
dirtyFlows.delete(flow);
|
|
1500
|
+
flowReasons.delete(flow);
|
|
1501
|
+
contentRowsByFlow.delete(flow);
|
|
1502
|
+
syntheticKeys.delete(flow);
|
|
1503
|
+
}
|
|
1504
|
+
// Parent observers: drop those whose parent is gone or hosts no flow.
|
|
1505
|
+
parentObservers.forEach(function (observer, parent) {
|
|
1506
|
+
if (!doc.contains(parent)) {
|
|
1507
|
+
observer.disconnect();
|
|
1508
|
+
parentObservers.delete(parent);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
var used = false;
|
|
1512
|
+
flows.forEach(function (mode, flow) {
|
|
1513
|
+
if (flow.parentElement === parent) used = true;
|
|
1514
|
+
});
|
|
1515
|
+
if (!used) {
|
|
1516
|
+
observer.disconnect();
|
|
1517
|
+
parentObservers.delete(parent);
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function refresh() {
|
|
1523
|
+
if (disposed || !active) return;
|
|
1524
|
+
var t0 = statsBegin();
|
|
1525
|
+
if (structureDirty) {
|
|
1526
|
+
structureDirty = false;
|
|
1527
|
+
discover();
|
|
1528
|
+
pruneFlows();
|
|
1529
|
+
observeParents();
|
|
1530
|
+
}
|
|
1531
|
+
var keep = settings.getSnapshot().keepThink;
|
|
1532
|
+
var pending = [];
|
|
1533
|
+
dirtyFlows.forEach(function (flow) { pending.push(flow); });
|
|
1534
|
+
dirtyFlows.clear();
|
|
1535
|
+
for (var i = 0; i < pending.length; i++) {
|
|
1536
|
+
var flow = pending[i];
|
|
1537
|
+
if (!flows.has(flow) || !doc.contains(flow)) continue;
|
|
1538
|
+
flow.classList.toggle(KEEP_CLASS, keep);
|
|
1539
|
+
var reason = flowReasons.get(flow);
|
|
1540
|
+
flowReasons.delete(flow);
|
|
1541
|
+
var contentRows = contentRowsByFlow.get(flow);
|
|
1542
|
+
if (contentRows !== undefined) contentRowsByFlow.delete(flow);
|
|
1543
|
+
if (reason === 'content') {
|
|
1544
|
+
if (contentRows !== undefined) barRefresh(flow, contentRows);
|
|
1545
|
+
} else {
|
|
1546
|
+
var outcome = pass(flow);
|
|
1547
|
+
// Light→full upgrade: foldable content appeared (or the flow
|
|
1548
|
+
// already had it when the rescan discovered it in light mode).
|
|
1549
|
+
// Never downgrades.
|
|
1550
|
+
if (flows.get(flow) === 'light' && (outcome.sawTool || outcome.sawThink)) {
|
|
1551
|
+
setFlowMode(flow, 'full');
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
statsEnd('refresh', 1, t0);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// ----------------------------------------------------------------
|
|
1559
|
+
// Public lifecycle.
|
|
1560
|
+
// ----------------------------------------------------------------
|
|
1561
|
+
function dispose() {
|
|
1562
|
+
if (disposed) return;
|
|
1563
|
+
disposed = true;
|
|
1564
|
+
stopRescan();
|
|
1565
|
+
if (typeof doc.removeEventListener === 'function') {
|
|
1566
|
+
doc.removeEventListener('visibilitychange', onVisibility);
|
|
1567
|
+
}
|
|
1568
|
+
if (offSettings !== null) offSettings();
|
|
1569
|
+
if (rafId !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(rafId);
|
|
1570
|
+
if (bootRaf !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(bootRaf);
|
|
1571
|
+
disconnectObservers();
|
|
1572
|
+
barsByFlow.forEach(function (registry) {
|
|
1573
|
+
registry.forEach(function (bar) {
|
|
1574
|
+
if (bar._ccxFallTimer !== undefined) timers.cancel(bar._ccxFallTimer);
|
|
1575
|
+
if (bar._ccxCollapseTimer !== undefined) timers.cancel(bar._ccxCollapseTimer);
|
|
1576
|
+
});
|
|
1577
|
+
});
|
|
1578
|
+
flows.clear();
|
|
1579
|
+
flowObservers.clear();
|
|
1580
|
+
parentObservers.clear();
|
|
1581
|
+
expanded.clear();
|
|
1582
|
+
barsByFlow.clear();
|
|
1583
|
+
collapsing.clear();
|
|
1584
|
+
dirtyFlows.clear();
|
|
1585
|
+
flowReasons.clear();
|
|
1586
|
+
contentRowsByFlow.clear();
|
|
1587
|
+
syntheticKeys.clear();
|
|
1588
|
+
// Remove injected UI and classes so the flow returns to normal.
|
|
1589
|
+
var flowsNow = doc.querySelectorAll(FLOW_SELECTOR);
|
|
1590
|
+
for (var f = 0; f < flowsNow.length; f++) {
|
|
1591
|
+
flowsNow[f].classList.remove(KEEP_CLASS);
|
|
1592
|
+
var bars = flowsNow[f].querySelectorAll('[' + BADGE_ATTR + ']');
|
|
1593
|
+
for (var b = 0; b < bars.length; b++) bars[b].remove();
|
|
1594
|
+
var tagged = flowsNow[f].querySelectorAll('.' + MERGED_CLASS + ',.' + EMPTY_CLASS + ',.' + FALLING_CLASS + ',.' + COLLAPSING_CLASS);
|
|
1595
|
+
for (var t = 0; t < tagged.length; t++) tagged[t].classList.remove(MERGED_CLASS, EMPTY_CLASS, FALLING_CLASS, COLLAPSING_CLASS);
|
|
1596
|
+
}
|
|
1597
|
+
// Clear any shrink-animation inline styles left on tool rows.
|
|
1598
|
+
var allToolRows = doc.querySelectorAll('[data-chat-flow-kind="tool-call"]');
|
|
1599
|
+
for (var tr = 0; tr < allToolRows.length; tr++) clearRowShrink(allToolRows[tr]);
|
|
1600
|
+
if (styleTag !== null && !adoptedStyle) styleTag.remove();
|
|
1601
|
+
styleTag = null;
|
|
1602
|
+
doc.documentElement.style.removeProperty('--ccx-dur');
|
|
1603
|
+
doc.documentElement.style.removeProperty('--ccx-step');
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Boot work (style tag, flow discovery, first passes) is deferred one
|
|
1607
|
+
// frame so plugin activation never blocks the page's first paint. If
|
|
1608
|
+
// the tab is hidden at boot the rAF never fires: the visibility
|
|
1609
|
+
// listener still arms, and becoming visible runs bootInit directly.
|
|
1610
|
+
function bootInit() {
|
|
1611
|
+
if (booted || disposed) return;
|
|
1612
|
+
booted = true;
|
|
1613
|
+
applySettings();
|
|
1614
|
+
settings.stats = ensureStats();
|
|
1615
|
+
insertStyle();
|
|
1616
|
+
discover();
|
|
1617
|
+
observeParents();
|
|
1618
|
+
startRescan();
|
|
1619
|
+
structureDirty = true;
|
|
1620
|
+
schedule();
|
|
1621
|
+
}
|
|
1622
|
+
if (typeof doc.addEventListener === 'function') {
|
|
1623
|
+
doc.addEventListener('visibilitychange', onVisibility);
|
|
1624
|
+
}
|
|
1625
|
+
if (doc.hidden) setActive(false);
|
|
1626
|
+
offSettings = settings.subscribe(function () {
|
|
1627
|
+
applySettings();
|
|
1628
|
+
if (statsObj !== null) {
|
|
1629
|
+
var statsOn = settings.getSnapshot().stats === true;
|
|
1630
|
+
if (statsOn && !statsObj.enabled) {
|
|
1631
|
+
statsObj.obs = 0; statsObj.obsMs = 0;
|
|
1632
|
+
statsObj.refresh = 0; statsObj.refreshMs = 0;
|
|
1633
|
+
statsObj.pass = 0; statsObj.passMs = 0;
|
|
1634
|
+
statsObj.scan = 0; statsObj.scanMs = 0;
|
|
1635
|
+
statsObj.clone = 0; statsObj.cloneMs = 0;
|
|
1636
|
+
statsObj.skip = 0;
|
|
1637
|
+
statsObj.start = Date.now();
|
|
1638
|
+
}
|
|
1639
|
+
statsObj.enabled = statsOn;
|
|
1640
|
+
}
|
|
1641
|
+
flows.forEach(function (mode, flow) { markDirty(flow, 'rows'); });
|
|
1642
|
+
schedule();
|
|
1643
|
+
});
|
|
1644
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
1645
|
+
bootRaf = requestAnimationFrame(bootInit);
|
|
1646
|
+
} else {
|
|
1647
|
+
bootInit();
|
|
1648
|
+
}
|
|
1649
|
+
return dispose;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// ------------------------------------------------------------------
|
|
1653
|
+
// Settings card: Settings → 插件 → 工具折叠. Rendered with the same
|
|
1654
|
+
// chrome as the product's own plugin cards (the .ccxCard rules in the
|
|
1655
|
+
// style sheet above), so it is visually indistinguishable from them.
|
|
1656
|
+
// ------------------------------------------------------------------
|
|
1657
|
+
/**
|
|
1658
|
+
* One plugin card under Settings → 插件, owning the folding preferences.
|
|
1659
|
+
* @param props - injected face: useCcxSettings snapshot hook, setDur,
|
|
1660
|
+
* setKeepThink; every change applies live through the shared store.
|
|
1661
|
+
* @returns the card.
|
|
1662
|
+
*/
|
|
1663
|
+
function SettingsCard(props) {
|
|
1664
|
+
var state = props.useCcxSettings(function (snapshot) { return snapshot; });
|
|
1665
|
+
var openCell = React.useState(false);
|
|
1666
|
+
var isOpen = openCell[0];
|
|
1667
|
+
var setOpen = openCell[1];
|
|
1668
|
+
var nowCell = React.useState(0);
|
|
1669
|
+
var setNow = nowCell[1];
|
|
1670
|
+
// While the stats toggle is on, re-render once per second so the
|
|
1671
|
+
// cumulative cost figures stay live (ticker rides the timer service).
|
|
1672
|
+
React.useEffect(function () {
|
|
1673
|
+
if (!state.stats) return;
|
|
1674
|
+
var handle = props.tick(function () { setNow(Date.now()); }, 1000);
|
|
1675
|
+
return function () { props.cancelTick(handle); };
|
|
1676
|
+
}, [state.stats]);
|
|
1677
|
+
var cardClass = isOpen ? 'ccxCard ccxCardOpen' : 'ccxCard';
|
|
1678
|
+
var header = React.createElement('button', {
|
|
1679
|
+
type: 'button',
|
|
1680
|
+
className: 'ccxHeader',
|
|
1681
|
+
'aria-expanded': isOpen,
|
|
1682
|
+
'aria-label': (isOpen ? '收起' : '展开') + ': 工具折叠',
|
|
1683
|
+
onClick: function () { setOpen(!isOpen); }
|
|
1684
|
+
},
|
|
1685
|
+
React.createElement('span', { className: 'ccxHeadText' },
|
|
1686
|
+
React.createElement('span', { className: 'ccxName' }, '工具折叠'),
|
|
1687
|
+
React.createElement('span', { className: 'ccxDescription' }, '折叠工具调用与思考的显示设置')),
|
|
1688
|
+
React.createElement('span', { className: isOpen ? 'ccxChevron ccxChevronOpen' : 'ccxChevron' }, '▾'));
|
|
1689
|
+
if (!isOpen) return React.createElement('li', { className: cardClass }, header);
|
|
1690
|
+
var durField = React.createElement('div', { className: 'ccxField' },
|
|
1691
|
+
React.createElement('div', { className: 'ccxFieldHead' },
|
|
1692
|
+
React.createElement('label', { className: 'ccxFieldLabel', htmlFor: 'ccx-dur' }, '展开动画时长'),
|
|
1693
|
+
React.createElement('span', { className: 'ccxBadge' }, state.durMs + ' ms')),
|
|
1694
|
+
React.createElement('input', {
|
|
1695
|
+
id: 'ccx-dur', type: 'range', className: 'ccxRange', min: 0, max: 1000, step: 10,
|
|
1696
|
+
value: state.durMs,
|
|
1697
|
+
onChange: function (event) { props.setDur(Number(event.target.value)); }
|
|
1698
|
+
}),
|
|
1699
|
+
React.createElement('p', { className: 'ccxFieldHint' }, '折叠与展开的弹性动画时长,0 为瞬时切换'));
|
|
1700
|
+
var thinkField = React.createElement('div', { className: 'ccxField' },
|
|
1701
|
+
React.createElement('div', { className: 'ccxFieldHead' },
|
|
1702
|
+
React.createElement('label', { className: 'ccxFieldLabel', htmlFor: 'ccx-keep-think' }, '保留思考'),
|
|
1703
|
+
React.createElement('input', {
|
|
1704
|
+
id: 'ccx-keep-think', type: 'checkbox', className: 'ccxToggle', checked: state.keepThink,
|
|
1705
|
+
onChange: function (event) { props.setKeepThink(event.target.checked); }
|
|
1706
|
+
})),
|
|
1707
|
+
React.createElement('p', { className: 'ccxFieldHint' }, '保留已完成的思考内容(分隔模式下显示在两条折叠条之间,合并模式下展开时按原顺序插回)'));
|
|
1708
|
+
var splitField = React.createElement('div', { className: 'ccxField' },
|
|
1709
|
+
React.createElement('div', { className: 'ccxFieldHead' },
|
|
1710
|
+
React.createElement('label', { className: 'ccxFieldLabel', htmlFor: 'ccx-split-think' }, '思考分隔调用组'),
|
|
1711
|
+
React.createElement('input', {
|
|
1712
|
+
id: 'ccx-split-think', type: 'checkbox', className: 'ccxToggle', checked: state.splitThink,
|
|
1713
|
+
onChange: function (event) { props.setSplitThink(event.target.checked); }
|
|
1714
|
+
})),
|
|
1715
|
+
React.createElement('p', { className: 'ccxFieldHint' }, '开启(默认):已完成的思考把前后两组工具调用隔开、各自独立折叠;关闭:思考并入所在工具组一起折叠'));
|
|
1716
|
+
var statsField = React.createElement('div', { className: 'ccxField' },
|
|
1717
|
+
React.createElement('div', { className: 'ccxFieldHead' },
|
|
1718
|
+
React.createElement('label', { className: 'ccxFieldLabel', htmlFor: 'ccx-stats' }, '性能统计'),
|
|
1719
|
+
React.createElement('input', {
|
|
1720
|
+
id: 'ccx-stats', type: 'checkbox', className: 'ccxToggle', checked: state.stats,
|
|
1721
|
+
onChange: function (event) { props.setStats(event.target.checked); }
|
|
1722
|
+
})),
|
|
1723
|
+
React.createElement('p', { className: 'ccxFieldHint' }, '统计插件自身耗时并实时显示在本卡片内(默认关;开启才产生极小的计时开销)'));
|
|
1724
|
+
var storageHint = props.bridgeStatus === undefined || props.bridgeStatus() === 'dsh'
|
|
1725
|
+
? '设置保存在 DSH 主机配置(~/.dsh/settings.yaml),由 DSH 设置服务持久化'
|
|
1726
|
+
: '未检测到 DSH 设置服务,设置仅保存在本浏览器(localStorage)';
|
|
1727
|
+
var body = React.createElement('div', { className: 'ccxBody' },
|
|
1728
|
+
durField, thinkField, splitField, statsField,
|
|
1729
|
+
React.createElement('p', { className: 'ccxFieldHint' }, storageHint));
|
|
1730
|
+
var children = [header, body];
|
|
1731
|
+
if (state.stats) {
|
|
1732
|
+
var engineStats = props.stats();
|
|
1733
|
+
if (engineStats !== undefined) {
|
|
1734
|
+
var elapsed = Math.max(1, (Date.now() - engineStats.start) / 1000);
|
|
1735
|
+
var rows = [
|
|
1736
|
+
['观察回调', 'obs', engineStats.obs, engineStats.obsMs],
|
|
1737
|
+
['引擎刷新', 'refresh', engineStats.refresh, engineStats.refreshMs],
|
|
1738
|
+
['合并重算', 'pass', engineStats.pass, engineStats.passMs],
|
|
1739
|
+
['安全重扫', 'scan', engineStats.scan, engineStats.scanMs],
|
|
1740
|
+
['摘要克隆', 'clone', engineStats.clone, engineStats.cloneMs]
|
|
1741
|
+
];
|
|
1742
|
+
var statRows = rows.map(function (row) {
|
|
1743
|
+
return React.createElement('div', { className: 'ccxStatRow', key: row[1] },
|
|
1744
|
+
React.createElement('span', { className: 'ccxFieldLabel' }, row[0]),
|
|
1745
|
+
React.createElement('span', { className: 'ccxStatValue' },
|
|
1746
|
+
row[2] + ' 次 · ' + row[3].toFixed(1) + ' ms(' + (row[3] / elapsed).toFixed(2) + ' ms/s)'));
|
|
1747
|
+
});
|
|
1748
|
+
children.push(React.createElement('div', { className: 'ccxBody' },
|
|
1749
|
+
statRows,
|
|
1750
|
+
React.createElement('p', { className: 'ccxFieldHint' }, '流式变更直接忽略(零开销短路):' + engineStats.skip + ' 批次'),
|
|
1751
|
+
React.createElement('p', { className: 'ccxFieldHint' }, '累计自开启统计起算;标签页隐藏时引擎全部暂停,不计入')));
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
return React.createElement('li', { className: cardClass }, children);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
var plugin = {
|
|
1758
|
+
name: 'toolfold',
|
|
1759
|
+
// Declared so the fiber waits for the slots service and `ctx.slots` is
|
|
1760
|
+
// available in apply — the documented dynamic-package pattern. `timer`
|
|
1761
|
+
// is the Cordis timer service: browser timer globals (setTimeout /
|
|
1762
|
+
// setInterval / clearTimeout / clearInterval) are shadowed by teaching
|
|
1763
|
+
// traps in the dynamic client environment, so every engine timer goes
|
|
1764
|
+
// through ctx.timeout / ctx.interval disposers.
|
|
1765
|
+
inject: ['slots', 'timer'],
|
|
1766
|
+
apply: function (ctx) {
|
|
1767
|
+
if (typeof document === 'undefined') return;
|
|
1768
|
+
var settings = createSettingsStore();
|
|
1769
|
+
// Diagnostic/test hook: the settings store (and its live stats
|
|
1770
|
+
// counters) is reachable as window.__toolfoldSettings.
|
|
1771
|
+
if (typeof window !== 'undefined') window.__toolfoldSettings = settings;
|
|
1772
|
+
// DSH settings bridge: official settingsScope → host route →
|
|
1773
|
+
// localStorage (see the createSettingsBridge doc above).
|
|
1774
|
+
var bridge = createSettingsBridge(ctx, settings);
|
|
1775
|
+
if (typeof window !== 'undefined') window.__toolfoldBridge = bridge;
|
|
1776
|
+
var timers = {
|
|
1777
|
+
after: function (callback, delay) { return ctx.timeout(callback, delay); },
|
|
1778
|
+
every: function (callback, delay) { return ctx.interval(callback, delay); },
|
|
1779
|
+
cancel: function (handle) {
|
|
1780
|
+
if (handle !== undefined && handle !== null) handle();
|
|
1781
|
+
}
|
|
1782
|
+
};
|
|
1783
|
+
ctx.effect(function () {
|
|
1784
|
+
var offEngine = installCollapseEngine(document, settings, timers);
|
|
1785
|
+
return function () {
|
|
1786
|
+
offEngine();
|
|
1787
|
+
bridge.dispose();
|
|
1788
|
+
};
|
|
1789
|
+
});
|
|
1790
|
+
// Probe the host-backed tiers once; the official scope subscribe
|
|
1791
|
+
// above and this call both converge on the same adoptSection path.
|
|
1792
|
+
void bridge.load();
|
|
1793
|
+
if (React === null) return;
|
|
1794
|
+
var slots = ctx.slots;
|
|
1795
|
+
if (slots === undefined) return;
|
|
1796
|
+
try {
|
|
1797
|
+
// Generator form, exactly like the product's own entries; slots.inject
|
|
1798
|
+
// waits for the declaration and re-runs on redeclaration, so the card
|
|
1799
|
+
// appears whenever Settings → 插件 is open while this plugin runs.
|
|
1800
|
+
slots.inject('settings.plugin.item', function* () {
|
|
1801
|
+
yield slots.register(
|
|
1802
|
+
{
|
|
1803
|
+
name: 'settings.plugin.item',
|
|
1804
|
+
key: 'toolfold',
|
|
1805
|
+
id: 'toolfold',
|
|
1806
|
+
order: 30,
|
|
1807
|
+
label: '工具折叠',
|
|
1808
|
+
inject: function () {
|
|
1809
|
+
return {
|
|
1810
|
+
hooks: { ccxSettings: settings },
|
|
1811
|
+
setDur: function (ms) { bridge.write('durMs', ms); },
|
|
1812
|
+
setKeepThink: function (on) { bridge.write('keepThink', on); },
|
|
1813
|
+
setSplitThink: function (on) { bridge.write('splitThink', on); },
|
|
1814
|
+
setStats: function (on) { bridge.write('stats', on); },
|
|
1815
|
+
stats: function () { return settings.stats; },
|
|
1816
|
+
bridgeStatus: function () { return bridge.status(); },
|
|
1817
|
+
tick: function (cb, ms) { return ctx.interval(cb, ms); },
|
|
1818
|
+
cancelTick: function (handle) {
|
|
1819
|
+
if (handle !== undefined && handle !== null) handle();
|
|
1820
|
+
}
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
},
|
|
1824
|
+
SettingsCard
|
|
1825
|
+
);
|
|
1826
|
+
});
|
|
1827
|
+
} catch (err) {
|
|
1828
|
+
// console.error mirrors into the load report, so a registration
|
|
1829
|
+
// failure is visible instead of silently vanishing.
|
|
1830
|
+
console.error('toolfold settings card failed to register:', err);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
module.exports = plugin;
|
|
1836
|
+
return module.exports;
|
|
1837
|
+
}
|
|
1838
|
+
});
|