@mzzsfy/dsh-think-expand 0.2.3 → 0.2.4

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/README.md CHANGED
@@ -14,6 +14,7 @@ DeepSeek Harness 插件:流式输出思考时自动展开最新一条思考行,
14
14
  | 打开会话 / 刷新页面 / 切换会话 | 最后一条思考行自动展开,其余保持收起 |
15
15
  | 自动展开后用户手动收起 | 不再重展开,直到下次打开或切换会话 |
16
16
  | 用户手动展开较旧行 | 手动意图优先,插件不展开最后一条 |
17
+ | 插件展开/收起动作改变内容高度 | 动作前置底的视口在高度落定后回补置底,官方自动滚动跟随不失联;用户主动滚开则不干预 |
17
18
  | 卸载插件 | 无持久副作用,重载页面即恢复官方默认行为 |
18
19
 
19
20
  无任何设置项,安装即自动生效;同一时刻至多一条思考行处于展开状态;不做行数裁剪,不改写思考正文与官方折叠组件结构。
@@ -23,7 +24,8 @@ DeepSeek Harness 插件:流式输出思考时自动展开最新一条思考行,
23
24
  - Think 行识别:官方 ReasoningRow 根节点字面量属性 `[data-variant="think"]` + `data-state`,折叠头 `[data-disclosure-row]`,滚动容器 `[data-conversation-scroll]`;正文容器按 `thinkBody` 类名子串匹配(CSS Modules 哈希带前缀);识别失败即不干预。
24
25
  - 展开/收起模拟点击折叠头(按 `aria-expanded` 判定),与官方组件状态机一致,不直改 DOM 内部状态,不写任何 DOM 属性。
25
26
  - 行标识 = 控制器侧元素稳定 uid(WeakMap 计数),标记命中 uid 优先、已见文本前缀兜底,标记载体为模块级 Map(键为登记时文本);client 端插件展开过的行另以元素 WeakSet 记闩锁(展开记入、收起解除,会话切换重置),供决策层区分手动展开,不写 DOM 属性;全部标记随页面生命周期存活,不持久化。body 哨兵观察器常驻 document.body 监视容器身份变化(会话切换,仅 childList 通道);容器重挂后首扫抑制手动识别,既存展开行视为中性;观察器句柄挂 window 代际槽,HMR 重评估先拆上一代,防多套决策机并行同扫。
26
- - 纯逻辑层(行分类、展开决策、标记存活性)在 `src/logic.mjs`,`src/client.js` 内嵌同源实现,`node --test` 以同一套 BDD 场景对两份实现做 parity 验证,并逐函数对比源码文本(含内部助手)防双写漂移。
27
+ - 纯逻辑层(行分类、展开决策、标记存活性、置底判定)在 `src/logic.mjs`,`src/client.js` 内嵌同源实现,`node --test` 以同一套 BDD 场景对两份实现做 parity 验证,并逐函数对比源码文本(含内部助手)防双写漂移。
28
+ - 置底保持:官方滚动跟随以"视口贴近底部"为前提(距离底部不超过阈值判定置底),插件展开/收起改变内容高度会把置底视口推离底部,官方跟随随即失联。动作前记录置底状态,高度落定(rAF 帧计数)后偏离量可被本次高度变化解释才回补置底,用户主动滚开不干预;判定函数进纯逻辑层。
27
29
  - Host 半区 `src/index.js` 为空壳,profile 行仅用于让 client 模块系统发现本包的 dsh.client 声明。
28
30
 
29
31
  ## 安装
