@mzzsfy/dsh-session-manager 0.7.1 → 0.8.1

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,6 +1,6 @@
1
1
  # @mzzsfy/dsh-session-manager
2
2
 
3
- DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 + 归档删除 + 回收站还原重挂载 + 历史输入浮层。
3
+ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 + 归档删除 + 回收站还原重挂载 + 历史输入浮层 + 插话撤回。
4
4
 
5
5
  ## 功能
6
6
 
@@ -11,6 +11,7 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
11
11
  - **上限与首次对齐**:全部历史最多保留最新 200 条(`HISTORY_INPUT_LIMIT`)、单条截 2 万字符(`HISTORY_INPUT_MAX_CHARS`),聚合精确文本去重保留最新时间;**每会话首条保护**:超出上限被挤出时,每个来源会话在去重后集合中的最早条目仍保留(会话开场提示词最旧也最有复用价值,时间倒序截断总先挤掉它们;保护在去重后集合上计算,首条文本已被更新的会话复用时不再按旧会话重复追加;无 sid 的收藏条目不触发)。首次(该工作区尚无缓存文件)`aligned=false`,client 显示「首次对齐历史中」提示并每 3 秒(`HISTORY_REPULL_MS`)静默重拉;单会话产物读取失败仅告警跳过;scope 非法或缺省回退 `session`。
12
12
  - 常用提示词收藏:`prompts` 以外的范围,历史行鼠标悬停时行尾出现收藏星标(已收藏实星、未收藏空心),点击即收藏/取消(经 `POST /api/session-manager/prompts/toggle`),toast 反馈并同步星标;`prompts` 范围右上角「编辑」按钮进入编辑态:行内文本框直接改(Enter/失焦保存,实现为移除旧文本 + 收藏新文本)、行尾 `×` 删除、底部输入框 Enter 新增,「完成」退出;上限 `HISTORY_PROMPTS_MAX` 条,超出裁掉最旧;收藏与自动对齐的历史缓存互不覆写(工作区缓存列举跳过 prompts.json);global 范围读取走 stat 指纹缓存(`listWorkspaceCachesCached`),产物未变的文件不重复读盘;client 与 core 的 `HISTORY_SCOPES` 镜像由 parity 测试锁定。
13
13
  - 历史浮层启停开关:低频功能,归档面板末行小尺寸开关(规约 switch 形态 `sm-histsw`,状态选择器锚定 `input[type=checkbox]`);值存宿主 settings(`historyEnabled`,默认启用,原生设置页与本面板同存储即时同值),读写经本插件 `GET/POST /api/session-manager/history-enabled`(client 写 settings 不经宿主 remotes,零版本脆弱依赖);浮层挂载拉取一次,停用即锚点/快捷键/浮层整体不渲染、键盘监听放行,变更刷新页面生效。
14
+ - 插话撤回:插话发送(steer)后、消息尚未被智能体应用(宿主队列快照中仍存在 `placement=steering` 条目,即 Inbox next-step 等待 step 边界 claim)期间,撤回图标**直接出现在该插话气泡的原生操作图标排**(复制图标旁,与其同排同款):经 MutationObserver 定位官方 pending steering 气泡的语义标记 `data-pending-steering` 及其图标排(CSS module 类名后缀 `_actions`),注入 undo 弯箭头按钮并**克隆官方按钮 className**——尺寸/hover/悬停显隐(reveal)全部原生;气泡被应用后整棵卸载,注入按钮随之消失。dock 插槽本体 `display:none` 零占位,仅承载观察与注入;官方 DOM 结构漂移时注入静默跳过(不报错,干净禁用精神);点击经官方 `ISession.updateQueue(itemId, {kind:'remove'})` 摘除(对 next-step 同样有效,无运行状态限制——智能体停止后残留的未送达插话同样可撤),成功才回填输入框草稿(`inputActions.setDraft` 整体替换,与历史浮层回填同语义,**会覆盖输入框中未发送的既有草稿**)并 toast 提示;回填自身失败(会话切换等竞态)时经 toast 兜底展示原文;消息已被应用(竞态窗口内被 claim,宿主返回 `session/queue-item-not-found`)时专用文案报错且不动草稿。含附件的插话(text 为 null)图标禁用并提示(与官方队列编辑同为纯文本限制,撤回丢附件);subagent 非 continuable 会话不注入(与官方队列面板 queueMutable 同规);RPC 走会话绑定面 `binding.session.updateQueue`(RemoteResult 不抛错,与官方 conversation 服务零耦合),会话绑定缺失或会话面无 `updateQueue`(旧宿主)时返回空 props 整体禁用。归档面板提供启停开关(`steerRecallEnabled`,默认启用,开关行在历史浮层开关之下,复用同款 switch 形态),读写经本插件 `GET/POST /api/session-manager/steer-recall-enabled`,停用即不注入并移除已注入按钮,变更刷新页面生效。
14
15
  - 自动归档:host 半区对超期会话走官方 `workspace.archiveSession` 通道,三个触发源共用同一评估与门闩:新会话创建(`session/created`,按该会话所属工作区限定)、settings 就绪后的启动补扫(全量,清掉停机期间积压)、每日周期轮(全量,经宿主 timer 服务固定短 tick + 到期判断)。幂等:已归档会话不参与评估。阈值 `autoArchiveDays` 默认 7,`0` 关闭;周期间隔 `autoArchiveIntervalHours` 默认 24,`0` 关闭周期轮,变更经周期 tick 对账(关闭即时暂停、重启用最迟下个 tick 生效、缩短经锚定钳制前移,最长等新间隔而非等满旧周期)。评估筛选:createdAt 纯内存预筛 + 已归档跳过 + stat 后活跃度判定,locate 缺失(第三方后端)与产物不可读的会话一律跳过,宁可漏归档不可误归档。经 settings 命名空间 `session-manager` 注册(schema 拒绝负数与非整数)。timer 为软依赖:宿主定时服务不可用时仅周期轮停用,面板顶部提示,插件其余能力不受影响。
