dsh-recall-plugin 1.7.0 → 2.0.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/lib/client.js CHANGED
@@ -2,13 +2,14 @@
2
2
  * dsh-recall-plugin — Client 半(持久插件形态,浏览器 bundle)
3
3
  *
4
4
  * 职责:注册 conversation.chat.node 的 user 渲染器,重绘用户消息气泡
5
- * (文本/图片/JSON 块),在复制按钮旁加「撤回」按钮;二次确认面板展示
5
+ * (文本/图片/JSON 块;图片经官方 props.renderMessageImages 管线渲染,
6
+ * 见 UserRecallNode 内注释),在复制按钮旁加「撤回」按钮;二次确认面板展示
6
7
  * 文件变更清单,确认后经 /api/recall/* 调用 Host 回退文件,并用官方
7
8
  * sessions.fork 把对话一并回退(新会话打开、原会话归档)。
8
9
  *
9
10
  * 这是 dsh.client bundle(exports["./client"]),由 client-modules 打包成
10
11
  * /plugins/<pkg>/client.js 注入页面;factory 内 require("react") 由平台
11
- * 模块表提供。零构建依赖:纯 JS + React.createElement
12
+ * 模块表提供。当前为纯 JS + React.createElement 直写。
12
13
  */
13
14
  window.__ModuleLoader__.load({
14
15
  id: "dsh-recall-plugin",
@@ -38,9 +39,6 @@ window.__ModuleLoader__.load({
38
39
  '.dsh-recall-row{flex-direction:column;align-items:flex-end;gap:6px;display:flex}',
39
40
  '.dsh-recall-stack{flex-direction:column;align-items:flex-end;gap:8px;min-width:0;max-width:min(525px,82%);display:flex}',
40
41
  '.dsh-recall-bubble{background:var(--dsw-specific-bubble);max-width:100%;color:var(--dsw-alias-label-primary);border-radius:22px;padding:10px 16px;font-size:16px;line-height:24px;white-space:pre-wrap;word-break:break-word}',
41
- '.dsh-recall-img{max-width:100%;max-height:320px;border-radius:12px;object-fit:contain}',
42
- '.dsh-recall-img-failed{border:1px dashed var(--dsw-alias-border-l2);border-radius:12px;padding:8px 14px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0}',
43
- '.dsh-recall-img-failed:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}',
44
42
  '.dsh-recall-json{margin:0;max-width:100%;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:pre-wrap;word-break:break-word;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;padding:8px 10px;background:var(--dsw-alias-markdown-code-block)}',
45
43
  '.dsh-recall-actions{align-items:center;gap:10px;height:28px;display:flex}',
46
44
  '.dsh-recall-time{color:var(--dsw-alias-label-tertiary);white-space:nowrap;padding-right:12px;font-size:14px;line-height:24px}',
@@ -75,6 +73,7 @@ window.__ModuleLoader__.load({
75
73
  '.dsh-recall-ex-chip:hover{color:var(--dsw-alias-label-primary)}',
76
74
  '.dsh-recall-ex-status{margin-right:auto;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary)}',
77
75
  '.dsh-recall-ex-status-error{color:var(--dsw-alias-state-error-primary)}',
76
+ '.dsh-recall-ex-status-success{color:var(--dsw-alias-state-success-primary)}',
78
77
  '.dsh-recall-tree{display:flex;flex-direction:column;gap:2px;padding:4px 0}',
79
78
  '.dsh-recall-tree-node{display:flex;flex-direction:column;gap:1px}',
80
79
  '.dsh-recall-tree-row{display:flex;gap:6px;align-items:center;min-width:0;padding:2px 4px;border-radius:6px;cursor:default}',
@@ -177,7 +176,7 @@ window.__ModuleLoader__.load({
177
176
  // has:false 且不再重试,撤回按钮将永不出现。
178
177
  // init 顺带下发插件行为开关(refillDraft 等),存进 pluginConfig
179
178
  // 供撤回执行链读取——设置页改配置 + 重启后随下一次 init 刷新。
180
- const pluginConfig = { refillDraft: true }
179
+ const pluginConfig = { refillDraft: true, archiveOriginal: true }
181
180
  function ensureInit(sessionId) {
182
181
  if (!sessionId) return Promise.resolve()
183
182
  const cached = initMap.get(sessionId)
@@ -185,6 +184,7 @@ window.__ModuleLoader__.load({
185
184
  const done = api('init', { sessionId }).then((res) => {
186
185
  if (res && res.config && typeof res.config === 'object') {
187
186
  if (typeof res.config.refillDraft === 'boolean') pluginConfig.refillDraft = res.config.refillDraft
187
+ if (typeof res.config.archiveOriginal === 'boolean') pluginConfig.archiveOriginal = res.config.archiveOriginal
188
188
  }
189
189
  const notice = res && res.notice
190
190
  if (notice && notice.unsupported) {
@@ -268,52 +268,6 @@ window.__ModuleLoader__.load({
268
268
  )
269
269
  }
270
270
 
271
- // 图片加载失败的暂态判定(issue #8):会话回放时最早的图片消息先于
272
- // 会话 binding 就绪渲染,loadImage 以 unknown session 拒绝——这类
273
- // 失败靠短退避自动重试即可自愈;此前 `.catch(() => {})` 把失败静默
274
- // 吞掉且 src 恒为 null,图片永久空白且刷新无法恢复。自动重试耗尽
275
- // 后置 failed,由 ImageBox 提供「点击重试」(对齐官方 MessageImage
276
- // 的失败态语义),attempt 仅被手动重试递增以复用整条加载链。
277
- const IMAGE_RETRY_DELAYS = [400, 1500, 4000]
278
-
279
- function useImageSrc(attachment, loadImage) {
280
- const [src, setSrc] = React.useState(null)
281
- const [failed, setFailed] = React.useState(false)
282
- const [attempt, setAttempt] = React.useState(0)
283
- React.useEffect(() => {
284
- let alive = true
285
- let timer = null
286
- if (!attachment || typeof loadImage !== 'function') return undefined
287
- // 复位旧状态:attachment 变化或手动重试时清掉上一轮的 src/failed,
288
- // 避免加载期间短暂显示上一张图或残留的失败态
289
- setSrc(null)
290
- setFailed(false)
291
- const tryLoad = (retried) => {
292
- Promise.resolve(loadImage(attachment)).then((url) => {
293
- if (!alive) return
294
- if (url) setSrc(String(url))
295
- else if (retried < IMAGE_RETRY_DELAYS.length) timer = setTimeout(() => tryLoad(retried + 1), IMAGE_RETRY_DELAYS[retried])
296
- else setFailed(true)
297
- }).catch(() => {
298
- if (!alive) return
299
- if (retried < IMAGE_RETRY_DELAYS.length) timer = setTimeout(() => tryLoad(retried + 1), IMAGE_RETRY_DELAYS[retried])
300
- else setFailed(true)
301
- })
302
- }
303
- tryLoad(0)
304
- return () => { alive = false; if (timer) clearTimeout(timer) }
305
- }, [attachment, loadImage, attempt])
306
- return { src, failed, retry: () => setAttempt(attempt + 1) }
307
- }
308
-
309
- function ImageBox(props) {
310
- const { src, failed, retry } = useImageSrc(props.attachment, props.loadImage)
311
- if (src) return React.createElement('img', { className: 'dsh-recall-img', src, alt: '' })
312
- // 加载中保持原零占位语义(不撑气泡);只在确定失败后渲染可重试入口
313
- if (failed) return React.createElement('button', { type: 'button', className: 'dsh-recall-img-failed', onClick: retry }, '图片加载失败,点击重试')
314
- return null
315
- }
316
-
317
271
  // kind 语义单表承载(文案/徽章类名/汇总顺序):新增 kind 时只改
318
272
  // 这一处,避免出现「映射表改了、CSS 类名忘了」的半改状态
319
273
  const KIND_INFO = {
@@ -404,14 +358,21 @@ window.__ModuleLoader__.load({
404
358
 
405
359
  function UserRecallNode(props) {
406
360
  const node = props && props.node
407
- const loadImage = props && props.loadImage
361
+ // 图片渲染入口:官方 ChatNodeSeat 传给本 slot props 契约只有
362
+ // renderMessageImages(内部经 conversation.message.images slot 渲染
363
+ // 官方 MessageImages,自带鉴权、缓存、失败重试与灯箱预览)。
364
+ // 契约里从不存在 loadImage(issue #9):v1.6/v1.7 读该不存在的
365
+ // 字段导致自研加载链从未执行、用户消息图片永久无声空白。
366
+ const renderMessageImages = props && props.renderMessageImages
408
367
  const sessionId = props && props.sessionId
409
368
  const data = node && node.data ? node.data : {}
410
369
  // node.id 是会话事件匹配时写入的真实消息 ID;node.key 是位置键(如 13:input),不能用于快照查询
411
370
  const messageId = node ? String(node.id || node.key || '') : ''
412
371
  const blocks = Array.isArray(data.content) ? data.content : []
413
372
  const text = blocks.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('')
414
- const images = blocks.filter((b) => b && b.type === 'image' && b.attachment).map((b) => b.attachment)
373
+ // 官方契约:images image 块数组({attachment}),官方内部取
374
+ // image.attachment.attachmentId —— 不是裸 attachment 对象
375
+ const imageBlocks = blocks.filter((b) => b && b.type === 'image' && b.attachment).map((b) => ({ attachment: b.attachment }))
415
376
  const rest = blocks.filter((b) => !b || !(b.type === 'text' && typeof b.text === 'string') && !(b.type === 'image' && b.attachment))
416
377
 
417
378
  const [copied, setCopied] = React.useState(false)
@@ -513,9 +474,36 @@ window.__ModuleLoader__.load({
513
474
  if (recall.stage !== 'confirm') return
514
475
  const changes = recall.changes || []
515
476
  const previewCut = typeof recall.cutSeq === 'number' ? recall.cutSeq : null
477
+ // P0-3:携带预览摘要(total 是完整计数,与 Host 侧 diffFor 的
478
+ // total 对齐;changes 截断到 500 条,不能用来比对)。Host 端
479
+ // 据此在 execute 时校验「预览后文件集是否变化」,变了则返回
480
+ // STALE 拒绝执行,防止把工作区改到用户没看过的新状态。
481
+ const previewTotal = typeof recall.total === 'number' ? recall.total : changes.length
516
482
  setRecall({ stage: 'executing', changes })
517
- api('execute', { messageId, sessionId }).then(async (res) => {
483
+ api('execute', { messageId, sessionId, previewTotal, previewAt: Date.now() }).then(async (res) => {
518
484
  if (!res || !res.ok) {
485
+ // STALE:预览后文件变了——自动重新拉一次最新清单回到确认
486
+ // 阶段让用户看新内容,而不是停在错误面板
487
+ if (res && res.code === 'STALE') {
488
+ setRecall({ stage: 'loading' })
489
+ api('preview', { messageId, sessionId }).then((res2) => {
490
+ if (!res2 || !res2.ok) {
491
+ setRecall({ stage: 'error', message: (res2 && (res2.message || res2.error)) || '无法获取快照' })
492
+ return
493
+ }
494
+ setRecall({
495
+ stage: 'confirm',
496
+ changes: res2.changes || [],
497
+ total: typeof res2.total === 'number' ? res2.total : (res2.changes || []).length,
498
+ truncated: Boolean(res2.truncated),
499
+ time: res2.time || null,
500
+ cutSeq: typeof res2.cutSeq === 'number' ? res2.cutSeq : null
501
+ })
502
+ }).catch((error) => {
503
+ setRecall({ stage: 'error', message: String(error) })
504
+ })
505
+ return
506
+ }
519
507
  setRecall({ stage: 'error', message: (res && (res.message || res.error)) || '回退失败' })
520
508
  return
521
509
  }
@@ -537,8 +525,10 @@ window.__ModuleLoader__.load({
537
525
  if (typeof sessionsSvc.open === 'function') sessionsSvc.open(childId)
538
526
  chatReverted = true
539
527
  fillTarget = childId
540
- // 回退前的原会话归档:只是从列表隐藏、可恢复,避免侧栏出现两个近似会话
541
- if (workspacesSvc && typeof workspacesSvc.archiveSession === 'function') {
528
+ // 回退前的原会话归档(可关,见设置页 archiveOriginal):只是从列表
529
+ // 隐藏、可恢复,避免侧栏出现两个近似会话;关闭时原会话
530
+ // 保留供对照
531
+ if (pluginConfig.archiveOriginal && workspacesSvc && typeof workspacesSvc.archiveSession === 'function') {
542
532
  workspacesSvc.archiveSession(sessionId).catch(() => {})
543
533
  }
544
534
  } else {
@@ -597,10 +587,14 @@ window.__ModuleLoader__.load({
597
587
  }
598
588
 
599
589
  const bubbleChildren = []
600
- if (text !== '') bubbleChildren.push(React.createElement('div', { className: 'dsh-recall-bubble', key: 'text' }, text))
601
- for (let i = 0; i < images.length; i++) {
602
- bubbleChildren.push(React.createElement(ImageBox, { key: 'img-' + i, attachment: images[i], loadImage }))
590
+ // 图片在上、气泡在下:布局顺序对齐官方 UserStyleBubble(历史会话
591
+ // 里用户消息的观感与官方一致)。Fragment 包一层仅为挂 key。
592
+ if (imageBlocks.length && typeof renderMessageImages === 'function') {
593
+ bubbleChildren.push(React.createElement(React.Fragment, { key: 'images' },
594
+ renderMessageImages({ images: imageBlocks, align: 'end' })
595
+ ))
603
596
  }
597
+ if (text !== '') bubbleChildren.push(React.createElement('div', { className: 'dsh-recall-bubble', key: 'text' }, text))
604
598
  for (let i = 0; i < rest.length; i++) {
605
599
  bubbleChildren.push(React.createElement('pre', { className: 'dsh-recall-json', key: 'rest-' + i }, JSON.stringify(rest[i], null, 2)))
606
600
  }
@@ -719,7 +713,7 @@ window.__ModuleLoader__.load({
719
713
  }, s))
720
714
  ),
721
715
  React.createElement('div', { className: 'dsh-recall-panel-actions' },
722
- state.message ? React.createElement('span', { className: 'dsh-recall-ex-status' + (state.error ? ' dsh-recall-ex-status-error' : '') }, state.message) : null,
716
+ state.message ? React.createElement('span', { className: 'dsh-recall-ex-status' + (state.error ? ' dsh-recall-ex-status-error' : ' dsh-recall-ex-status-success') }, state.message) : null,
723
717
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: !dirty || state.busy, onClick: discard }, '放弃修改'),
724
718
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn dsh-recall-btn-danger', disabled: !dirty || state.busy, onClick: save }, '保存')
725
719
  )
@@ -735,6 +729,21 @@ window.__ModuleLoader__.load({
735
729
  const [usage, setUsage] = React.useState(null)
736
730
  const [errors, setErrors] = React.useState(null)
737
731
  const [state, setState] = React.useState({ busy: false, message: '', error: false })
732
+ // 快照全量计数与当前拉取上限(S1-2):Host 按 limit 切片返回,
733
+ // total 是全量——树默认折叠,全量加载到 2000 上限对 DOM 无压力,
734
+ // 分页与树形组装互斥,故用「加载更多」而非翻页。
735
+ const [limit, setLimit] = React.useState(200)
736
+ const [total, setTotal] = React.useState(0)
737
+ // 存储健康(S2-4):git 可用性 + home/降级分区计数,来自 usage
738
+ // 端点的扩展响应;git 不可用/降级是消息流一次性 toast 之外常驻
739
+ // 可见的排障信息。
740
+ const [health, setHealth] = React.useState(null)
741
+ // 树搜索关键字(S3-4):纯客户端过滤已加载条目,按工作区名/会话
742
+ // 标题/消息文本/快照 ID 匹配;只过滤当前已加载数据,与 S1-2 的
743
+ // limit 正交。
744
+ const [query, setQuery] = React.useState('')
745
+ // 最近错误展开态(S3-5):默认 5 条,可展开全部 20 条/收起
746
+ const [showAllErrors, setShowAllErrors] = React.useState(false)
738
747
  // 冷会话标题在服务端要整日志解压(10 秒级),首屏不等它:列表
739
748
  // 先出(live/缓存标题),拿到后对缺标题的会话异步补拉再合并。
740
749
  const [titlesPending, setTitlesPending] = React.useState(false)
@@ -783,10 +792,12 @@ window.__ModuleLoader__.load({
783
792
  }).catch(() => {})
784
793
  }
785
794
 
786
- function refresh() {
787
- api('manage', { op: 'list' }).then((res) => {
795
+ function refresh(overLimit) {
796
+ const useLimit = overLimit || limit
797
+ api('manage', { op: 'list', limit: useLimit }).then((res) => {
788
798
  if (res && res.ok) {
789
799
  setItems(res.items || [])
800
+ setTotal(typeof res.total === 'number' ? res.total : (res.items || []).length)
790
801
  fetchTitles(res.items || [])
791
802
  fetchMessages(res.items || [])
792
803
  }
@@ -795,7 +806,10 @@ window.__ModuleLoader__.load({
795
806
  // 渲染后,用户先看到树形内容,占用再异步补上(usage 不带
796
807
  // sessionId,Host 汇总全部工作区)。
797
808
  api('manage', { op: 'usage' }).then((res) => {
798
- if (res && res.ok) setUsage(res.bytes || 0)
809
+ if (res && res.ok) {
810
+ setUsage(res.bytes || 0)
811
+ setHealth({ gitAvailable: res.gitAvailable !== false, homeStores: res.homeStores || 0, fallbackStores: res.fallbackStores || 0 })
812
+ }
799
813
  }).catch(() => {})
800
814
  api('status', {}).then((res) => {
801
815
  if (res && res.ok) setErrors(res.errors || [])
@@ -803,7 +817,10 @@ window.__ModuleLoader__.load({
803
817
  }).catch(() => {
804
818
  // list 失败时仍尝试补 usage/status,避免整卡全空
805
819
  api('manage', { op: 'usage' }).then((res) => {
806
- if (res && res.ok) setUsage(res.bytes || 0)
820
+ if (res && res.ok) {
821
+ setUsage(res.bytes || 0)
822
+ setHealth({ gitAvailable: res.gitAvailable !== false, homeStores: res.homeStores || 0, fallbackStores: res.fallbackStores || 0 })
823
+ }
807
824
  }).catch(() => {})
808
825
  api('status', {}).then((res) => {
809
826
  if (res && res.ok) setErrors(res.errors || [])
@@ -813,6 +830,13 @@ window.__ModuleLoader__.load({
813
830
 
814
831
  React.useEffect(() => { refresh() }, [])
815
832
 
833
+ // S3-5:清空 Host 侧错误缓冲并同步本地状态;Host 已清后下次
834
+ // refresh 拉回为空,不重复请求。
835
+ function clearErrors() {
836
+ setErrors([])
837
+ api('status', { op: 'clear' }).catch(() => {})
838
+ }
839
+
816
840
  function run(op, extra, doneText) {
817
841
  if (state.busy) return
818
842
  setState({ busy: true, message: '执行中…', error: false })
@@ -832,17 +856,17 @@ window.__ModuleLoader__.load({
832
856
  function renderDeleteAllConfirm() {
833
857
  if (!confirming || confirming.kind !== 'all') return null
834
858
  return React.createElement('div', { className: 'dsh-recall-tree-confirm' },
835
- '确认删除所有工作区的全部快照?此操作不可恢复。',
836
- React.createElement('button', {
837
- type: 'button',
838
- className: 'dsh-recall-ex-chip',
839
- onClick: () => {
840
- setConfirming(null)
841
- run('deleteAll', {}, '已清空全部快照')
842
- }
843
- }, '确认全部删除'),
844
- React.createElement('button', { type: 'button', className: 'dsh-recall-ex-chip', onClick: () => setConfirming(null) }, '取消')
845
- )
859
+ '确认删除所有工作区的全部快照?此操作不可恢复。',
860
+ React.createElement('button', {
861
+ type: 'button',
862
+ className: 'dsh-recall-btn dsh-recall-btn-danger',
863
+ onClick: () => {
864
+ setConfirming(null)
865
+ run('deleteAll', {}, '已清空全部快照')
866
+ }
867
+ }, '确认全部删除'),
868
+ React.createElement('button', { type: 'button', className: 'dsh-recall-ex-chip', onClick: () => setConfirming(null) }, '取消')
869
+ )
846
870
  }
847
871
 
848
872
  // 树形管理:工作区 → 会话 → 快照三级。折叠状态用 Set<key> 记录,
@@ -880,7 +904,16 @@ window.__ModuleLoader__.load({
880
904
  }
881
905
  return wsList
882
906
  }
883
- const tree = buildTree(items)
907
+ const q = query.trim().toLowerCase()
908
+ const filteredItems = q
909
+ ? (items || []).filter((it) =>
910
+ (it.workspace || '').toLowerCase().indexOf(q) >= 0 ||
911
+ (it.sessionTitle || '').toLowerCase().indexOf(q) >= 0 ||
912
+ (it.messageText || '').toLowerCase().indexOf(q) >= 0 ||
913
+ String(it.id || '').toLowerCase().indexOf(q) >= 0
914
+ )
915
+ : items
916
+ const tree = buildTree(filteredItems)
884
917
  // 每个节点的删除按钮统一走 run();workspace/session 用新端点 scope
885
918
  // 批量删,叶子沿用原单条删除。二次确认文案按节点层级区分。
886
919
  function confirmDelete(kind, key, extra, text) {
@@ -985,21 +1018,63 @@ window.__ModuleLoader__.load({
985
1018
  }
986
1019
  const treeNodes = tree.map(renderWorkspace)
987
1020
 
1021
+ // 计数用 Host 返回的全量 total 而非已加载条数(S1-2):loaded 为
1022
+ // null 表示首屏加载中(显示 …),已加载但 total 超过当前 limit
1023
+ // 时提示「当前显示最新 X 条」与加载更多入口。
1024
+ const loaded = items ? items.length : null
1025
+ const countText = loaded === null
1026
+ ? '共 … 条快照'
1027
+ : '共 ' + total + ' 条快照' + (limit < total ? '(当前显示最新 ' + loaded + ' 条)' : '')
1028
+
1029
+ // 加载更多:上限 2000 与 Host 端钳制一致;total 已含全量计数,
1030
+ // 直接按 total 一次拉满(再往上就是 2000 封顶)
1031
+ function loadMore() {
1032
+ const next = Math.min(Math.max(total, limit), 2000)
1033
+ if (next <= limit) return
1034
+ setLimit(next)
1035
+ refresh(next)
1036
+ }
1037
+
988
1038
  return React.createElement('div', { className: 'dsh-recall-ex-card' },
989
1039
  React.createElement('div', { className: 'dsh-recall-ex-title' }, '快照管理'),
990
1040
  React.createElement('div', { className: 'dsh-recall-ex-note' },
991
1041
  usage === null
992
- ? '共 ' + (items ? items.length : '') + ' 条快照。'
993
- : '共 ' + (items ? items.length : '…') + ' 条快照,全部工作区快照存储占用 ' + sizeText(usage) + '。'
1042
+ ? countText + ''
1043
+ : countText + ',全部工作区快照存储占用 ' + sizeText(usage) + '。'
994
1044
  ),
1045
+ health ? React.createElement('div', { className: 'dsh-recall-ex-note', key: 'health' },
1046
+ React.createElement('span', {
1047
+ className: health.gitAvailable ? '' : 'dsh-recall-ex-status-error'
1048
+ }, health.gitAvailable ? 'git 可用' : 'git 不可用(快照引擎依赖 git)'),
1049
+ ' · 快照存储:home ' + health.homeStores + ' 个工作区' + (health.fallbackStores ? ',降级 ' + health.fallbackStores + ' 个' : '')
1050
+ ) : null,
1051
+ React.createElement('input', {
1052
+ className: 'dsh-recall-ex-input',
1053
+ placeholder: '搜索工作区 / 会话标题 / 消息内容 / ID',
1054
+ value: query,
1055
+ spellCheck: false,
1056
+ onChange: (e) => setQuery(e.target.value),
1057
+ }),
995
1058
  treeNodes.length > 0 ? React.createElement('div', { className: 'dsh-recall-tree' }, ...treeNodes) : null,
1059
+ items && items.length === 0 && !q
1060
+ ? React.createElement('div', { className: 'dsh-recall-ex-note', key: 'empty' }, '在任意工作区发送一条消息后,这里会出现快照。')
1061
+ : null,
1062
+ q && filteredItems && filteredItems.length === 0
1063
+ ? React.createElement('div', { className: 'dsh-recall-ex-note', key: 'no-match' }, '无匹配快照')
1064
+ : null,
996
1065
  renderDeleteAllConfirm(),
997
1066
  React.createElement('div', { className: 'dsh-recall-panel-actions' },
998
- state.message ? React.createElement('span', { className: 'dsh-recall-ex-status' + (state.error ? ' dsh-recall-ex-status-error' : '') }, state.message) : null,
1067
+ state.message ? React.createElement('span', { className: 'dsh-recall-ex-status' + (state.error ? ' dsh-recall-ex-status-error' : ' dsh-recall-ex-status-success') }, state.message) : null,
1068
+ limit < total ? React.createElement('button', {
1069
+ type: 'button',
1070
+ className: 'dsh-recall-btn',
1071
+ disabled: state.busy,
1072
+ onClick: loadMore
1073
+ }, '加载更多') : null,
999
1074
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: state.busy, onClick: refresh }, '刷新'),
1000
1075
  React.createElement('button', {
1001
1076
  type: 'button',
1002
- className: 'dsh-recall-btn',
1077
+ className: 'dsh-recall-btn dsh-recall-btn-danger',
1003
1078
  disabled: state.busy,
1004
1079
  title: '删除全部工作区的所有快照;会直接核对并删除 git tag(即使列表为空也可清理残留)',
1005
1080
  onClick: () => setConfirming({ kind: 'all' })
@@ -1013,9 +1088,15 @@ window.__ModuleLoader__.load({
1013
1088
  }, '立即 gc')
1014
1089
  ),
1015
1090
  errors && errors.length > 0
1016
- ? React.createElement('div', { className: 'dsh-recall-ex-note' },
1017
- '最近错误:',
1018
- errors.slice(0, 5).map((e, i) => React.createElement('div', { key: i, className: 'dsh-recall-ex-note' }, clockText(e.time) + ' ' + e.message))
1091
+ ? React.createElement('div', { className: 'dsh-recall-ex-note', key: 'errors' },
1092
+ React.createElement('div', { className: 'dsh-recall-ex-status' },
1093
+ '最近错误:',
1094
+ (showAllErrors ? errors : errors.slice(0, 5)).map((e, i) => React.createElement('div', { key: i, className: 'dsh-recall-ex-note' }, clockText(e.time) + ' ' + e.message))
1095
+ ),
1096
+ React.createElement('div', { className: 'dsh-recall-panel-actions' },
1097
+ errors.length > 5 ? React.createElement('button', { type: 'button', className: 'dsh-recall-ex-chip', onClick: () => setShowAllErrors((v) => !v) }, showAllErrors ? '收起' : '展开全部 (' + errors.length + ')') : null,
1098
+ React.createElement('button', { type: 'button', className: 'dsh-recall-ex-chip', onClick: clearErrors }, '清空')
1099
+ )
1019
1100
  )
1020
1101
  : null
1021
1102
  )
@@ -1058,6 +1139,16 @@ window.__ModuleLoader__.load({
1058
1139
  )
1059
1140
  }
1060
1141
 
1142
+ // ConfigForm 的字节↔MB 换算:持久化与 schema 都是字节(config-get
1143
+ // 下发原值),只在 display/input 层换算成人工友好的 MB 小数;round
1144
+ // 2 位小数去尾零,避免默认值 104857600 裸奔成一长串数字
1145
+ // (plan-settings-ux S1-1)。
1146
+ function bytesToMb(bytes) {
1147
+ const n = Number(bytes)
1148
+ if (!Number.isFinite(n) || n <= 0) return ''
1149
+ return String(Math.round((n / 1048576) * 100) / 100)
1150
+ }
1151
+
1061
1152
  // 插件配置表单:值经 Host 的 settings namespace「dsh-recall」读写
1062
1153
  // (config-get / config-set 端点),保存即持久化进用户 settings 文档
1063
1154
  // 并热生效(Host watch 链路原地更新 cfg),无需重启。只提交相对
@@ -1069,6 +1160,9 @@ window.__ModuleLoader__.load({
1069
1160
  const [overridden, setOverridden] = React.useState({})
1070
1161
  const [writable, setWritable] = React.useState(true)
1071
1162
  const [state, setState] = React.useState({ busy: false, message: '', error: false })
1163
+ // 「高级:基础排除表」折叠态(S3-2):与 exclude.txt 同为 gitignore
1164
+ // 编辑器,叠放会造成认知负担——内置规则收进折叠,只留高频项在首屏
1165
+ const [showAdvanced, setShowAdvanced] = React.useState(false)
1072
1166
 
1073
1167
  function load() {
1074
1168
  api('config-get', {}).then((res) => {
@@ -1077,9 +1171,13 @@ window.__ModuleLoader__.load({
1077
1171
  const next = {
1078
1172
  gcSnaps: String(v.gcSnaps == null ? '' : v.gcSnaps),
1079
1173
  gcHours: String(v.gcHours == null ? '' : v.gcHours),
1080
- maxFileBytes: String(v.maxFileBytes == null ? '' : v.maxFileBytes),
1174
+ maxFileBytes: bytesToMb(v.maxFileBytes),
1175
+ maxSnapshotsPerWorkspace: String(v.maxSnapshotsPerWorkspace == null ? '' : v.maxSnapshotsPerWorkspace),
1081
1176
  baseExcludes: Array.isArray(v.baseExcludes) ? v.baseExcludes.join('\n') : '',
1082
1177
  refillDraft: v.refillDraft !== false,
1178
+ snapshotEnabled: v.snapshotEnabled !== false,
1179
+ archiveOriginal: v.archiveOriginal !== false,
1180
+ retentionDays: String(v.retentionDays == null ? '' : v.retentionDays),
1083
1181
  }
1084
1182
  setDraft(next)
1085
1183
  setBaseline(next)
@@ -1101,7 +1199,7 @@ window.__ModuleLoader__.load({
1101
1199
  function save() {
1102
1200
  if (state.busy || !draft || !baseline) return
1103
1201
  const patch = {}
1104
- for (const key of ['gcSnaps', 'gcHours', 'maxFileBytes', 'baseExcludes', 'refillDraft']) {
1202
+ for (const key of ['gcSnaps', 'gcHours', 'maxFileBytes', 'maxSnapshotsPerWorkspace', 'baseExcludes', 'refillDraft', 'snapshotEnabled', 'archiveOriginal', 'retentionDays']) {
1105
1203
  if (draft[key] !== baseline[key]) patch[key] = draft[key]
1106
1204
  }
1107
1205
  if (!Object.keys(patch).length) {
@@ -1120,11 +1218,25 @@ window.__ModuleLoader__.load({
1120
1218
  clean.gcHours = n
1121
1219
  }
1122
1220
  if (patch.maxFileBytes !== undefined) {
1123
- const n = parseInt(patch.maxFileBytes, 10)
1124
- if (!Number.isFinite(n) || n < 1024) { setState({ busy: false, message: '文件大小上限必须是 >= 1024 的字节数', error: true }); return }
1125
- clean.maxFileBytes = n
1221
+ // display 层是 MB 小数,持久化仍是字节(Math.round 取整):
1222
+ // model 侧不变,config-get/config-set 往返零改动
1223
+ const mb = Number(patch.maxFileBytes)
1224
+ if (!Number.isFinite(mb) || mb < 0.01) { setState({ busy: false, message: '文件大小上限至少 0.01 MB', error: true }); return }
1225
+ clean.maxFileBytes = Math.round(mb * 1048576)
1226
+ }
1227
+ if (patch.maxSnapshotsPerWorkspace !== undefined) {
1228
+ const n = parseInt(patch.maxSnapshotsPerWorkspace, 10)
1229
+ if (!Number.isFinite(n) || n < 0) { setState({ busy: false, message: '快照总量上限必须是 >= 0 的整数(0 表示不限制)', error: true }); return }
1230
+ clean.maxSnapshotsPerWorkspace = n
1126
1231
  }
1127
1232
  if (patch.refillDraft !== undefined) clean.refillDraft = Boolean(patch.refillDraft)
1233
+ if (patch.snapshotEnabled !== undefined) clean.snapshotEnabled = Boolean(patch.snapshotEnabled)
1234
+ if (patch.archiveOriginal !== undefined) clean.archiveOriginal = Boolean(patch.archiveOriginal)
1235
+ if (patch.retentionDays !== undefined) {
1236
+ const n = parseInt(patch.retentionDays, 10)
1237
+ if (!Number.isFinite(n) || n < 0) { setState({ busy: false, message: '保留天数必须是 >= 0 的整数(0 表示不启用)', error: true }); return }
1238
+ clean.retentionDays = n
1239
+ }
1128
1240
  if (patch.baseExcludes !== undefined) {
1129
1241
  clean.baseExcludes = String(patch.baseExcludes).split('\n').map((l) => l.trim()).filter(Boolean)
1130
1242
  }
@@ -1139,7 +1251,7 @@ window.__ModuleLoader__.load({
1139
1251
  }).catch((e) => setState({ busy: false, message: String(e), error: true }))
1140
1252
  }
1141
1253
 
1142
- function numRow(key, label, hint) {
1254
+ function numRow(key, label, hint, opts) {
1143
1255
  const locked = Boolean(envLocks && envLocks[key])
1144
1256
  const changed = Boolean(draft && baseline && draft[key] !== baseline[key])
1145
1257
  return React.createElement('div', { className: 'dsh-recall-cfg-row', key: key },
@@ -1150,8 +1262,11 @@ window.__ModuleLoader__.load({
1150
1262
  type: 'number',
1151
1263
  value: draft ? draft[key] : '',
1152
1264
  disabled: locked || !writable,
1265
+ min: opts && opts.min,
1266
+ step: opts && opts.step,
1153
1267
  onChange: (e) => edit(key, e.target.value),
1154
1268
  }),
1269
+ opts && opts.suffix ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, opts.suffix) : null,
1155
1270
  changed && !locked ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已修改') : null,
1156
1271
  overridden && overridden[key] !== undefined ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已覆盖') : null,
1157
1272
  locked ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '环境变量锁定') : null
@@ -1160,18 +1275,49 @@ window.__ModuleLoader__.load({
1160
1275
  )
1161
1276
  }
1162
1277
 
1278
+ function resetDefaults() {
1279
+ if (state.busy || !writable) return
1280
+ setState({ busy: true, message: '恢复默认中…', error: false })
1281
+ api('config-reset', {}).then((res) => {
1282
+ if (res && res.ok) {
1283
+ load()
1284
+ setState({ busy: false, message: '已恢复默认值', error: false })
1285
+ } else {
1286
+ setState({ busy: false, message: (res && (res.message || res.error)) || '恢复默认失败', error: true })
1287
+ }
1288
+ }).catch((e) => setState({ busy: false, message: String(e), error: true }))
1289
+ }
1290
+
1163
1291
  if (!draft) {
1164
1292
  return React.createElement('div', { className: 'dsh-recall-ex-note' }, state.message || '正在读取配置…')
1165
1293
  }
1166
1294
 
1167
1295
  return React.createElement('div', { className: 'dsh-recall-ex-card' },
1168
- numRow('gcSnaps', 'gc 触发条数', '每积累多少条快照触发一次 git gc'),
1169
- numRow('gcHours', 'gc 触发小时', '距上次 gc 超过多少小时触发(与条数先到先触发)'),
1170
- numRow('maxFileBytes', '文件大小上限', '超过该字节数的文件不进快照、不被回退触碰'),
1296
+ React.createElement('div', { className: 'dsh-recall-cfg-row', key: 'snapshotEnabled' },
1297
+ React.createElement('div', { className: 'dsh-recall-cfg-line' },
1298
+ React.createElement('label', { className: 'dsh-recall-cfg-label', htmlFor: 'dsh-recall-cfg-snapshot' }, '启用快照'),
1299
+ React.createElement('input', {
1300
+ id: 'dsh-recall-cfg-snapshot',
1301
+ type: 'checkbox',
1302
+ checked: Boolean(draft.snapshotEnabled),
1303
+ disabled: !writable,
1304
+ onChange: (e) => edit('snapshotEnabled', e.target.checked),
1305
+ }),
1306
+ draft.snapshotEnabled !== baseline.snapshotEnabled ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已修改') : null,
1307
+ overridden && overridden.snapshotEnabled !== undefined ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已覆盖') : null
1308
+ ),
1309
+ React.createElement('div', { className: 'dsh-recall-cfg-hint' }, '关闭后不再新建快照(已有快照仍可撤回),适合临时禁用快照的场合')
1310
+ ),
1311
+ numRow('gcSnaps', 'gc 触发条数', '每积累多少条快照触发一次 git gc', { min: 1, step: 1 }),
1312
+ numRow('gcHours', 'gc 触发小时', '距上次 gc 超过多少小时触发(与条数先到先触发)', { min: 1, step: 1 }),
1313
+ numRow('maxFileBytes', '文件大小上限', '超过该大小的文件不进快照、不被回退触碰(单位 MB,支持小数)', { suffix: 'MB', min: 0.01, step: 0.5 }),
1314
+ numRow('maxSnapshotsPerWorkspace', '快照总量上限', '每个工作区保留的最大快照数,超限自动删除最旧的;填 0 表示不限制', { min: 0, step: 1 }),
1315
+ numRow('retentionDays', '快照保留天数', '按天数保留快照,超期自动删除最旧的;填 0 表示不启用(与快照总数上限各自生效)', { min: 0, step: 1 }),
1171
1316
  React.createElement('div', { className: 'dsh-recall-cfg-row', key: 'refillDraft' },
1172
1317
  React.createElement('div', { className: 'dsh-recall-cfg-line' },
1173
- React.createElement('label', { className: 'dsh-recall-cfg-label' }, '撤回后回填输入框'),
1318
+ React.createElement('label', { className: 'dsh-recall-cfg-label', htmlFor: 'dsh-recall-cfg-refill' }, '撤回后回填输入框'),
1174
1319
  React.createElement('input', {
1320
+ id: 'dsh-recall-cfg-refill',
1175
1321
  type: 'checkbox',
1176
1322
  checked: Boolean(draft.refillDraft),
1177
1323
  disabled: !writable,
@@ -1182,7 +1328,23 @@ window.__ModuleLoader__.load({
1182
1328
  ),
1183
1329
  React.createElement('div', { className: 'dsh-recall-cfg-hint' }, '撤回成功后把被撤回的消息文本回填到输入框,方便修改后重新发送')
1184
1330
  ),
1185
- React.createElement('div', { className: 'dsh-recall-cfg-row', key: 'baseExcludes' },
1331
+ React.createElement('div', { className: 'dsh-recall-cfg-row', key: 'archiveOriginal' },
1332
+ React.createElement('div', { className: 'dsh-recall-cfg-line' },
1333
+ React.createElement('label', { className: 'dsh-recall-cfg-label', htmlFor: 'dsh-recall-cfg-archive' }, '撤回后归档原会话'),
1334
+ React.createElement('input', {
1335
+ id: 'dsh-recall-cfg-archive',
1336
+ type: 'checkbox',
1337
+ checked: Boolean(draft.archiveOriginal),
1338
+ disabled: !writable,
1339
+ onChange: (e) => edit('archiveOriginal', e.target.checked),
1340
+ }),
1341
+ draft.archiveOriginal !== baseline.archiveOriginal ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已修改') : null,
1342
+ overridden && overridden.archiveOriginal !== undefined ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已覆盖') : null
1343
+ ),
1344
+ React.createElement('div', { className: 'dsh-recall-cfg-hint' }, '撤回后原会话从列表归档隐藏(可从归档找回);关闭则保留在列表中,方便对照回退前后的上下文')
1345
+ ),
1346
+ React.createElement(SectionToggle, { title: '高级:基础排除表', open: showAdvanced, onToggle: () => setShowAdvanced((v) => !v) }),
1347
+ showAdvanced ? React.createElement('div', { className: 'dsh-recall-cfg-row', key: 'baseExcludes' },
1186
1348
  React.createElement('div', { className: 'dsh-recall-cfg-line' },
1187
1349
  React.createElement('label', { className: 'dsh-recall-cfg-label' }, '基础排除表'),
1188
1350
  draft.baseExcludes !== baseline.baseExcludes ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已修改') : null,
@@ -1195,11 +1357,18 @@ window.__ModuleLoader__.load({
1195
1357
  disabled: !writable,
1196
1358
  onChange: (e) => edit('baseExcludes', e.target.value),
1197
1359
  }),
1198
- React.createElement('div', { className: 'dsh-recall-cfg-hint' }, 'gitignore 语法,每行一条;优先级低于 exclude.txt,下一次快照/回退即时生效')
1199
- ),
1360
+ React.createElement('div', { className: 'dsh-recall-cfg-hint' }, '内置规则,每个工作区共享,建议保持默认;gitignore 语法每行一条,优先级低于「排除配置」里的 exclude.txt(S3-2 折叠)')
1361
+ ) : null,
1200
1362
  React.createElement('div', { className: 'dsh-recall-panel-actions' },
1201
- state.message ? React.createElement('span', { className: 'dsh-recall-ex-status' + (state.error ? ' dsh-recall-ex-status-error' : '') }, state.message) : null,
1363
+ state.message ? React.createElement('span', { className: 'dsh-recall-ex-status' + (state.error ? ' dsh-recall-ex-status-error' : ' dsh-recall-ex-status-success') }, state.message) : null,
1202
1364
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: state.busy || !writable, onClick: () => setDraft(Object.assign({}, baseline)) }, '放弃修改'),
1365
+ React.createElement('button', {
1366
+ type: 'button',
1367
+ className: 'dsh-recall-btn',
1368
+ disabled: state.busy || !writable,
1369
+ title: '把所有字段恢复到插件出厂默认值',
1370
+ onClick: resetDefaults
1371
+ }, '恢复默认'),
1203
1372
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: state.busy || !writable, onClick: save }, '保存'),
1204
1373
  !writable ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '只读设置源') : null
1205
1374
  )
@@ -1266,16 +1435,23 @@ window.__ModuleLoader__.load({
1266
1435
  // 整个插件;lowest renders,负值恰好覆盖默认渲染器实现撤回 UI。
1267
1436
  // priority -1 也可能被别的机器上的插件占用(同样抛冲突),所以
1268
1437
  // 递减重试三次——最坏情况只是撤回按钮不渲染,绝不让插件加载失败。
1269
- let mounted = false
1270
- for (let priority = -1; priority >= -3 && !mounted; priority--) {
1271
- try {
1272
- slots.inject('conversation.chat.node', () => slots.register(
1273
- { name: 'conversation.chat.node', key: 'user', priority },
1274
- UserRecallNode
1275
- ))
1276
- mounted = true
1277
- } catch (error) {
1278
- if (priority === -3) console.error('[dsh-recall-plugin] slot register failed:', error)
1438
+ // chat.node keyed key 与节点 UI 投影 kind 对齐:'user' 是常规
1439
+ // 用户消息;'steering' agent 运行中插入的转向指令——官方仍按
1440
+ // 用户气泡回显,但 keyed 'user' 不命中,会落到默认渲染、撤回按钮
1441
+ // 缺失(实测存储层 role 一致、仅 UI 投影 kind 不同)。两个 key
1442
+ // 各自独立注册,互不抢占。
1443
+ for (const slotKey of ['user', 'steering']) {
1444
+ let mounted = false
1445
+ for (let priority = -1; priority >= -3 && !mounted; priority--) {
1446
+ try {
1447
+ slots.inject('conversation.chat.node', () => slots.register(
1448
+ { name: 'conversation.chat.node', key: slotKey, priority },
1449
+ UserRecallNode
1450
+ ))
1451
+ mounted = true
1452
+ } catch (error) {
1453
+ if (priority === -3) console.error('[dsh-recall-plugin] slot register failed (' + slotKey + '):', error)
1454
+ }
1279
1455
  }
1280
1456
  }
1281
1457