@mzzsfy/dsh-think-expand 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.3.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -3,6 +3,15 @@
3
3
  // 纯逻辑段在 LOGIC 标记之间,与 src/logic.mjs 保持同源,由 parity 测试(含逐函数
4
4
  // 源码文本对比)保证;容器观察器句柄挂 window 代际槽,HMR 重评估先拆上一代。
5
5
 
6
+ // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;键 = 市场短名(发现页
7
+ // 收录显示形态);该插件未就绪时入队,由其启动时排空
8
+ if (typeof window !== 'undefined') {
9
+ const NAV_ICON = { 'dsh-think-expand': 'spark' }
10
+ if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
11
+ else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
12
+ else window.__navicIconQueue = [NAV_ICON]
13
+ }
14
+
6
15
  window.__ModuleLoader__.load({
7
16
  id: '@mzzsfy/dsh-think-expand',
8
17
  factory(require) {
@@ -35,6 +44,22 @@ window.__ModuleLoader__.load({
35
44
  // 已见文本 Map 容量上限,超出按插入序裁剪最旧条目(手动/已读标记增长有界)。
36
45
  const SEEN_MAP_CAP = 10 * 20
37
46
 
47
+ // 置底判定阈值:与官方滚动跟随的贴近底部语义一致,距离底部不超过该值视为置底。
48
+ const PIN_THRESHOLD_PX = 25
49
+
50
+ // 置底判定:视口距底部不超过阈值;度量形态 { scrollHeight, scrollTop, clientHeight }。
51
+ function isPinned(metrics, threshold = PIN_THRESHOLD_PX) {
52
+ return metrics.scrollHeight - metrics.scrollTop - metrics.clientHeight <= threshold
53
+ }
54
+
55
+ // 置底回补判定:动作后偏离底部的量可被本次高度变化解释(官方跟随失效或浏览器
56
+ // 未补偿)才回补;用户在落定窗口内主动滚开的偏离超出该范围,不干预。
57
+ function shouldPinRestore(before, after, threshold = PIN_THRESHOLD_PX) {
58
+ const distance = after.scrollHeight - after.scrollTop - after.clientHeight
59
+ const heightDelta = after.scrollHeight - before.scrollHeight
60
+ return distance <= Math.abs(heightDelta) + threshold
61
+ }
62
+
38
63
  function capMap(map, cap = SEEN_MAP_CAP) {
39
64
  while (map.size > cap) {
40
65
  const oldest = map.keys().next()
@@ -249,28 +274,75 @@ window.__ModuleLoader__.load({
249
274
 
250
275
  const collectRows = (container) => Array.from(container.querySelectorAll(SELECTOR_ROW), describeRow)
251
276
 
252
- // 执行动作并返回被点击的行元素(未触发点击返回 null)
253
- function applyAction(described, action) {
277
+ // 执行动作并返回被点击的行元素(未触发点击返回 null)。
278
+ // 动作改变内容高度会把置底视口推离底部,官方滚动跟随随即失联:
279
+ // 动作前处于置底态时,高度落定后回补置底。
280
+ function applyAction(container, described, action) {
254
281
  const row = described[action.index]
255
282
  if (!row || !row.headable) return null
256
283
  const head = row.el.querySelector(SELECTOR_HEAD)
257
284
  if (head === null) return null
258
285
  if (head.getAttribute(ATTR_EXPANDED) !== String(action.kind === 'expand')) {
286
+ const before = containerMetrics(container)
259
287
  if (action.kind === 'expand') pluginExpandedEls.add(row.el)
260
288
  else pluginExpandedEls.delete(row.el)
261
289
  head.click()
290
+ if (isPinned(before)) schedulePinRestore(container, before)
262
291
  return row.el
263
292
  }
264
293
  return null
265
294
  }
266
295
 
296
+ // ---- 置底回补 ----
297
+
298
+ const containerMetrics = (container) => ({
299
+ scrollHeight: container.scrollHeight,
300
+ scrollTop: container.scrollTop,
301
+ clientHeight: container.clientHeight,
302
+ })
303
+
304
+ // 高度落定判定:高度变化后连续 SETTLE_FRAMES 帧不变即落定;
305
+ // 总帧数达上限按已落定兜底(渲染停滞环境不永久挂起)。
306
+ const PIN_SETTLE_FRAMES = 2
307
+ const PIN_SETTLE_MAX_FRAMES = 30
308
+
309
+ // 单一挂起回补任务(slot 代际槽):同批多动作时以最新前置度量重启,
310
+ // HMR 重评估经槽身份校验作废。
311
+ function schedulePinRestore(target, before) {
312
+ if (window[SLOT_KEY] !== slot) return
313
+ if (slot.pinFrame !== null) cancelAnimationFrame(slot.pinFrame)
314
+ let lastHeight = before.scrollHeight
315
+ let changed = false
316
+ let stableFrames = 0
317
+ let totalFrames = 0
318
+ const tick = () => {
319
+ slot.pinFrame = null
320
+ if (window[SLOT_KEY] !== slot || target !== observedContainer || !target.isConnected) return
321
+ const metrics = containerMetrics(target)
322
+ if (metrics.scrollHeight !== lastHeight) {
323
+ lastHeight = metrics.scrollHeight
324
+ changed = true
325
+ stableFrames = 0
326
+ } else {
327
+ stableFrames += 1
328
+ }
329
+ totalFrames += 1
330
+ if (totalFrames < PIN_SETTLE_MAX_FRAMES && (!changed || stableFrames < PIN_SETTLE_FRAMES)) {
331
+ slot.pinFrame = requestAnimationFrame(tick)
332
+ return
333
+ }
334
+ if (shouldPinRestore(before, metrics)) target.scrollTop = target.scrollHeight
335
+ }
336
+ slot.pinFrame = requestAnimationFrame(tick)
337
+ }
338
+
267
339
  function scan() {
268
340
  const container = document.querySelector(SELECTOR_SCROLL)
269
341
  // 容器身份守卫:容器替换至哨兵回调之间,旧观察器投递的扫描不对新容器用旧 registry 决策
270
342
  if (container === null || container !== observedContainer) return 0
271
343
  const described = collectRows(container)
272
344
  releaseLatchIfCollapsed(described)
273
- for (const action of plan(registry, toLogicRows(described)).actions) applyAction(described, action)
345
+ for (const action of plan(registry, toLogicRows(described)).actions) applyAction(container, described, action)
274
346
  return described.length
275
347
  }
276
348
 
@@ -313,7 +385,7 @@ window.__ModuleLoader__.load({
313
385
  if (!described.some((row) => row.headable)) return
314
386
  finalState.pending = false
315
387
  for (const action of planFinal(toLogicRows(described)).actions) {
316
- const clicked = applyAction(described, action)
388
+ const clicked = applyAction(container, described, action)
317
389
  if (clicked !== null) {
318
390
  finalState.el = clicked
319
391
  finalState.awaitRegister = true
@@ -366,7 +438,7 @@ window.__ModuleLoader__.load({
366
438
  if (container === null) return 0
367
439
  const described = collectRows(container)
368
440
  const actions = plan(registry, toLogicRows(described), { suppressManual: true }).actions
369
- for (const action of actions) applyAction(described, action)
441
+ for (const action of actions) applyAction(container, described, action)
370
442
  return described.length
371
443
  }
372
444
 
@@ -381,8 +453,9 @@ window.__ModuleLoader__.load({
381
453
  if (previous.bodySentinel !== null) previous.bodySentinel.disconnect()
382
454
  if (previous.containerObserver !== null) previous.containerObserver.disconnect()
383
455
  if (previous.debounceTimer !== null) clearTimeout(previous.debounceTimer)
456
+ if (previous.pinFrame !== null) cancelAnimationFrame(previous.pinFrame)
384
457
  }
385
- slot = { bodySentinel: null, containerObserver: null, debounceTimer: null }
458
+ slot = { bodySentinel: null, containerObserver: null, debounceTimer: null, pinFrame: null }
386
459
  window[SLOT_KEY] = slot
387
460
  bodySentinel = new MutationObserver(ensureAttached)
388
461
  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
  })