15
16
  - 自动归档配置:归档面板「已删除」区下方的配置行,阈值天数与检查周期小时数两个数字输入,失焦或 Enter 单字段即时提交,非法输入前端拦截不发请求,服务端失败回读生效值回滚;读写经本插件 `GET/POST /api/session-manager/auto-archive`(POST 部分更新,仅接受出现且为非负整数的字段,空字段拒绝),值存宿主 settings 与评估逻辑同源,`0` = 关闭,GET 返回含默认回退的当前生效值。
16
17
  - 归档面板:设置页「会话归档」分区,数据为 client 侧 `session.list` 行 ∩ `workspaces.follow` 归档快照的交集,按更新时间倒序;主列表只展开最近 `ARCHIVE_PAGE_SIZE`(100)条,列表尾部「按工作区浏览」按钮直接进入二级视图查看全部(主列表不翻页,防归档集合无限增长拖垮渲染)。二级视图:无搜索词时按工作区分组(组间按组内最新活跃倒序,分组默认收起,展开即见组内首页并可继续翻页,`workspace` 假值并入「未分组」组);输入搜索词后平铺展示标题命中(不区分大小写子串,标题缺失回退会话 id 天然可搜)的全部归档会话并显示匹配计数,同样分页。行内标题前显示所属工作区徽标——映射优先走 workspace 账本(`sessionIds`,与官方 dsh-client-ui-workspace 的 workspaceBySession 同构),cwd 命中某登记工作区路径时回退该工作区标题(较官方「cwd basename 直接回退」收紧,防未登记目录名冒充工作区),均未命中显示「未分组」;工作区 title 缺失回退路径末段。镜像函数 client 与 core 双实现由 parity 测试锁定。行内支持取消归档与删除。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-session-manager",
3
3
  "description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
4
- "version": "0.7.1",
4
+ "version": "0.8.1",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -14,11 +14,11 @@ window.__ModuleLoader__.load({
14
14
  id: '@mzzsfy/dsh-session-manager',
15
15
  factory(require) {
16
16
  const React = require('react')
17
- const { useState, useEffect, useSyncExternalStore } = React
17
+ const { useState, useEffect, useRef, useSyncExternalStore } = React
18
18
 
19
- // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染(本插件分区 → archive);
20
- // 该插件未就绪时入队,由其启动时排空
21
- const NAV_ICON = { '会话归档': 'archive' }
19
+ // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;双键 = 分区 label + 市场
20
+ // 短名(发现页收录显示形态);该插件未就绪时入队,由其启动时排空
21
+ const NAV_ICON = { '会话归档': 'archive', 'dsh-session-manager': 'archive' }
22
22
  if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
23
23
  else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
24
24
  else window.__navicIconQueue = [NAV_ICON]
@@ -173,6 +173,9 @@ const CSS = [
173
173
  '.sm-hist__banner { padding:7px 14px; flex:none; text-align:center; font:var(--dsw-font-xxs-12, 12px/18px sans-serif);',
174
174
  ' color:light-dark(rgba(15,17,21,.55), rgba(232,234,237,.55));',
175
175
  ' background:light-dark(rgba(15,17,21,.04), rgba(255,255,255,.06)); }',
176
+ // 插话撤回图标:注入原生 pending steering 气泡的操作图标排,样式经克隆官方
177
+ // 按钮类名原生同款;dock 本体零 DOM,仅承载注入逻辑
178
+ '.sm-steer-host { display:none; }',
176
179
  ].join('\n')
177
180
 
178
181
  const UNARCHIVE_URL = '/api/session-manager/unarchive'
@@ -649,6 +652,7 @@ function SessionManagerApp(props) {
649
652
  }),
650
653
  h(AutoArchiveConfig, null),
651
654
  h(HistorySwitchRow, null),
655
+ h(SteerSwitchRow, null),
652
656
  )
653
657
  }
654
658
 
@@ -682,6 +686,34 @@ function HistorySwitchRow() {
682
686
  )
683
687
  }
684
688
 
