@mzzsfy/dsh-context-manager 0.2.1 → 0.4.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
@@ -1,14 +1,15 @@
1
1
  # dsh-context-manager
2
2
 
3
- 对话增强插件:历史输入回溯、插话撤回、对话分叉(进行中轮分叉/自动重发)、标题栏复制 sessionId。dsh-session-manager 历史输入功能的独立延续包(原包已移除该功能)。
3
+ 对话增强插件:历史输入回溯(Alt+↑ 或输入框工具排按钮唤起)、插话撤回、对话分叉(进行中轮分叉/自动重发)、标题栏复制 sessionId。dsh-session-manager 历史输入功能的独立延续包(原包已移除该功能)。
4
4
 
5
5
  ## 功能
6
6
 
7
- ### 历史输入浮层(Alt+↑)
7
+ ### 历史输入浮层(Alt+↑ 或输入框按钮)
8
8
 
9
- 输入框按 `Alt+↑` 唤起历史输入浮层:
9
+ 输入框按 `Alt+↑`,或点击输入框下方工具排的历史按钮(指令/附件右侧,与 Alt+↑ 等效,再点关闭)唤起历史输入浮层:
10
10
 
11
- - 范围导航:`←/→` 按 常用(prompts)→ 当前会话(session)→ 本工作区(workspace)→ 全部工作区(global) 依次切换(边界停住),列表时间倒序
11
+ - 范围导航:顶栏范围标签点击展开 pill 组选择;`←/→` 按 常用(prompts)→ 当前会话(session)→ 本工作区(workspace)→ 全部工作区(global) 依次切换(边界停住),列表时间倒序
12
+ - 收录范围:普通输入与斜杠命令(如 `/goal …`,按会话日志的 command/run 重组为完整命令行),工具结果回填与插件注入不收录
12
13
  - 搜索:输入即时过滤(IME 组合输入守卫,命中计数),`↑/↓` 选中、`Enter` 回填
13
14
  - 回填经宿主公共契约 `inputActions.setDraft`;`Esc` 两级退出(搜索词非空先清词,再按关闭浮层)
14
15
  - 列表行悬停星标收藏/取消常用提示词;常用范围内点「编辑」进入编辑态(重命名/删除/新增),数据落全局 `prompts.json`,与自动历史互不覆写
@@ -40,7 +41,7 @@
40
41
 
41
42
  ## 启停
42
43
 
43
- 设置页「插件」→「对话增强」卡片五开关(历史输入/插话撤回/对话分叉/分叉后自动重发/标题栏复制 sessionId),经宿主 settings 持久,变更刷新页面生效。
44
+ 设置页「插件」→「对话增强」卡片六开关(历史输入/输入框历史按钮/插话撤回/对话分叉/分叉后自动重发/标题栏复制 sessionId),经宿主 settings 持久,变更刷新页面生效。「输入框历史按钮」仅控制工具排按钮显隐,停用后 Alt+↑ 与浮层不受影响。
44
45
 
45
46
  ## 依赖与门控
46
47
 
@@ -55,6 +56,7 @@
55
56
  | `/api/context/inputs` | GET | 历史输入(`?sessionId=&scope=`) |
56
57
  | `/api/context/prompts/toggle` | POST | 收藏/取消收藏(按截断后文本查重) |
57
58
  | `/api/context/history-enabled` | GET/POST | 历史浮层启停 |
59
+ | `/api/context/history-button-enabled` | GET/POST | 输入框历史按钮启停 |
58
60
  | `/api/context/steer-recall-enabled` | GET/POST | 插话撤回启停 |
59
61
  | `/api/context/fork-enabled` | GET/POST | 对话分叉启停 |
60
62
  | `/api/context/fork-auto-resend-enabled` | GET/POST | 分叉后自动重发启停 |
@@ -64,4 +66,4 @@
64
66
 
65
67
  ## dsh 版本兼容
66
68
 
67
- 三版本全部通过:样式注入 data-plugin 契约、Alt+↑ 历史浮层、对话 fork、设置「插件」页对话增强卡四开关、host 半区 API(history-enabled / fork-enabled)。0.1.6 设置分区卡片位于「插件」页,非独立导航项。
69
+ 三版本(0.1.2-rc.1 / 0.1.5-rc.3 / 0.1.7-rc.1)兼容通过:样式注入 data-plugin 契约、历史输入浮层(Alt+↑ 或输入框按钮)、启停路由与方法白名单、激活 live。0.1.6 设置分区卡片位于「插件」页,非独立导航项;0.1.7-rc.1 内置插件页改版为分组列表(无卡片开关锚),样式与 API 面实测正常。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-context-manager",
3
3
  "description": "对话增强:历史输入浮层(范围导航/搜索/subagent排除)、常用提示词收藏、插话撤回、对话 fork(进行中轮分叉/自动重发)",
4
- "version": "0.2.1",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -1,11 +1,13 @@
1
- // dsh-context-manager Client 半区:「插件」设置页可配置卡片(五启停开关)
2
- // + 历史输入浮层(Alt+↑ 唤起,范围导航/搜索/收藏回填) + 插话撤回 + 对话 fork
1
+ // dsh-context-manager Client 半区:「插件」设置页可配置卡片(六启停开关)
2
+ // + 历史输入浮层(Alt+↑ 或输入框工具排按钮唤起,范围导航/搜索/收藏回填)
3
+ // + 插话撤回 + 对话 fork
3
4
  // + 标题栏复制 sessionId(悬停显隐)。