@@ -51,7 +53,7 @@ pnpm --dir packages/dsh-think-expand test
51
53
  node --test packages/dsh-think-expand/test/*.test.mjs
52
54
  ```
53
55
 
54
- 覆盖:上表全部行为场景、uid 行身份(同开头历史行不劫持定位、已读标记不拦同开头新行)、suppressManual 首扫、批量 running 降级、识别失败降级、空正文降级、双实现 parity(场景 + 导出键守卫 + 逐函数源码对比)、client.js 语法检查。
56
+ 覆盖:上表全部行为场景、置底判定与置底回补判定(阈值边界/高度变化可解释偏离/用户滚开不回补)、uid 行身份(同开头历史行不劫持定位、已读标记不拦同开头新行)、suppressManual 首扫、批量 running 降级、识别失败降级、空正文降级、双实现 parity(场景 + 导出键守卫 + 逐函数源码对比)、client.js 语法检查。
55
57
 
56
58
  ## License
57
59
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-think-expand",
3
3
  "description": "流式回复时自动展开 AI 思考过程,始终显示最新一条;安装即生效,手动操作优先,装卸无残留",
4
- "version": "0.2.3",
4
+ "version": "0.2.4",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -35,6 +35,22 @@ window.__ModuleLoader__.load({
35
35
  // 已见文本 Map 容量上限,超出按插入序裁剪最旧条目(手动/已读标记增长有界)。
36
36
  const SEEN_MAP_CAP = 10 * 20
37
37
 
38
+ // 置底判定阈值:与官方滚动跟随的贴近底部语义一致,距离底部不超过该值视为置底。
39
+ const PIN_THRESHOLD_PX = 25
40
+
41
+ // 置底判定:视口距底部不超过阈值;度量形态 { scrollHeight, scrollTop, clientHeight }。
42
+ function isPinned(metrics, threshold = PIN_THRESHOLD_PX) {
43
+ return metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight <= threshold
44
+ }
45
+
46
+ // 置底回补判定:动作后偏离底部的量可被本次高度变化解释(官方跟随失效或浏览器
47
+ // 未补偿)才回补;用户在落定窗口内主动滚开的偏离超出该范围,不干预。
48
+ function shouldPinRestore(before, after, threshold = PIN_THRESHOLD_PX) {
49
+ const distance = after.scrollHeight - after.scrollTop - after.clientHeight
50
+ const heightDelta = after.scrollHeight - before.scrollHeight
51
+ return distance <= Math.abs(heightDelta) + threshold
52
+ }
53
+
38
54
  function capMap(map, cap = SEEN_MAP_CAP) {
39
55
  while (map.size > cap) {
40
56
  const oldest = map.keys().next()
@@ -249,28 +265,75 @@ window.__ModuleLoader__.load({
249
265
 
250
266
  const collectRows = (container) => Array.from(container.querySelectorAll(SELECTOR_ROW), describeRow)
251
267
 
252
- // 执行动作并返回被点击的行元素(未触发点击返回 null)
253
- function applyAction(described, action) {
268
+ // 执行动作并返回被点击的行元素(未触发点击返回 null)。
269
+ // 动作改变内容高度会把置底视口推离底部,官方滚动跟随随即失联:
270
+ // 动作前处于置底态时,高度落定后回补置底。
271
+ function applyAction(container, described, action) {
254
272
  const row = described[action.index]
255
273
  if (!row || !row.headable) return null
256
274
  const head = row.el.querySelector(SELECTOR_HEAD)
257
275
  if (head === null) return null
258
276
  if (head.getAttribute(ATTR_EXPANDED) !== String(action.kind === 'expand')) {
277
+ const before = containerMetrics(container)
259
278
  if (action.kind === 'expand') pluginExpandedEls.add(row.el)
260
279
  else pluginExpandedEls.delete(row.el)
261
280
  head.click()
281
+ if (isPinned(before)) schedulePinRestore(container, before)
262
282
  return row.el
263
283
  }
264
284
  return null
265
285
  }
266
286
 
287
+ // ---- 置底回补 ----
288
+
289
+ const containerMetrics = (container) => ({
290
+ scrollHeight: container.scrollHeight,
291
+ scrollTop: container.scrollTop,
292
+ clientHeight: container.clientHeight,
293
+ })
294
+
295
+ // 高度落定判定:高度变化后连续 SETTLE_FRAMES 帧不变即落定;
296
+ // 总帧数达上限按已落定兜底(渲染停滞环境不永久挂起)。
297
+ const PIN_SETTLE_FRAMES = 2
298
+ const PIN_SETTLE_MAX_FRAMES = 30
299
+
300
+ // 单一挂起回补任务(slot 代际槽):同批多动作时以最新前置度量重启,
301
+ // HMR 重评估经槽身份校验作废。
302
+ function schedulePinRestore(target, before) {
303
+ if (window[SLOT_KEY] !== slot) return
304
+ if (slot.pinFrame !== null) cancelAnimationFrame(slot.pinFrame)
305
+ let lastHeight = before.scrollHeight
306
+ let changed = false
307
+ let stableFrames = 0
308
+ let totalFrames = 0
309
+ const tick = () => {
310
+ slot.pinFrame = null
311
+ if (window[SLOT_KEY] !== slot || target !== observedContainer || !target.isConnected) return
312
+ const metrics = containerMetrics(target)
313
+ if (metrics.scrollHeight !== lastHeight) {
314
+ lastHeight = metrics.scrollHeight
315
+ changed = true
316
+ stableFrames = 0
317
+ } else {
318
+ stableFrames += 1
319
+ }
320
+ totalFrames += 1
321
+ if (totalFrames < PIN_SETTLE_MAX_FRAMES && (!changed || stableFrames < PIN_SETTLE_FRAMES)) {
322
+ slot.pinFrame = requestAnimationFrame(tick)
323
+ return
324
+ }
325
+ if (shouldPinRestore(before, metrics)) target.scrollTop = target.scrollHeight
326
+ }
327
+ slot.pinFrame = requestAnimationFrame(tick)
328
+ }
329
+
267
330
  function scan() {
268
331
  const container = document.querySelector(SELECTOR_SCROLL)
269
332
  // 容器身份守卫:容器替换至哨兵回调之间,旧观察器投递的扫描不对新容器用旧 registry 决策
270
333
  if (container === null || container !== observedContainer) return 0
271
334
  const described = collectRows(container)
272
335
  releaseLatchIfCollapsed(described)
273
- for (const action of plan(registry, toLogicRows(described)).actions) applyAction(described, action)
336
+ for (const action of plan(registry, toLogicRows(described)).actions) applyAction(container, described, action)
274
337
  return described.length
275
338
  }
276
339
 
@@ -313,7 +376,7 @@ window.__ModuleLoader__.load({
313
376
  if (!described.some((row) => row.headable)) return
314
377
  finalState.pending = false
315
378
  for (const action of planFinal(toLogicRows(described)).actions) {
316
- const clicked = applyAction(described, action)
379
+ const clicked = applyAction(container, described, action)
317
380
  if (clicked !== null) {
318
381
  finalState.el = clicked
319
382
  finalState.awaitRegister = true
@@ -366,7 +429,7 @@ window.__ModuleLoader__.load({
366
429
  if (container === null) return 0
367
430
  const described = collectRows(container)
368
431
  const actions = plan(registry, toLogicRows(described), { suppressManual: true }).actions
369
- for (const action of actions) applyAction(described, action)
432
+ for (const action of actions) applyAction(container, described, action)
370
433
  return described.length
371
434
  }
372
435
 
@@ -381,8 +444,9 @@ window.__ModuleLoader__.load({
381
444
  if (previous.bodySentinel !== null) previous.bodySentinel.disconnect()
382
445
  if (previous.containerObserver !== null) previous.containerObserver.disconnect()
383
446
  if (previous.debounceTimer !== null) clearTimeout(previous.debounceTimer)
447
+ if (previous.pinFrame !== null) cancelAnimationFrame(previous.pinFrame)
384
448
  }
385
- slot = { bodySentinel: null, containerObserver: null, debounceTimer: null }
449
+ slot = { bodySentinel: null, containerObserver: null, debounceTimer: null, pinFrame: null }
386
450
  window[SLOT_KEY] = slot
387
451
  bodySentinel = new MutationObserver(ensureAttached)
388
452
  slot.bodySentinel = bodySentinel
package/src/logic.mjs CHANGED
@@ -7,6 +7,22 @@ export const STATE_OK = 'ok'
7
7
  // 已见文本 Map 容量上限,超出按插入序裁剪最旧条目(手动/已读标记增长有界)。
8
8
  export const SEEN_MAP_CAP = 10 * 20
9
9
 
10
+ // 置底判定阈值:与官方滚动跟随的贴近底部语义一致,距离底部不超过该值视为置底。
11
+ export const PIN_THRESHOLD_PX = 25
12
+
13
+ // 置底判定:视口距底部不超过阈值;度量形态 { scrollHeight, scrollTop, clientHeight }。
14
+ export function isPinned(metrics, threshold = PIN_THRESHOLD_PX) {
15
+ return metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight <= threshold
16
+ }
17
+
18
+ // 置底回补判定:动作后偏离底部的量可被本次高度变化解释(官方跟随失效或浏览器
19
+ // 未补偿)才回补;用户在落定窗口内主动滚开的偏离超出该范围,不干预。
20
+ export function shouldPinRestore(before, after, threshold = PIN_THRESHOLD_PX) {
21
+ const distance = after.scrollHeight - after.scrollTop - after.clientHeight
22
+ const heightDelta = after.scrollHeight - before.scrollHeight
23
+ return distance <= Math.abs(heightDelta) + threshold
24
+ }
25
+
10
26
  // 前缀匹配:空串 seen 会命中任意行,视为无匹配。
11
27
  function prefixOf(seen, text) {
12
28
  return seen.length > 0 && text.length >= seen.length && text.startsWith(seen)
@@ -21,7 +21,7 @@ function clientLogic() {
21
21
  const factory = new Function(
22
22
  '"use strict";'
23
23
  + section
24
- + '; return { STATE_RUNNING, STATE_OK, createRegistry, plan, planFinal, registerCurrent, capMap, needsReattach, SEEN_MAP_CAP, prefixOf, matchMark, findSeenKey, isCurrent, putSeen, findRow };',
24
+ + '; return { STATE_RUNNING, STATE_OK, createRegistry, plan, planFinal, registerCurrent, capMap, needsReattach, SEEN_MAP_CAP, prefixOf, matchMark, findSeenKey, isCurrent, putSeen, findRow, PIN_THRESHOLD_PX, isPinned, shouldPinRestore };',
25
25
  )
26
26
  return factory()
27
27
  }
@@ -37,7 +37,50 @@ const collapseOf = (result, index) =>
37
37
  result.actions.filter((a) => a.index === index && a.kind === 'collapse').length
38
38
 
39
39
  function defineScenarios(prefix, L) {
40
- const { createRegistry, plan, planFinal, registerCurrent, STATE_RUNNING, STATE_OK, capMap, needsReattach, SEEN_MAP_CAP } = L
40
+ const { createRegistry, plan, planFinal, registerCurrent, STATE_RUNNING, STATE_OK, capMap, needsReattach, SEEN_MAP_CAP, PIN_THRESHOLD_PX, isPinned, shouldPinRestore } = L
41
+
42
+ // 滚动度量构造:{ scrollHeight, scrollTop, clientHeight }
43
+ const metrics = (scrollHeight, scrollTop, clientHeight) => ({ scrollHeight, scrollTop, clientHeight })
44
+
45
+ test(prefix + '置底判定:距离不超过阈值视为置底', () => {
46
+ // 距离 = 800 - 0 - 600 = 200,远超阈值,非置底
47
+ assert.equal(isPinned(metrics(800, 0, 600)), false)
48
+ // 恰好触底(scrollTop = scrollHeight - clientHeight)
49
+ assert.equal(isPinned(metrics(800, 550, 600)), true)
50
+ // 距离等于阈值,边界内置底
51
+ assert.equal(isPinned(metrics(800, 800 - 600 - PIN_THRESHOLD_PX, 600)), true)
52
+ // 距离超出阈值一个像素,非置底
53
+ assert.equal(isPinned(metrics(800, 800 - 600 - PIN_THRESHOLD_PX - 1, 600)), false)
54
+ })
55
+
56
+ test(prefix + '置底回补判定:偏离可被高度变化解释才回补', () => {
57
+ // 展开:高度 +500,原置底,官方未自愈(偏离 500)→ 回补
58
+ const before = metrics(1000, 300, 700)
59
+ const afterExpand = metrics(1500, 300, 700)
60
+ assert.equal(shouldPinRestore(before, afterExpand), true)
61
+ // 官方已自愈(已在底部)→ 回补判定为真,写入为幂等无操作
62
+ assert.equal(shouldPinRestore(before, metrics(1500, 800, 700)), true)
63
+ // 用户落定窗口内主动滚开:偏离超出变化量 + 阈值 → 不回补
64
+ const userScrolled = metrics(1500, 800 - PIN_THRESHOLD_PX - 1 - 500, 700)
65
+ assert.equal(shouldPinRestore(before, userScrolled), false)
66
+ // 收起:高度 -300,无钳制无锚定(偏离 300)→ 回补
67
+ const afterCollapse = metrics(700, 300, 700)
68
+ assert.equal(shouldPinRestore(before, afterCollapse), true)
69
+ // 收起后浏览器已钳制回底部 → 幂等
70
+ assert.equal(shouldPinRestore(before, metrics(700, 0, 700)), true)
71
+ // 高度未变化(原点击未改变高度)且已在底 → 幂等回补
72
+ assert.equal(shouldPinRestore(before, metrics(1000, 300, 700)), true)
73
+ })
74
+
75
+ test(prefix + '置底回补阈值可显式覆盖', () => {
76
+ const before = metrics(1000, 300, 700)
77
+ // 阈值 0:偏离恰等于高度变化量(500)→ 回补;再多一像素 → 不回补
78
+ assert.equal(shouldPinRestore(before, metrics(1500, 300, 700), 0), true)
79
+ assert.equal(shouldPinRestore(before, metrics(1500, 299, 700), 0), false)
80
+ // 默认阈值下偏离变化量 + 25 以内 → 回补
81
+ assert.equal(shouldPinRestore(before, metrics(1500, 275, 700)), true)
82
+ assert.equal(shouldPinRestore(before, metrics(1500, 274, 700)), false)
83
+ })
41
84
 
42
85
  test(prefix + '流式思考自动展开', () => {
43
86
  const reg = createRegistry()
@@ -329,7 +372,7 @@ test('LOGIC 段与 logic.mjs 逐函数源码一致(归一化注释与空白,含
329
372
  .replace(/\/\*[\s\S]*?\*\//g, '')
330
373
  .replace(/\/\/[^\n]*/g, '')
331
374
  .replace(/\s+/g, '')
332
- for (const name of ['plan', 'planFinal', 'createRegistry', 'capMap', 'needsReattach', 'registerCurrent']) {
375
+ for (const name of ['plan', 'planFinal', 'createRegistry', 'capMap', 'needsReattach', 'registerCurrent', 'isPinned', 'shouldPinRestore']) {
333
376
  assert.equal(
334
377
  normalize(client[name].toString()),
335
378
  normalize(logic[name].toString()),
@@ -343,7 +386,7 @@ test('LOGIC 段与 logic.mjs 逐函数源码一致(归一化注释与空白,含
343
386
  'LOGIC 段内部助手与 logic.mjs 漂移: ' + name,
344
387
  )
345
388
  }
346
- for (const name of ['STATE_RUNNING', 'STATE_OK', 'SEEN_MAP_CAP']) {
389
+ for (const name of ['STATE_RUNNING', 'STATE_OK', 'SEEN_MAP_CAP', 'PIN_THRESHOLD_PX']) {
347
390
  assert.equal(client[name], logic[name], '常量漂移: ' + name)
348
391
  }
349
392
  })