689
+ // 插话撤回启停开关:与历史浮层开关同构,复用同款 switch 形态与样式(同前缀);
690
+ // 值存宿主 settings,切换经本插件路由中转,变更刷新页面生效
691
+ function SteerSwitchRow() {
692
+ const [enabled, setEnabled] = useState(null)
693
+ useEffect(() => {
694
+ api(STEER_ENABLED_URL)
695
+ .then((payload) => setEnabled(payload ? payload.enabled !== false : true))
696
+ .catch(() => setEnabled(true))
697
+ }, [])
698
+ const flip = (event) => {
699
+ const next = event.target.checked
700
+ setEnabled(next)
701
+ api(STEER_ENABLED_URL, { method: 'POST', body: JSON.stringify({ enabled: next }) })
702
+ .then((payload) => {
703
+ toast('插话撤回已' + (payload && payload.enabled !== false ? '启用' : '停用') + ',刷新页面后生效')
704
+ })
705
+ .catch(() => {
706
+ setEnabled(!next)
707
+ toast('切换失败', { kind: 'error' })
708
+ })
709
+ }
710
+ return h('label', { className: 'sm-histsw' },
711
+ h('input', { type: 'checkbox', checked: enabled !== false, onChange: flip }),
712
+ h('span', { className: 'sm-histsw__track' }, h('span', { className: 'sm-histsw__thumb' })),
713
+ h('span', { className: 'sm-histsw__label', onClick: (event) => event.preventDefault() }, '插话撤回图标'),
714
+ )
715
+ }
716
+
685
717
  // 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
686
718
  // 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
687
719
  // 服务端失败后回读生效值回滚显示
@@ -776,6 +808,7 @@ const HISTORY_SCOPE_DEFAULT = HISTORY_SCOPES.indexOf('session')
776
808
  const HISTORY_REPULL_MS = 3 * 1000
777
809
  const PROMPTS_TOGGLE_URL = '/api/session-manager/prompts/toggle'
778
810
  const HISTORY_ENABLED_URL = '/api/session-manager/history-enabled'
811
+ const STEER_ENABLED_URL = '/api/session-manager/steer-recall-enabled'
779
812
 
780
813
  function HistoryDock({ session, inputActions }) {
781
814
  const [open, setOpen] = useState(false)
@@ -1097,6 +1130,158 @@ function HistoryDock({ session, inputActions }) {
1097
1130
  )
1098
1131
  }
1099
1132
 