4
5
  // 历史输入挂官方 conversation.input.dock 插槽(渲染为零高度锚点),回填走宿主公共
5
6
  // 契约 inputActions.setDraft;插话撤回注入官方 pending steering 气泡操作图标排;
6
7
  // fork 注入消息气泡操作排,分叉到该轮之前并把该轮用户输入回填子会话输入框(重试
7
8
  // 语义),RPC 走宿主 sessions 服务面(fork+open),锚点经 remote.session.follow
8
- // 开场帧建立轮号→{ 结束 seq, 首问文本 } 映射;复制 sessionId 挂官方
9
+ // 开场帧建立轮号→{ 结束 seq, 首问文本 } 映射;回填前经官方 input 快照(useInput)
10
+ // 探测输入框占用,已被用户输入则不覆盖(权限高于回填);复制 sessionId 挂官方
9
11
  // conversation.session.header.actions 插槽。浏览器半区经 webServer 路由
10
12
  // ('/api/context/*')访问 Host。打包为单文件自包含格式,无法跨文件 require;
11
13
  // 与 src/core.mjs 镜像的纯函数(filterHistoryInputs/forkFailureText/forkRetryText)
@@ -16,6 +18,7 @@ window.__ModuleLoader__.load({
16
18
  factory(require) {
17
19
  const React = require('react')
18
20
  const { useState, useEffect, useRef } = React
21
+ const PKG_ID = '@mzzsfy/dsh-context-manager'
19
22
 
20
23
  // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;键 = 市场短名(发现页
21
24
  // 收录显示形态);该插件未就绪时入队,由其启动时排空
@@ -78,11 +81,19 @@ const CSS = [
78
81
  ' color-scheme:light dark; color:light-dark(#0f1115, #e8eaed);',
79
82
  ' background:light-dark(#fff, #1e1f22); border-radius:14px;',
80
83
  ' box-shadow:0 0 0 0.5px light-dark(rgba(15,17,21,.18), rgba(255,255,255,.14)), 0 4px 16px rgba(0,0,0,.08), 0 16px 48px rgba(0,0,0,.16); }',
81
- '.cx-hist__hint { display:flex; align-items:center; gap:10px; padding:9px 14px; flex:none;',
84
+ '.cx-hist__hint { display:flex; flex-wrap:wrap; align-items:center; gap:10px; padding:9px 14px; flex:none;',
82
85
  ' border-bottom:1px solid light-dark(rgba(15,17,21,.08), rgba(255,255,255,.1));',
83
86
  ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:light-dark(rgba(15,17,21,.55), rgba(232,234,237,.55)); }',
84
- '.cx-hist__scope { padding:2px 10px; border-radius:999px; flex:none;',
85
- ' background:#1677ff; color:#fff; font-weight:600; }',
87
+ // 范围 pill 组:点击直达切换,选中实底蓝,未选淡底
88
+ '.cx-hist__scopebar { display:flex; align-items:center; gap:4px; flex:none; }',
89
+ '.cx-hist__pill { appearance:none; border:0; cursor:pointer; flex:none; padding:2px 9px;',
90
+ ' border-radius:999px; font:var(--dsw-font-xxs-12, 12px/18px sans-serif);',
91
+ ' color:light-dark(rgba(15,17,21,.6), rgba(232,234,237,.6));',
92
+ ' background:light-dark(rgba(15,17,21,.06), rgba(255,255,255,.08)); }',
93
+ '.cx-hist__pill:hover { color:light-dark(rgba(15,17,21,.85), rgba(232,234,237,.85));',
94
+ ' background:light-dark(rgba(15,17,21,.1), rgba(255,255,255,.14)); }',
95
+ '.cx-hist__pill--on, .cx-hist__pill--on:hover { background:#1677ff; color:#fff; font-weight:600; }',
96
+ '.cx-hist__pill:focus-visible { outline:2px solid #1677ff; outline-offset:1px; }',
86
97
  '.cx-hist__search { flex:1; min-width:0; padding:3px 10px; border-radius:8px; color-scheme:light dark;',
87
98
  ' border:1px solid light-dark(rgba(15,17,21,.14), rgba(255,255,255,.18));',
88
99
  ' background:transparent; color:inherit; font:var(--dsw-font-xxs-12, 12px/18px sans-serif); }',
@@ -154,6 +165,7 @@ const CSS = [
154
165
  const INPUTS_URL = '/api/context/inputs'
155
166
  const PROMPTS_TOGGLE_URL = '/api/context/prompts/toggle'
156
167
  const HISTORY_ENABLED_URL = '/api/context/history-enabled'
168
+ const HISTORY_BUTTON_ENABLED_URL = '/api/context/history-button-enabled'
157
169
  const STEER_ENABLED_URL = '/api/context/steer-recall-enabled'
158
170
  const FORK_ENABLED_URL = '/api/context/fork-enabled'
159
171
  const FORK_AUTO_RESEND_URL = '/api/context/fork-auto-resend-enabled'
@@ -236,11 +248,70 @@ function forkRetryText(data) {
236
248
  return text.trim() === '' ? null : text
237
249
  }
238
250
 
251
+ // ── 输入框历史按钮:注入官方输入工具排,点击开关浮层(与 Alt+↑ 等效)──
252
+
253
+ // 注入标记:识别自家节点,防重复注入;停用/卸载按标记全量移除
254
+ const HISTORY_BTN_FLAG = 'data-cx-hist-trigger'
255
+ // 官方输入工具排定位锚点:行类名后缀 + 官方 _add 图标按钮,双锚点一致才认;
256
+ // 官方类名漂移时寻行返回 null,注入静默跳过(干净禁用)
257
+ const TOOLS_ROW_SUFFIX = '_tools'
258
+ // 按钮悬停说明:点击与 Alt+↑ 等效
259
+ const HISTORY_BUTTON_TITLE = '历史输入(Alt+↑)'
260
+
261
+ // 官方输入工具排寻行:遍历官方 _add 图标按钮取其所属行
262
+ function findToolsRow(doc) {
263
+ for (const button of doc.querySelectorAll('button[class$="_add"]')) {
264
+ const row = button.parentElement
265
+ if (row && typeof row.className === 'string' && row.className.endsWith(TOOLS_ROW_SUFFIX)) return row
266
+ }
267
+ return null
268
+ }
269
+
270
+ // 历史图标 svg(时钟回拨):fill 继承 currentColor,视觉重量与官方 14px 图标一致
271
+ function histButtonSvg(doc) {
272
+ const namespace = 'http://www.w3.org/2000/svg'
273
+ const svg = doc.createElementNS(namespace, 'svg')
274
+ svg.setAttribute('width', '14')
275
+ svg.setAttribute('height', '14')
276
+ svg.setAttribute('viewBox', '0 0 24 24')
277
+ svg.setAttribute('fill', 'none')
278
+ svg.setAttribute('aria-hidden', 'true')
279
+ const path = doc.createElementNS(namespace, 'path')
280
+ path.setAttribute('fill', 'currentColor')
281
+ path.setAttribute('d', 'M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L8.85 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 16.99 10.51 18 13 18c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z')
282
+ svg.appendChild(path)
283
+ return svg
284
+ }
285
+
286
+ // 幂等注入:未注入时克隆官方 _add 类名创建按钮(尺寸/hover 全部原生),位置
287
+ // 固定在最后一个官方 _add 之后(与指令/附件同簇);已注入原样返回。
288
+ // doc 注入便于测试与宿主文档解耦
289
+ function ensureHistoryButton(doc, row, onToggle) {
290
+ const existing = row.querySelector('[' + HISTORY_BTN_FLAG + ']')
291
+ if (existing) return existing
292
+ const official = row.querySelector('button[class$="_add"]')
293
+ if (!official) return null
294
+ const button = doc.createElement('button')
295
+ button.type = 'button'
296
+ button.setAttribute(HISTORY_BTN_FLAG, '')
297
+ button.className = official.className
298
+ button.title = HISTORY_BUTTON_TITLE
299
+ button.setAttribute('aria-label', '历史输入')
300
+ button.addEventListener('click', onToggle)
301
+ button.appendChild(histButtonSvg(doc))
302
+ const officialButtons = row.querySelectorAll('button[class$="_add"]')
303
+ const last = officialButtons[officialButtons.length - 1]
304
+ row.insertBefore(button, (last && last.nextSibling) || null)
305
+ return button
306
+ }
307
+
239
308
  function HistoryDock({ session, inputActions }) {
240
309
  const [open, setOpen] = useState(false)
241
310
  const [items, setItems] = useState(null)
242
311
  const [cursor, setCursor] = useState(-1)
243
312
  const [scopeIndex, setScopeIndex] = useState(HISTORY_SCOPE_DEFAULT)
313
+ // 范围标签展开态:默认单胶囊只显当前范围,点击展开 pill 组,选择后收起
314
+ const [scopeOpen, setScopeOpen] = useState(false)
244
315
  const [aligning, setAligning] = useState(false)
245
316
  const [loadError, setLoadError] = useState(false)
246
317
  // 收藏集合:浮层打开时并行拉一次,行悬停星标据此显示实/空心;
@@ -262,8 +333,13 @@ function HistoryDock({ session, inputActions }) {
262
333
  // 历史浮层启停(设置开关):挂载拉取一次,停用即整体不渲染、键盘放行
263
334
  const enabledRef = React.useRef(true)
264
335
  const [historyEnabled, setHistoryEnabled] = useState(true)
336
+ // 输入框历史按钮启停(设置开关):false 仅隐藏按钮,快捷键与浮层不受影响
337
+ const buttonEnabledRef = React.useRef(true)
338
+ const [buttonEnabled, setButtonEnabled] = useState(true)
265
339
  sessionRef.current = session
266
340
  inputActionsRef.current = inputActions
341
+ // 条件 return 之前同步:停用路径的扫描守卫也要看到最新值(与 SteerRecallDock 同构)
342
+ buttonEnabledRef.current = buttonEnabled
267
343
  useEffect(() => {
268
344
  api(HISTORY_ENABLED_URL)
269
345
  .then((payload) => {
@@ -272,6 +348,9 @@ function HistoryDock({ session, inputActions }) {
272
348
  setHistoryEnabled(on)
273
349
  })
274
350
  .catch(() => {})
351
+ api(HISTORY_BUTTON_ENABLED_URL)
352
+ .then((payload) => setButtonEnabled(payload ? payload.enabled !== false : true))
353
+ .catch(() => {})
275
354
  }, [])
276
355
 
277
356
  function syncView(patch) {
@@ -388,6 +467,7 @@ function HistoryDock({ session, inputActions }) {
388
467
  rawItemsRef.current = null
389
468
  queryRef.current = ''
390
469
  setQuery('')
470
+ setScopeOpen(false)
391
471
  syncView({ open: true, items: null, cursor: -1, scopeIndex: HISTORY_SCOPE_DEFAULT, aligning: false })
392
472
  setOpen(true)
393
473
  setScopeIndex(HISTORY_SCOPE_DEFAULT)
@@ -400,10 +480,10 @@ function HistoryDock({ session, inputActions }) {
400
480
  requestScope(HISTORY_SCOPE_DEFAULT)
401
481
  }
402
482
 
403
- // 切换范围:→ 向大(工作区/全局),← 返回收藏;边界停住;搜索词清空后缓存直接返回
404
- function switchScope(delta) {
405
- const next = viewRef.current.scopeIndex + delta
406
- if (next < 0 || next >= HISTORY_SCOPE_LABELS.length) return
483
+ // 范围切换共用落点:清过滤与选择态后拉取目标范围;同范围不动,切换即收起展开态
484
+ function selectScope(next) {
485
+ if (next === viewRef.current.scopeIndex) return
486
+ setScopeOpen(false)
407
487
  rawItemsRef.current = null
408
488
  queryRef.current = ''
409
489
  setQuery('')
@@ -417,6 +497,13 @@ function HistoryDock({ session, inputActions }) {
417
497
  requestScope(next)
418
498
  }
419
499
 
500
+ // ←/→ 相对切换:→ 向大(工作区/全局),← 返回收藏;边界停住
501
+ function switchScope(delta) {
502
+ const next = viewRef.current.scopeIndex + delta
503
+ if (next < 0 || next >= HISTORY_SCOPES.length) return
504
+ selectScope(next)
505
+ }
506
+
420
507
  // Alt+↑ 唤起浮层;浮层开 = 菜单模态,捕获阶段拦截导航键,先于 Lexical 光标移动。
421
508
  // 设置开关停用时监听器直接放行(读 ref,开关值挂载拉取后即时反映)
422
509
  useEffect(() => {
@@ -493,11 +580,15 @@ function HistoryDock({ session, inputActions }) {
493
580
  if (row) row.scrollIntoView({ block: 'nearest' })
494
581
  }, [open, cursor, items])
495
582
 
496
- // 浮层外点击关闭(双写:漏写 viewRef 会让键盘层误判浮层仍开,吞掉输入框方向键)
583
+ // 浮层外点击关闭(双写:漏写 viewRef 会让键盘层误判浮层仍开,吞掉输入框方向键)。
584
+ // 注入按钮在本容器外,closest 守卫跳过:否则 mousedown 先关、click 再开,
585
+ // 浮层开时点按钮等于常开,toggle 语义失效
497
586
  useEffect(() => {
498
587
  if (!open) return undefined
499
588
  function onPointerDown(event) {
500
- if (rootRef.current && !rootRef.current.contains(event.target)) {
589
+ const target = event.target
590
+ if (target && typeof target.closest === 'function' && target.closest('[' + HISTORY_BTN_FLAG + ']')) return
591
+ if (rootRef.current && !rootRef.current.contains(target)) {
501
592
  syncView({ open: false })
502
593
  setOpen(false)
503
594
  }
@@ -506,6 +597,49 @@ function HistoryDock({ session, inputActions }) {
506
597
  return () => document.removeEventListener('mousedown', onPointerDown, true)
507
598
  }, [open])
508
599
 
600
+ // 注入按钮点击:开→关,关→唤起;经 ref 接线,挂载一次的监听器恒读最新闭包
601
+ function toggleFromButton() {
602
+ if (viewRef.current.open) {
603
+ syncView({ open: false })
604
+ setOpen(false)
605
+ return
606
+ }
607
+ openPopup()
608
+ }
609
+ const toggleRef = React.useRef(() => {})
610
+ toggleRef.current = toggleFromButton
611
+
612
+ // 输入框工具排按钮(设置开关):挂载后扫描官方工具排注入;官方工具排随布局
613
+ // 状态被 React 重建时经观察器补注,开关值异步到达(无 DOM 变更)经重扫对账。
614
+ // 启停读 ref(开关值挂载拉取后即时反映),停用/卸载按标记全量移除
615
+ const buttonScanRef = React.useRef(null)
616
+ useEffect(() => {
617
+ function removeAll() {
618
+ document.querySelectorAll('[' + HISTORY_BTN_FLAG + ']').forEach((button) => button.remove())
619
+ }
620
+ function sync() {
621
+ if (enabledRef.current === false || buttonEnabledRef.current === false) {
622
+ removeAll()
623
+ return
624
+ }
625
+ const row = findToolsRow(document)
626
+ if (row) ensureHistoryButton(document, row, () => toggleRef.current())
627
+ }
628
+ buttonScanRef.current = sync
629
+ const observer = new MutationObserver(sync)
630
+ observer.observe(document.documentElement, { childList: true, subtree: true })
631
+ sync()
632
+ return () => {
633
+ buttonScanRef.current = null
634
+ observer.disconnect()
635
+ removeAll()
636
+ }
637
+ }, [])
638
+ // 开关状态/浮层启停变化即时重扫(观察器只覆盖 DOM 变更,状态变化需主动对账)
639
+ useEffect(() => {
640
+ if (buttonScanRef.current) buttonScanRef.current()
641
+ }, [historyEnabled, buttonEnabled])
642
+
509
643
  // 零高度锚点:平时不占任何界面空间,仅浮层打开时渲染;设置停用整体不渲染
510
644
  if (session === undefined || inputActions === undefined || historyEnabled === false) return null
511
645
  const promptsIdx = HISTORY_SCOPES.indexOf('prompts')
@@ -560,7 +694,20 @@ function HistoryDock({ session, inputActions }) {
560
694
  return h('div', { className: 'cx-hist', ref: rootRef },
561
695
  open && h('div', { className: 'cx-hist__pop' },
562
696
  h('div', { className: 'cx-hist__hint' },
563
- h('span', { className: 'cx-hist__scope' }, HISTORY_SCOPE_LABELS[scopeIndex]),
697
+ h('span', { className: 'cx-hist__scopebar' },
698
+ scopeOpen
699
+ ? HISTORY_SCOPE_LABELS.map((label, idx) => h('button', {
700
+ key: label,
701
+ className: 'cx-hist__pill' + (idx === scopeIndex ? ' cx-hist__pill--on' : ''),
702
+ title: idx === scopeIndex ? '收起' : '切换到' + label,
703
+ onClick: () => (idx === scopeIndex ? setScopeOpen(false) : selectScope(idx)),
704
+ }, label))
705
+ : h('button', {
706
+ className: 'cx-hist__pill cx-hist__pill--on',
707
+ title: '点击选择范围',
708
+ onClick: () => setScopeOpen(true),
709
+ }, HISTORY_SCOPE_LABELS[scopeIndex] + ' ▾'),
710
+ ),
564
711
  inPrompts
565
712
  ? h('button', {
566
713
  className: 'cx-hist__editbtn',
@@ -717,6 +864,9 @@ const FORK_PAGE_MAX_MESSAGES = 2000
717
864
  // 重试草稿通道:分叉成功后子会话尚未挂载,原输入先按子会话 id 暂存,
718
865
  // 子会话的 dock 挂载(inputActions 就绪)时消费回填并清除
719
866
  const pendingForkDrafts = new Map()
867
+ // 已消费会话:effect 依赖(inputActions 身份)变化会重跑,重跑时草稿已被 delete,
868
+ // 若不做标记会落入「无草稿」路径并对输入框产生二次写入(空写即清空)
869
+ const consumedForkDrafts = new Set()
720
870
  // 草稿消费重查:覆盖 open 切换期间「挂载先于登记」的窗口(单次即可,上限留裕量)
721
871
  const FORK_DRAFT_POLL_MS = 400
722
872
  const FORK_DRAFT_POLLS = 3
@@ -832,7 +982,7 @@ function SteerRecallDock({ session, useSession, inputActions, updateQueue }) {
832
982
  // 轮号无映射 = 该轮未完成,首问无文本(纯图等)= 无法重试,均不注入;
833
983
  // 首轮无前锚(宿主 fork 边界必须落在 turn/end 上,复制零事件不可表达),也不注入。
834
984
  // 分叉动作经宿主 sessions 服务面 fork+open(与官方 chat 同构),成功 toast 并切换
835
- function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSession, submitPrompt, loadTurnEnds }) {
985
+ function ForkDockWithBootstrap({ session, inputActions, useInput, forkSession, cancelSession, submitPrompt, loadTurnEnds }) {
836
986
  // 依赖键 = 会话 id:session 快照身份随每次投影更新漂移,不能作 effect 依赖;
837
987
  // loadTurnEnds 闭包身份同样不稳定,经 ref 取用
838
988
  const sessionId = session && session.sessionId
@@ -842,15 +992,39 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
842
992
  const submitRef = useRef(null)
843
993
  submitRef.current = submitPrompt
844
994
  const [turnEnds, setTurnEnds] = useState(null)
995
+ // 映射基线:重拉结果与基线比对,相同则不 setState——setState 会触发重渲染并再次
996
+ // 触发扫描,新轮在映射中缺失时形成 拉取→setState→扫描→拉取 的无限循环
997
+ const turnEndsRef = useRef(null)
998
+ // 在途门控:MutationObserver 在流式输出期间高频触发,无门控会造成请求风暴
999
+ const refreshPendingRef = useRef(false)
1000
+ // 重拉入口装配到 ref 供扫描调用:新轮 turn/end 落在拉取窗口之后时映射里没有该轮,
1001
+ // 注入资格判空即跳过,按钮永不出现(实测:必须刷新页面才恢复)。门控保证同时在途
1002
+ // 最多一次;结果与基线相同则不 setState,避免 setState→扫描→拉取的循环
1003
+ const refreshRef = useRef(null)
1004
+ refreshRef.current = () => {
1005
+ if (refreshPendingRef.current) return
1006
+ refreshPendingRef.current = true
1007
+ Promise.resolve(loadRef.current())
1008
+ .then((map) => {
1009
+ refreshPendingRef.current = false
1010
+ if (turnEndsRef.current === map) return
1011
+ turnEndsRef.current = map
1012
+ setTurnEnds(map)
1013
+ })
1014
+ .catch(() => { refreshPendingRef.current = false })
1015
+ }
845
1016
  useEffect(() => {
846
1017
  if (sessionId === undefined) return undefined
847
1018
  let disposed = false
848
1019
  // 拉取前置空:会话切换后旧会话的映射不得继续服务新会话——轮号跨会话重叠,
849
1020
  // 旧 seq 会让分叉锚点错位;置空期间 ForkDock 以 null 短路,不注入按钮
850
1021
  setTurnEnds(null)
1022
+ turnEndsRef.current = null
851
1023
  loadRef.current()
852
1024
  .then((map) => {
853
- if (!disposed) setTurnEnds(map)
1025
+ if (disposed) return
1026
+ turnEndsRef.current = map
1027
+ setTurnEnds(map)
854
1028
  })
855
1029
  .catch((error) => {
856
1030
  // 映射拉取失败(网络/宿主繁忙):fork 无锚点可用,保持禁用不注入;
@@ -866,8 +1040,14 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
866
1040
  // 自动重发(开关开):草稿带 autoSubmit 标记,setDraft 后经 remote.session.prompt
867
1041
  // 立即提交原文(重生成语义);prompt 失败仅 toast——文本已在输入框,可手动发送
868
1042
  const [draftPollTick, setDraftPollTick] = useState(0)
1043
+ // 回填前探测输入框当前草稿:宿主输入快照(useInput)只在快照面可用,
1044
+ // 缺失时按空草稿处理(旧宿主降级为原行为)
1045
+ const liveDraft = typeof useInput === 'function' ? useInput((state) => state.draft) : ''
1046
+ const liveDraftRef = useRef('')
1047
+ liveDraftRef.current = typeof liveDraft === 'string' ? liveDraft : ''
869
1048
  useEffect(() => {
870
1049
  if (sessionId === undefined || !inputActions || typeof inputActions.setDraft !== 'function') return undefined
1050
+ if (consumedForkDrafts.has(sessionId)) return undefined
871
1051
  const draft = pendingForkDrafts.get(sessionId)
872
1052
  if (draft === undefined) {
873
1053
  if (draftPollTick >= FORK_DRAFT_POLLS) return undefined
@@ -875,20 +1055,34 @@ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSessi
875
1055
  return () => clearTimeout(timer)
876
1056
  }
877
1057
  pendingForkDrafts.delete(sessionId)
1058
+ consumedForkDrafts.add(sessionId)
878
1059
  const text = draft && typeof draft === 'object' ? draft.text : draft
879
1060
  const autoSubmit = Boolean(draft && typeof draft === 'object' && draft.autoSubmit === true)
880
- inputActions.setDraft(text)
1061
+ // 用户已开始输入:回填是破坏性整体替换(setDraft 清根重建),优先级低于用户输入;
1062
+ // 放弃回填并提示,避免吞掉用户在 dock 挂载窗口内的击键
1063
+ const draftOccupied = liveDraftRef.current.trim() !== ''
881
1064
  if (autoSubmit && submitRef.current) {
1065
+ // 提交即无草稿(官方发送语义):原文已发出,输入框不得残留,否则用户再发即重复消息;
1066
+ // 提交失败才回填文本,供手动重发
1067
+ if (draftOccupied) {
1068
+ toast('子会话已有输入,原输入未自动发送')
1069
+ return undefined
1070
+ }
882
1071
  Promise.resolve(submitRef.current({ sessionId, text })).catch((error) => {
883
- toast('自动重发失败,文本已在输入框可手动发送: ' + String(error && error.message || error), { kind: 'error' })
1072
+ if (typeof inputActions.setDraft === 'function') inputActions.setDraft(text)
1073
+ toast('自动重发失败,文本已回填输入框可手动发送: ' + String(error && error.message || error), { kind: 'error' })
884
1074
  })
1075
+ } else if (draftOccupied) {
1076
+ toast('子会话已有输入,原输入未回填')
1077
+ } else {
1078
+ inputActions.setDraft(text)
885
1079
  }
886
1080
  return undefined
887
1081
  }, [sessionId, inputActions, draftPollTick])
888
- return h(ForkDock, { session, forkSession, cancelSession, turnEnds })
1082
+ return h(ForkDock, { session, forkSession, cancelSession, turnEnds, refreshRef })
889
1083
  }
890
1084
 
891
- function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
1085
+ function ForkDock({ session, forkSession, cancelSession, turnEnds, refreshRef }) {
892
1086
  const [enabled, setEnabled] = useState(true)
893
1087
  const [autoResend, setAutoResend] = useState(false)
894
1088
  const turnEndsRef = useRef(null)
@@ -937,13 +1131,18 @@ function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
937
1131
  if (rawTurn === null || rawTurn.trim() === '') return
938
1132
  const turn = Number(rawTurn)
939
1133
  if (!Number.isInteger(turn)) return
940
- // 重试资格三查:映射缺失的轮(超窗口)不可分叉;首轮无前锚(宿主
1134
+ // 重试资格三查:映射缺失的轮(超窗口/新轮)不可分叉;首轮无前锚(宿主
941
1135
  // fork 边界必须落在 turn/end,复制零事件不可表达);无首问文本(纯图等)
942
1136
  // 回填无从谈起。open=true 的进行中轮 turn/end 未落账,同样可分叉——
943
1137
  // 锚点取其前一个闭合轮,该轮未完成的回复分叉后停掉(见点击处理器)。
944
1138
  // 按钮在场即暗示可用,禁用态文案无法区分成因,不注入即误导
945
1139
  const entry = turnEndsRef.current.get(turn)
946
- if (entry === undefined || entry.text === null) return
1140
+ if (entry === undefined) {
1141
+ // 新轮:映射是挂载时的一次快照,补拉后由 setState 触发重扫
1142
+ if (refreshRef.current) refreshRef.current()
1143
+ return
1144
+ }
1145
+ if (entry.text === null) return
947
1146
  let previous = null
948
1147
  for (const key of turnEndsRef.current.keys()) {
949
1148
  const candidate = turnEndsRef.current.get(key)
@@ -976,9 +1175,10 @@ function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
976
1175
  .then((childId) => {
977
1176
  // 暂存先于 toast:open 的子会话挂载可能紧随 resolve,回填承诺必须先就位;
978
1177
  // open 失败时暂存保留,用户手动打开子会话仍兑现;
979
- // 自动重发开关开:登记带标记草稿,子会话消费时回填后经 prompt 提交
1178
+ // 自动重发开关开:登记带标记草稿,子会话消费时直接提交(不留草稿)
980
1179
  if (typeof childId === 'string' && childId !== '') {
981
1180
  pendingForkDrafts.set(childId, { text: currentEntry.text, autoSubmit: autoResendOn })
1181
+ consumedForkDrafts.delete(childId)
982
1182
  }
983
1183
  // 进行中轮分叉后原会话该轮无人再读,停掉止损;失败不影响分叉成功事实
984
1184
  if (currentEntry.open === true && typeof cancelSessionFn === 'function') {
@@ -986,7 +1186,7 @@ function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
986
1186
  console.warn('[context-manager] 分叉后停止原会话进行中回复失败', error)
987
1187
  })
988
1188
  }
989
- toast('已分叉,原输入已填入子会话输入框')
1189
+ toast(autoResendOn ? '已分叉,原输入已自动发送到子会话' : '已分叉,原输入已填入子会话输入框')
990
1190
  return childId
991
1191
  })
992
1192
  .catch((error) => {
@@ -1016,7 +1216,8 @@ function ForkDock({ session, forkSession, cancelSession, turnEnds }) {
1016
1216
 
1017
1217
  // 面板设置项悬停说明:原生 title(设置侧栏为滚动容器,CSS 气泡会被 overflow
1018
1218
  // 裁剪,JS 定位复杂度不成比例);文案与功能行为同源维护,由源码契约测试锁定
1019
- const HISTORY_SWITCH_TITLE = '在输入框按 Alt+↑ 唤起历史输入浮层,浏览并回填历史输入;浮层内 ←/→ 切换范围(常用 / 当前会话 / 本工作区 / 全部工作区),顶部搜索框过滤条目,行悬停星标可收藏常用提示词。停用后快捷键与浮层整体关闭,刷新页面生效。'
1219
+ const HISTORY_SWITCH_TITLE = '在输入框按 Alt+↑ 唤起历史输入浮层,浏览并回填历史输入;浮层内 ←/→ 切换范围,点击顶栏范围标签展开选择(常用 / 当前会话 / 本工作区 / 全部工作区),顶部搜索框过滤条目,行悬停星标可收藏常用提示词。停用后快捷键与浮层整体关闭,刷新页面生效。'
1220
+ const HISTORY_BUTTON_SWITCH_TITLE = '输入框下方工具排显示历史输入按钮,点击打开浮层,再点关闭,与 Alt+↑ 等效;停用仅隐藏按钮,快捷键与浮层不受影响;刷新页面生效。'
1020
1221
  const STEER_SWITCH_TITLE = '插话发送后、尚未被智能体应用期间,在该插话气泡的操作图标排显示撤回按钮,点击撤回并把原文填回输入框(覆盖输入框现有草稿);含附件的插话不可撤回;消息被应用后按钮随气泡消失,恰在应用瞬间点击会提示已应用且不动草稿。停用即不再注入,刷新页面生效。'
1021
1222
  const FORK_SWITCH_TITLE = '消息气泡操作排显示分叉按钮,点击分叉出新会话到该轮之前(该轮不带入子会话),该轮的用户输入自动回填子会话输入框供编辑重发,子会话自动打开且标题尾号递增;进行中的轮(回复尚未完成)同样可分叉,分叉后自动停止本会话该轮未完成的回复;首轮(无更早上下文可继承,新建会话即为同义操作)与无文本输入的轮(纯图等,无从重发)不注入;停用即不再注入,刷新页面生效。'
1022
1223
  const FORK_AUTO_RESEND_SWITCH_TITLE = '分叉成功后自动把该轮原输入发送到子会话立即开跑(重生成语义,相当于原输入重跑一遍);关闭时分叉仅把原输入回填子会话输入框,由你编辑后再手动发送。'
@@ -1111,6 +1312,7 @@ function switchRow(url, label, title, okText) {
1111
1312
  }
1112
1313
 
1113
1314
  const HistorySwitchRow = switchRow(HISTORY_ENABLED_URL, '历史输入浮层(Alt+↑)', HISTORY_SWITCH_TITLE, '历史输入浮层')
1315
+ const HistoryButtonSwitchRow = switchRow(HISTORY_BUTTON_ENABLED_URL, '输入框历史按钮', HISTORY_BUTTON_SWITCH_TITLE, '输入框历史按钮')
1114
1316
  const SteerSwitchRow = switchRow(STEER_ENABLED_URL, '插话撤回', STEER_SWITCH_TITLE, '插话撤回')
1115
1317
  const ForkSwitchRow = switchRow(FORK_ENABLED_URL, '对话 fork', FORK_SWITCH_TITLE, '对话 fork')
1116
1318
  const ForkAutoResendSwitchRow = switchRow(FORK_AUTO_RESEND_URL, '分叉后自动重发', FORK_AUTO_RESEND_SWITCH_TITLE, '分叉后自动重发')
@@ -1135,6 +1337,7 @@ function ContextPanel() {
1135
1337
  open && h('div', { className: 'cx-card__body' },
1136
1338
  h('span', { className: 'cx-panel__hint' }, '变更刷新页面生效。'),
1137
1339
  h(HistorySwitchRow),
1340
+ h(HistoryButtonSwitchRow),
1138
1341
  h(SteerSwitchRow),
1139
1342
  h(ForkSwitchRow),
1140
1343
  h(ForkAutoResendSwitchRow),
@@ -1193,15 +1396,24 @@ function ContextPanel() {
1193
1396
  }, 'context-manager styles')
1194
1397
 
1195
1398
  // 设置卡片:注册进官方「插件」设置页的可配置插件区(keyed slot,
1196
- // key = 本插件 settings namespace),不占独立设置导航项
1399
+ // key = 本插件 settings namespace),不占独立设置导航项。
1400
+ // 0.1.7-rc.1+ 该槽被宿主移除,卡片换形注册进插件管理页包详情配置槽
1401
+ // (plugins.bundle.config,key = 包名,管理页 ledger.bundles 判定在场);
1402
+ // 两代槽位并存注册:inject 是声明生命周期效应,错误世代宿主永不声明
1403
+ // 该槽,回调挂起不执行,零成本
1197
1404
  ctx.slots.inject('settings.plugin.item', () =>
1198
1405
  ctx.slots.register(
1199
1406
  { name: 'settings.plugin.item', id: 'context', key: 'context' },
1200
1407
  () => React.createElement(ContextPanel),
1201
1408
  ))
1409
+ ctx.slots.inject('plugins.bundle.config', () =>
1410
+ ctx.slots.register(
1411
+ { name: 'plugins.bundle.config', key: PKG_ID },
1412
+ () => React.createElement(ContextPanel),
1413
+ ))
1202
1414
 
1203
1415
  // 历史输入回溯入口:官方 conversation.input.dock 插槽(dsh-client-ui-goal 同构)。
1204
- // 宿主缺该插槽时注册抛错即禁用本功能,不阻塞其余能力
1416
+ // 宿主缺该插槽时 inject 挂起不注册(声明生命周期效应),本功能静默缺席
1205
1417
  try {
1206
1418
  ctx.slots.inject('conversation.input.dock', () =>
1207
1419
  ctx.slots.register(
package/src/core.mjs CHANGED
@@ -17,6 +17,9 @@ export const HISTORY_INPUT_LIMIT = 200
17
17
  export const HISTORY_INPUT_MAX_CHARS = 20 * 1000
18
18
  export const HISTORY_SCOPES = ['prompts', 'session', 'workspace', 'global']
19
19
  export const HISTORY_PROMPTS_MAX = 100
20
+ // 提取器输出格式版本:升级即令全部 extracts 指纹缓存失效重解压(自愈补齐新格式条目);
21
+ // 已知边界——超 MAX_ARTIFACT 的巨产物会话对齐时直接跳过,其旧版条目不随升级补齐
22
+ export const HISTORY_EXTRACT_VERSION = 2
20
23
  export const HISTORY_ALIGN_THROTTLE_MS = 30 * 1000
21
24
  export const HISTORY_STARTUP_DELAY_MS = 30 * 1000
22
25
  export const HISTORY_STARTUP_SCAN_LIMIT = 100
@@ -53,15 +56,24 @@ export function isSessionRunning({ agents, sessionId }) {
53
56
  }
54
57
 
55
58
  /**
56
- * 从会话事件流提取人类输入:仅 user/message 且来源为用户本人,
57
- * 文本块按行拼接;工具结果回填、插件注入与空白输入(纯图/空/纯空白)均不产出。
59
+ * 从会话事件流提取人类输入:user/message(来源为用户本人,文本块按行拼接)与
60
+ * command/run(斜杠命令,重组为 /name+args;args 是命令名后 verbatim rawInput,
61
+ * 含分隔空白,recordInput: false 的命令无 args 产出裸命令名)。
62
+ * 工具结果回填、插件注入与空白输入(纯图/空/纯空白)均不产出。
58
63
  * @param events - readSession 返回的原始事件数组
59
64
  * @returns 条目 { text, at },时间取事件时间
60
65
  */
61
66
  export function extractUserInputs(events) {
62
67
  const entries = []
63
68
  for (const event of events || []) {
64
- if (!event || event.type !== 'user/message') continue
69
+ if (!event) continue
70
+ if (event.type === 'command/run') {
71
+ const data = event.data
72
+ if (!data || !data.source || data.source.kind !== 'user' || typeof data.name !== 'string' || data.name === '') continue
73
+ entries.push({ text: '/' + data.name + (typeof data.args === 'string' ? data.args : ''), at: event.time })
74
+ continue
75
+ }
76
+ if (event.type !== 'user/message') continue
65
77
  const message = event.data
66
78
  if (!message || !message.source || message.source.kind !== 'user') continue
67
79
  const text = (message.content || [])