1133
+ // 插话撤回投影:仅用户插话(宿主队列 placement=steering,已发出但未被 step 边界
1134
+ // claim 应用);queued 由官方队列面板呈现,context 为注入上下文非用户消息
1135
+ function steerRowsOf(queue) {
1136
+ const rows = Array.isArray(queue) ? queue : []
1137
+ return rows.filter((row) => row && row.placement === 'steering')
1138
+ }
1139
+
1140
+ // 撤回失败文案:queue-item-not-found = 消息已被 step 边界 claim(应用),无法撤回;
1141
+ // 其余透传宿主错误消息
1142
+ function withdrawFailureText(result) {
1143
+ const error = result && result.error
1144
+ if (error && error.code === 'session/queue-item-not-found') return '插话已被应用,无法撤回'
1145
+ return '撤回失败: ' + ((error && error.message) || '未知错误')
1146
+ }
1147
+
1148
+ // 撤回动作:先宿主移除(inbox next-step 摘除,未应用即消失),成功才回填草稿,
1149
+ // 顺序保证「移除失败绝不覆盖输入框」;actions 注入便于测试与组件解耦
1150
+ async function withdrawSteer(row, actions) {
1151
+ let result
1152
+ try {
1153
+ result = await actions.updateQueue(row.id, { kind: 'remove' })
1154
+ } catch (error) {
1155
+ actions.notify('撤回失败: ' + String(error && error.message || error), { kind: 'error' })
1156
+ return false
1157
+ }
1158
+ if (!(result && result.ok)) {
1159
+ actions.notify(withdrawFailureText(result), { kind: 'error' })
1160
+ return false
1161
+ }
1162
+ try {
1163
+ actions.setDraft(row.text)
1164
+ } catch (error) {
1165
+ // 摘除已成功,草稿回填失败(会话切换等竞态)时文本必须可达:通知兜底展示原文
1166
+ actions.notify('草稿回填失败,原文: ' + row.text, { kind: 'error' })
1167
+ return false
1168
+ }
1169
+ actions.notify('已撤回到输入框')
1170
+ return true
1171
+ }
1172
+
1173
+ // 撤回图标 svg(undo 弯箭头,stroke 继承 currentColor,尺寸由所克隆的官方按钮类控制)
1174
+ function steerRecallSvg() {
1175
+ const namespace = 'http://www.w3.org/2000/svg'
1176
+ const svg = document.createElementNS(namespace, 'svg')
1177
+ svg.setAttribute('viewBox', '0 0 16 16')
1178
+ svg.setAttribute('fill', 'none')
1179
+ svg.setAttribute('stroke', 'currentColor')
1180
+ svg.setAttribute('stroke-width', '1.5')
1181
+ svg.setAttribute('stroke-linecap', 'round')
1182
+ svg.setAttribute('stroke-linejoin', 'round')
1183
+ svg.setAttribute('aria-hidden', 'true')
1184
+ const paths = ['M3 6.5h6.5a3.5 3.5 0 0 1 0 7H6', 'M6 3.5 3 6.5l3 3']
1185
+ for (const d of paths) {
1186
+ const path = document.createElementNS(namespace, 'path')
1187
+ path.setAttribute('d', d)
1188
+ svg.appendChild(path)
1189
+ }
1190
+ return svg
1191
+ }
1192
+
1193
+ // 注入按钮标记:识别自家节点与官方按钮,防止重复注入与误删官方节点
1194
+ const STEER_BTN_FLAG = 'data-sm-steer-recall'
1195
+ // 官方 pending steering 气泡的语义标记(官方同构:UserStyleBubble data 属性)与
1196
+ // 其操作图标排的 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定)
1197
+ const STEER_BUBBLE_SELECTOR = '[data-pending-steering]'
1198
+ const STEER_ACTIONS_SUFFIX = '[class$="_actions"]'
1199
+
1200
+ // 插话撤回:注入原生 pending steering 气泡的操作图标排(复制图标旁),样式经克隆
1201
+ // 官方按钮 className 完全原生;气泡被应用后整棵卸载,注入按钮随之消失。
1202
+ // dock 本体 display:none 零占位,仅承载观察与注入;官方 DOM 结构漂移时注入静默
1203
+ // 跳过(不报错),干净禁用精神
1204
+ function SteerRecallDock({ session, useSession, inputActions, updateQueue }) {
1205
+ const queue = useSession((state) => state.queue)
1206
+ const queueMutable = useSession((state) => state.subagent === null || state.subagent.address.mode === 'continuable')
1207
+ const [busy, setBusy] = useState(false)
1208
+ // 启停开关(设置页):挂载拉取一次,停用即不注入并移除已注入按钮;失败按启用兜底
1209
+ const [enabled, setEnabled] = useState(true)
1210
+ useEffect(() => {
1211
+ api(STEER_ENABLED_URL)
1212
+ .then((payload) => setEnabled(payload ? payload.enabled !== false : true))
1213
+ .catch(() => {})
1214
+ }, [])
1215
+ // 观察回调经由 ref 读最新状态,观察器只挂一次不随渲染重挂
1216
+ const rowsRef = useRef([])
1217
+ const mutableRef = useRef(true)
1218
+ const busyRef = useRef(false)
1219
+ const actionsRef = useRef(null)
1220
+ rowsRef.current = steerRowsOf(queue)
1221
+ mutableRef.current = queueMutable
1222
+ busyRef.current = busy
1223
+ actionsRef.current = { updateQueue, setDraft: inputActions ? inputActions.setDraft : undefined }
1224
+ const scanRef = useRef(null)
1225
+ useEffect(() => {
1226
+ function withdraw(row) {
1227
+ const actions = actionsRef.current
1228
+ if (busyRef.current || !actions || typeof actions.updateQueue !== 'function') return
1229
+ setBusy(true)
1230
+ void withdrawSteer(row, { updateQueue: actions.updateQueue, setDraft: actions.setDraft, notify: toast })
1231
+ .finally(() => setBusy(false))
1232
+ }
1233
+ function syncButton(button, row) {
1234
+ const textOnly = row.text !== null
1235
+ button.disabled = busyRef.current || !textOnly
1236
+ button.title = textOnly ? '撤回到输入框: ' + row.preview : '含附件的插话不支持撤回'
1237
+ }
1238
+ function scan() {
1239
+ const bubbles = document.querySelectorAll(STEER_BUBBLE_SELECTOR)
1240
+ const rows = mutableRef.current ? rowsRef.current : []
1241
+ bubbles.forEach((bubble, index) => {
1242
+ const actionsRow = bubble.querySelector(STEER_ACTIONS_SUFFIX)
1243
+ if (!actionsRow) return
1244
+ let button = actionsRow.querySelector('[' + STEER_BTN_FLAG + ']')
1245
+ const row = rows[index]
1246
+ if (!row) {
1247
+ // 气泡尚存但行已被应用/撤回(帧差瞬态):按钮随气泡卸载,先禁用防误触
1248
+ if (button) button.disabled = true
1249
+ return
1250
+ }
1251
+ if (!button) {
1252
+ const official = actionsRow.querySelector('button:not([' + STEER_BTN_FLAG + '])')
1253
+ if (!official) return
1254
+ button = document.createElement('button')
1255
+ button.type = 'button'
1256
+ button.setAttribute(STEER_BTN_FLAG, '')
1257
+ // 克隆官方按钮类名:尺寸/hover/悬停显隐(reveal)全部原生
1258
+ button.className = official.className
1259
+ button.addEventListener('click', () => withdraw(row))
1260
+ button.appendChild(steerRecallSvg())
1261
+ actionsRow.appendChild(button)
1262
+ }
1263
+ syncButton(button, row)
1264
+ })
1265
+ }
1266
+ scanRef.current = scan
1267
+ const observer = new MutationObserver(scan)
1268
+ observer.observe(document.documentElement, { childList: true, subtree: true })
1269
+ scan()
1270
+ return () => {
1271
+ scanRef.current = null
1272
+ observer.disconnect()
1273
+ document.querySelectorAll('[' + STEER_BTN_FLAG + ']').forEach((button) => button.remove())
1274
+ }
1275
+ }, [])
1276
+ // 行集/开关/忙碌变化即时重扫(观察器只覆盖 DOM 变更,状态变化需主动对账)
1277
+ useEffect(() => {
1278
+ if (scanRef.current) scanRef.current()
1279
+ }, [queue, enabled, busy, queueMutable])
1280
+ if (session === undefined || inputActions === undefined || updateQueue === undefined) return null
1281
+ if (!enabled || !queueMutable) return null
1282
+ return h('div', { className: 'sm-steer-host' })
1283
+ }
1284
+
1100
1285
  return {
1101
1286
  inject: ['slots', 'sessions', 'workspaces'],
1102
1287
  apply(ctx) {
@@ -1144,6 +1329,29 @@ function HistoryDock({ session, inputActions }) {
1144
1329
  console.warn('[session-manager] 历史输入入口未注册(宿主无 conversation.input.dock 插槽)', error)
1145
1330
  }
1146
1331
 
1332
+ // 插话撤回入口:未应用的插话撤回到输入框重新编辑。RPC 走会话绑定面
1333
+ // binding.session.updateQueue(RemoteResult 不抛错,与官方 conversation 服务
1334
+ // 无耦合);会话面缺失或无 updateQueue(旧宿主)返回空 props,组件不渲染
1335
+ try {
1336
+ ctx.slots.inject('conversation.input.dock', () =>
1337
+ ctx.slots.register(
1338
+ {
1339
+ name: 'conversation.input.dock',
1340
+ id: 'session-manager-steer',
1341
+ order: 25,
1342
+ inject: (sessionId) => {
1343
+ const binding = sessions.binding(sessionId)
1344
+ const updateQueue = binding && binding.session && binding.session.updateQueue
1345
+ if (typeof updateQueue !== 'function') return {}
1346
+ return { updateQueue: (itemId, action) => binding.session.updateQueue(itemId, action) }
1347
+ },
1348
+ },
1349
+ SteerRecallDock,
1350
+ ))
1351
+ } catch (error) {
1352
+ console.warn('[session-manager] 插话撤回入口未注册(宿主无 conversation.input.dock 插槽)', error)
1353
+ }
1354
+
1147
1355
  ctx.effect(() => unsubscribe, 'session-manager archived diff')
1148
1356
 
1149
1357
  function SessionManagerPanel({ sessions: sessionSvc, workspaces: workspaceSvc }) {
package/src/index.js CHANGED
@@ -111,6 +111,8 @@ const SETTINGS_SCHEMA = schemastery.object({
111
111
  .description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
112
112
  historyEnabled: schemastery.boolean().default(true)
113
113
  .description('历史输入浮层(Alt+↑),低频功能,关闭后输入框锚点与快捷键整体不渲染;变更刷新页面生效'),
114
+ steerRecallEnabled: schemastery.boolean().default(true)
115
+ .description('插话撤回图标(输入框上方),低频功能,关闭后撤回图标整体不渲染;变更刷新页面生效'),
114
116
  })
115
117
 
116
118
  function sendJson(res, status, payload) {
@@ -138,6 +140,7 @@ function readSettings(ctx) {
138
140
  days: Number.isInteger(days) && days >= 0 ? days : DEFAULT_AUTO_ARCHIVE_DAYS,
139
141
  intervalHours: Number.isInteger(hours) && hours >= 0 ? hours : DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
140
142
  historyEnabled: !value || value.historyEnabled !== false,
143
+ steerRecallEnabled: !value || value.steerRecallEnabled !== false,
141
144
  }
142
145
  }
143
146
 
@@ -914,6 +917,32 @@ export function apply(ctx, config) {
914
917
  }
915
918
  },
916
919
  },
920
+ {
921
+ // 插话撤回启停:与历史浮层开关同构,读走 settings,写经 settings.update
922
+ path: '/api/session-manager/steer-recall-enabled',
923
+ handler: async (req, res) => {
924
+ try {
925
+ if (req.method === 'GET') {
926
+ sendJson(res, 200, { enabled: readSettings(ctx).steerRecallEnabled })
927
+ return
928
+ }
929
+ if (!rejectMethod(req, res, 'POST')) return
930
+ let body
931
+ try {
932
+ body = JSON.parse(await readBody(req))
933
+ } catch {
934
+ throw new Error(MESSAGES.badJsonBody)
935
+ }
936
+ const settings = ctx.get('settings')
937
+ if (!settings) throw new Error('宿主设置服务不可用')
938
+ // update 异步落盘后才提交新值:await 保证持久化完成后再读回
939
+ await settings.update(NAMESPACE, { steerRecallEnabled: Boolean(body && body.enabled) })
940
+ sendJson(res, 200, { enabled: readSettings(ctx).steerRecallEnabled })
941
+ } catch (error) {
942
+ respondError(ctx, res, error)
943
+ }
944
+ },
945
+ },
917
946
  {
918
947
  // 自动归档配置:读回生效值(含默认回退),写经 settings.update 部分更新;
919
948
  // 0 = 关闭(阈值天数关自动归档,间隔小时数关周期轮),与评估逻辑同源语义
@@ -0,0 +1,266 @@
1
+ // 插话撤回(steer recall)接线与纯函数测试:加载真实 src/client.js,
2
+ // 捕获 slots.register 断言 dock 条目结构,切片实例化纯函数锁定撤回动作与失败文案。
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { readFileSync } from 'node:fs'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { dirname, join } from 'node:path'
8
+
9
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
10
+ const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
11
+
12
+ // 加载 client.js 并执行 apply(mock 最小服务面),返回插槽注册捕获。
13
+ // slots.inject 收到注册回调即同步执行;binding 桩仅对会话 s1 生效
14
+ function loadClient({ binding } = {}) {
15
+ const modules = []
16
+ const registered = []
17
+ const injectedNames = []
18
+ const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }
19
+ const reactStub = {
20
+ useState: (value) => [value, () => {}],
21
+ useEffect: () => {},
22
+ useSyncExternalStore: () => [],
23
+ useRef: (value) => ({ current: value }),
24
+ createElement: () => null,
25
+ }
26
+ const requireStub = (name) => {
27
+ if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
28
+ return reactStub
29
+ }
30
+ const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
31
+ factory(
32
+ windowStub,
33
+ requireStub,
34
+ { createElement: () => ({ style: {}, remove() {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } },
35
+ )
36
+ assert.equal(modules.length, 1, 'client.js 模块未被捕获')
37
+ const mod = modules[0].factory(requireStub)
38
+
39
+ const sessions = {
40
+ binding: (sessionId) => (sessionId === 's1' ? binding : undefined),
41
+ list: { subscribe: () => () => {}, getSnapshot: () => ({ ids: [], byId: {} }) },
42
+ }
43
+ const workspaces = {
44
+ list: { subscribe: () => () => {}, getSnapshot: () => undefined },
45
+ }
46
+ const ctx = {
47
+ get: (name) => (name === 'sessions' ? sessions : name === 'workspaces' ? workspaces : {}),
48
+ effect: () => {},
49
+ slots: {
50
+ inject: (name, register) => { injectedNames.push(name); register() },
51
+ register: (options, component) => registered.push({ options, component }),
52
+ },
53
+ }
54
+ mod.apply(ctx)
55
+ return { registered, injectedNames }
56
+ }
57
+
58
+ // 插话撤回 dock 条目捕获:与官方 queue dock(order 20)、goal dock(order 10)同槽位
59
+ function findSteerEntry(options) {
60
+ const { registered, injectedNames } = loadClient(options)
61
+ assert.ok(injectedNames.includes('conversation.input.dock'), 'apply 未注入 conversation.input.dock 插槽')
62
+ const entries = registered.filter((item) => item.options.name === 'conversation.input.dock')
63
+ const entry = entries.find((item) => item.options.id === 'session-manager-steer')
64
+ assert.ok(entry, '未注册 id=session-manager-steer 的插话撤回 dock 条目')
65
+ return { entry, entries }
66
+ }
67
+
68
+ test('Given client.js 装载, When apply, Then 注册插话撤回 dock 条目且历史浮层条目保留', () => {
69
+ const { entry, entries } = findSteerEntry()
70
+ assert.equal(typeof entry.component, 'function', 'dock 条目缺组件')
71
+ assert.equal(entry.options.order, 25, '插话撤回条目 order 应紧邻官方队列面板(20)之后')
72
+ assert.ok(entries.some((item) => item.options.id === 'session-manager-history'), '历史浮层条目被挤掉')
73
+ })
74
+
75
+ test('Given 会话绑定存在, When dock inject, Then updateQueue 路由到 binding.session.updateQueue', async () => {
76
+ const calls = []
77
+ const binding = {
78
+ session: {
79
+ updateQueue: async (itemId, action) => {
80
+ calls.push({ itemId, action })
81
+ return { ok: true }
82
+ },
83
+ },
84
+ }
85
+ const { entry } = findSteerEntry({ binding })
86
+ const props = entry.options.inject('s1')
87
+ assert.equal(typeof props.updateQueue, 'function', 'inject 未返回 updateQueue')
88
+ const result = await props.updateQueue('m1', { kind: 'remove' })
89
+ assert.deepEqual(calls, [{ itemId: 'm1', action: { kind: 'remove' } }], 'updateQueue 未按 remove 动作透传')
90
+ assert.deepEqual(result, { ok: true })
91
+ })
92
+
93
+ test('Given 会话绑定缺失, When dock inject, Then 返回空 props(干净禁用)', () => {
94
+ const { entry } = findSteerEntry()
95
+ assert.deepEqual(entry.options.inject('s1'), {}, '绑定缺失应返回空 props')
96
+ assert.deepEqual(entry.options.inject('missing'), {}, '未知会话应返回空 props')
97
+ })
98
+
99
+ test('Given 会话面无 updateQueue(旧宿主), When dock inject, Then 返回空 props(干净禁用)', () => {
100
+ const { entry } = findSteerEntry({ binding: { session: {} } })
101
+ assert.deepEqual(entry.options.inject('s1'), {}, '会话面无 updateQueue 应返回空 props')
102
+ })
103
+
104
+ // 纯函数切片:steerRowsOf / withdrawFailureText / withdrawSteer(无外部依赖,动作经注入)
105
+ const PURE_START = CLIENT_SRC.indexOf('function steerRowsOf(')
106
+ const PURE_END = CLIENT_SRC.indexOf('function SteerRecallDock(')
107
+ assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js 插话撤回纯函数切片定位失败')
108
+ const pure = new Function(
109
+ CLIENT_SRC.slice(PURE_START, PURE_END)
110
+ + '; return { steerRowsOf: steerRowsOf, withdrawFailureText: withdrawFailureText, withdrawSteer: withdrawSteer }',
111
+ )()
112
+
113
+ const STEERING_ROW = { id: 'm1', placement: 'steering', preview: '帮我看下', text: '帮我看下' }
114
+
115
+ test('steerRowsOf:仅保留 placement=steering,防非数组与非行形态', () => {
116
+ const queue = [
117
+ { id: 'q1', placement: 'queued' },
118
+ STEERING_ROW,
119
+ { id: 'c1', placement: 'context' },
120
+ STEERING_ROW,
121
+ null,
122
+ ]
123
+ assert.deepEqual(pure.steerRowsOf(queue), [STEERING_ROW, STEERING_ROW])
124
+ assert.deepEqual(pure.steerRowsOf([]), [])
125
+ assert.deepEqual(pure.steerRowsOf(undefined), [])
126
+ })
127
+
128
+ test('withdrawSteer 主路径:remove 成功后回填草稿并提示,返回 true', async () => {
129
+ const calls = []
130
+ const actions = {
131
+ updateQueue: async (itemId, action) => { calls.push({ itemId, action }); return { ok: true } },
132
+ setDraft: (text) => calls.push({ setDraft: text }),
133
+ notify: (text, opts) => calls.push({ notify: text, opts }),
134
+ }
135
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
136
+ assert.equal(done, true)
137
+ assert.deepEqual(calls, [
138
+ { itemId: 'm1', action: { kind: 'remove' } },
139
+ { setDraft: '帮我看下' },
140
+ { notify: '已撤回到输入框', opts: undefined },
141
+ ])
142
+ })
143
+
144
+ test('withdrawSteer 已被应用:queue-item-not-found 不回填草稿,专用文案报错', async () => {
145
+ const calls = []
146
+ const actions = {
147
+ updateQueue: async () => ({ ok: false, error: { code: 'session/queue-item-not-found', message: 'queued item is no longer pending' } }),
148
+ setDraft: (text) => calls.push({ setDraft: text }),
149
+ notify: (text, opts) => calls.push({ notify: text, opts }),
150
+ }
151
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
152
+ assert.equal(done, false)
153
+ assert.deepEqual(calls, [{ notify: '插话已被应用,无法撤回', opts: { kind: 'error' } }])
154
+ })
155
+
156
+ test('withdrawSteer 其他失败:透传错误消息且不回填草稿', async () => {
157
+ const calls = []
158
+ const actions = {
159
+ updateQueue: async () => ({ ok: false, error: { code: 'session/steer-unavailable', message: 'busy' } }),
160
+ setDraft: (text) => calls.push({ setDraft: text }),
161
+ notify: (text, opts) => calls.push({ notify: text, opts }),
162
+ }
163
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
164
+ assert.equal(done, false)
165
+ assert.deepEqual(calls, [{ notify: '撤回失败: busy', opts: { kind: 'error' } }])
166
+ })
167
+
168
+ test('withdrawSteer 传输层拒绝:按错误处理且不回填草稿', async () => {
169
+ const calls = []
170
+ const actions = {
171
+ updateQueue: async () => { throw new Error('network down') },
172
+ setDraft: (text) => calls.push({ setDraft: text }),
173
+ notify: (text, opts) => calls.push({ notify: text, opts }),
174
+ }
175
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
176
+ assert.equal(done, false)
177
+ assert.deepEqual(calls, [{ notify: '撤回失败: network down', opts: { kind: 'error' } }])
178
+ })
179
+
180
+ test('withdrawSteer 摘除成功但回填抛错:兜底展示原文,不报成功', async () => {
181
+ const calls = []
182
+ const actions = {
183
+ updateQueue: async () => ({ ok: true }),
184
+ setDraft: () => { throw new Error('shell released') },
185
+ notify: (text, opts) => calls.push({ notify: text, opts }),
186
+ }
187
+ const done = await pure.withdrawSteer(STEERING_ROW, actions)
188
+ assert.equal(done, false)
189
+ assert.deepEqual(calls, [{ notify: '草稿回填失败,原文: 帮我看下', opts: { kind: 'error' } }])
190
+ })
191
+
192
+ // ── 组件级门控:干净禁用规约的落地断言(整源加载 + 记录型 createElement 直接调组件) ──
193
+
194
+ // createElement 桩记录调用树:h(type, props, ...children) -> {type, props, children}
195
+ function loadComponent() {
196
+ const modules = []
197
+ const registered = []
198
+ const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }
199
+ const element = (type, props, ...children) => ({ type, props, children })
200
+ const reactStub = {
201
+ useState: (value) => [value, () => {}],
202
+ useEffect: () => {},
203
+ useRef: (value) => ({ current: value }),
204
+ createElement: element,
205
+ }
206
+ const requireStub = (name) => {
207
+ if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
208
+ return reactStub
209
+ }
210
+ const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
211
+ factory(
212
+ windowStub,
213
+ requireStub,
214
+ { createElement: () => ({ style: {}, remove() {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } },
215
+ )
216
+ const mod = modules[0].factory(requireStub)
217
+ const sessions = { binding: () => undefined, list: { subscribe: () => () => {}, getSnapshot: () => ({ ids: [], byId: {} }) } }
218
+ const workspaces = { list: { subscribe: () => () => {}, getSnapshot: () => undefined } }
219
+ const ctx = {
220
+ get: (name) => (name === 'sessions' ? sessions : name === 'workspaces' ? workspaces : {}),
221
+ effect: () => {},
222
+ slots: {
223
+ inject: (name, register) => register(),
224
+ register: (options, component) => registered.push({ options, component }),
225
+ },
226
+ }
227
+ mod.apply(ctx)
228
+ const entry = registered.find((item) => item.options.id === 'session-manager-steer')
229
+ assert.ok(entry, '组件级测试未捕获插话撤回条目')
230
+ return entry.component
231
+ }
232
+
233
+ const DOCK_COMPONENT = loadComponent()
234
+ const NO_ACTIONS = { setDraft: () => {} }
235
+ const OK_UPDATE = async () => ({ ok: true })
236
+
237
+ // useSession 桩:两次 selector 读取共用同一快照(与官方 SessionSnapshotSelector 单快照同构)
238
+ function dockWith(snapshot) {
239
+ const useSession = (selector) => selector(snapshot)
240
+ return (props) => DOCK_COMPONENT({ session: {}, useSession, inputActions: NO_ACTIONS, updateQueue: OK_UPDATE, ...props })
241
+ }
242
+
243
+ test('SteerRecallDock 干净禁用:依赖任一缺失即返回 null', () => {
244
+ const dock = dockWith({ queue: [STEERING_ROW], subagent: null })
245
+ assert.equal(dock({ session: undefined }), null, 'session 缺失应不渲染')
246
+ assert.equal(dock({ inputActions: undefined }), null, 'inputActions 缺失应不渲染')
247
+ assert.equal(dock({ updateQueue: undefined }), null, 'updateQueue 缺失应不渲染')
248
+ })
249
+
250
+ test('SteerRecallDock 干净禁用:subagent 非 continuable 即返回 null,启用时零占位宿主', () => {
251
+ const locked = dockWith({ queue: [STEERING_ROW], subagent: { address: { mode: 'prompt' } } })
252
+ assert.equal(locked({}), null, 'queueMutable 为假应不渲染')
253
+ const host = dockWith({ queue: [], subagent: null })({})
254
+ assert.equal(host.type, 'div')
255
+ assert.equal(host.props.className, 'sm-steer-host', '启用时应渲染 display:none 宿主承载注入逻辑')
256
+ })
257
+
258
+ // 注入契约(源码级守卫):选择器/标记/清理必须存在,官方 DOM 结构漂移时静默降级
259
+ test('SteerRecallDock 注入契约:语义标记选择器、按钮标记与卸载清理齐备', () => {
260
+ assert.ok(CLIENT_SRC.includes('[data-pending-steering]'), '应定位官方气泡语义标记')
261
+ assert.ok(CLIENT_SRC.includes('[class$="_actions"]'), '应按 CSS module 类名后缀定位官方图标排')
262
+ assert.ok(CLIENT_SRC.includes("data-sm-steer-recall"), '应有自家按钮标记防重复注入')
263
+ assert.ok(CLIENT_SRC.includes('new MutationObserver'), '应经 MutationObserver 跟随气泡出现')
264
+ assert.ok(CLIENT_SRC.includes('official.className'), '应克隆官方按钮类名保证样式原生')
265
+ assert.ok(CLIENT_SRC.includes('observer.disconnect()'), '卸载应断开观察')
266
+ })