dsh-recall-plugin 1.7.1 → 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
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * 这是 dsh.client bundle(exports["./client"]),由 client-modules 打包成
11
11
  * /plugins/<pkg>/client.js 注入页面;factory 内 require("react") 由平台
12
- * 模块表提供。零构建依赖:纯 JS + React.createElement
12
+ * 模块表提供。当前为纯 JS + React.createElement 直写。
13
13
  */
14
14
  window.__ModuleLoader__.load({
15
15
  id: "dsh-recall-plugin",
@@ -73,6 +73,7 @@ window.__ModuleLoader__.load({
73
73
  '.dsh-recall-ex-chip:hover{color:var(--dsw-alias-label-primary)}',
74
74
  '.dsh-recall-ex-status{margin-right:auto;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary)}',
75
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)}',
76
77
  '.dsh-recall-tree{display:flex;flex-direction:column;gap:2px;padding:4px 0}',
77
78
  '.dsh-recall-tree-node{display:flex;flex-direction:column;gap:1px}',
78
79
  '.dsh-recall-tree-row{display:flex;gap:6px;align-items:center;min-width:0;padding:2px 4px;border-radius:6px;cursor:default}',
@@ -175,7 +176,7 @@ window.__ModuleLoader__.load({
175
176
  // has:false 且不再重试,撤回按钮将永不出现。
176
177
  // init 顺带下发插件行为开关(refillDraft 等),存进 pluginConfig
177
178
  // 供撤回执行链读取——设置页改配置 + 重启后随下一次 init 刷新。
178
- const pluginConfig = { refillDraft: true }
179
+ const pluginConfig = { refillDraft: true, archiveOriginal: true }
179
180
  function ensureInit(sessionId) {
180
181
  if (!sessionId) return Promise.resolve()
181
182
  const cached = initMap.get(sessionId)
@@ -183,6 +184,7 @@ window.__ModuleLoader__.load({
183
184
  const done = api('init', { sessionId }).then((res) => {
184
185
  if (res && res.config && typeof res.config === 'object') {
185
186
  if (typeof res.config.refillDraft === 'boolean') pluginConfig.refillDraft = res.config.refillDraft
187
+ if (typeof res.config.archiveOriginal === 'boolean') pluginConfig.archiveOriginal = res.config.archiveOriginal
186
188
  }
187
189
  const notice = res && res.notice
188
190
  if (notice && notice.unsupported) {
@@ -472,9 +474,36 @@ window.__ModuleLoader__.load({
472
474
  if (recall.stage !== 'confirm') return
473
475
  const changes = recall.changes || []
474
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
475
482
  setRecall({ stage: 'executing', changes })
476
- api('execute', { messageId, sessionId }).then(async (res) => {
483
+ api('execute', { messageId, sessionId, previewTotal, previewAt: Date.now() }).then(async (res) => {
477
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
+ }
478
507
  setRecall({ stage: 'error', message: (res && (res.message || res.error)) || '回退失败' })
479
508
  return
480
509
  }
@@ -496,8 +525,10 @@ window.__ModuleLoader__.load({
496
525
  if (typeof sessionsSvc.open === 'function') sessionsSvc.open(childId)
497
526
  chatReverted = true
498
527
  fillTarget = childId
499
- // 回退前的原会话归档:只是从列表隐藏、可恢复,避免侧栏出现两个近似会话
500
- if (workspacesSvc && typeof workspacesSvc.archiveSession === 'function') {
528
+ // 回退前的原会话归档(可关,见设置页 archiveOriginal):只是从列表
529
+ // 隐藏、可恢复,避免侧栏出现两个近似会话;关闭时原会话
530
+ // 保留供对照
531
+ if (pluginConfig.archiveOriginal && workspacesSvc && typeof workspacesSvc.archiveSession === 'function') {
501
532
  workspacesSvc.archiveSession(sessionId).catch(() => {})
502
533
  }
503
534
  } else {
@@ -682,7 +713,7 @@ window.__ModuleLoader__.load({
682
713
  }, s))
683
714
  ),
684
715
  React.createElement('div', { className: 'dsh-recall-panel-actions' },
685
- 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,
686
717
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: !dirty || state.busy, onClick: discard }, '放弃修改'),
687
718
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn dsh-recall-btn-danger', disabled: !dirty || state.busy, onClick: save }, '保存')
688
719
  )
@@ -698,6 +729,21 @@ window.__ModuleLoader__.load({
698
729
  const [usage, setUsage] = React.useState(null)
699
730
  const [errors, setErrors] = React.useState(null)
700
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)
701
747
  // 冷会话标题在服务端要整日志解压(10 秒级),首屏不等它:列表
702
748
  // 先出(live/缓存标题),拿到后对缺标题的会话异步补拉再合并。
703
749
  const [titlesPending, setTitlesPending] = React.useState(false)
@@ -746,10 +792,12 @@ window.__ModuleLoader__.load({
746
792
  }).catch(() => {})
747
793
  }
748
794
 
749
- function refresh() {
750
- api('manage', { op: 'list' }).then((res) => {
795
+ function refresh(overLimit) {
796
+ const useLimit = overLimit || limit
797
+ api('manage', { op: 'list', limit: useLimit }).then((res) => {
751
798
  if (res && res.ok) {
752
799
  setItems(res.items || [])
800
+ setTotal(typeof res.total === 'number' ? res.total : (res.items || []).length)
753
801
  fetchTitles(res.items || [])
754
802
  fetchMessages(res.items || [])
755
803
  }
@@ -758,7 +806,10 @@ window.__ModuleLoader__.load({
758
806
  // 渲染后,用户先看到树形内容,占用再异步补上(usage 不带
759
807
  // sessionId,Host 汇总全部工作区)。
760
808
  api('manage', { op: 'usage' }).then((res) => {
761
- 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
+ }
762
813
  }).catch(() => {})
763
814
  api('status', {}).then((res) => {
764
815
  if (res && res.ok) setErrors(res.errors || [])
@@ -766,7 +817,10 @@ window.__ModuleLoader__.load({
766
817
  }).catch(() => {
767
818
  // list 失败时仍尝试补 usage/status,避免整卡全空
768
819
  api('manage', { op: 'usage' }).then((res) => {
769
- 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
+ }
770
824
  }).catch(() => {})
771
825
  api('status', {}).then((res) => {
772
826
  if (res && res.ok) setErrors(res.errors || [])
@@ -776,6 +830,13 @@ window.__ModuleLoader__.load({
776
830
 
777
831
  React.useEffect(() => { refresh() }, [])
778
832
 
833
+ // S3-5:清空 Host 侧错误缓冲并同步本地状态;Host 已清后下次
834
+ // refresh 拉回为空,不重复请求。
835
+ function clearErrors() {
836
+ setErrors([])
837
+ api('status', { op: 'clear' }).catch(() => {})
838
+ }
839
+
779
840
  function run(op, extra, doneText) {
780
841
  if (state.busy) return
781
842
  setState({ busy: true, message: '执行中…', error: false })
@@ -795,17 +856,17 @@ window.__ModuleLoader__.load({
795
856
  function renderDeleteAllConfirm() {
796
857
  if (!confirming || confirming.kind !== 'all') return null
797
858
  return React.createElement('div', { className: 'dsh-recall-tree-confirm' },
798
- '确认删除所有工作区的全部快照?此操作不可恢复。',
799
- React.createElement('button', {
800
- type: 'button',
801
- className: 'dsh-recall-ex-chip',
802
- onClick: () => {
803
- setConfirming(null)
804
- run('deleteAll', {}, '已清空全部快照')
805
- }
806
- }, '确认全部删除'),
807
- React.createElement('button', { type: 'button', className: 'dsh-recall-ex-chip', onClick: () => setConfirming(null) }, '取消')
808
- )
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
+ )
809
870
  }
810
871
 
811
872
  // 树形管理:工作区 → 会话 → 快照三级。折叠状态用 Set<key> 记录,
@@ -843,7 +904,16 @@ window.__ModuleLoader__.load({
843
904
  }
844
905
  return wsList
845
906
  }
846
- 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)
847
917
  // 每个节点的删除按钮统一走 run();workspace/session 用新端点 scope
848
918
  // 批量删,叶子沿用原单条删除。二次确认文案按节点层级区分。
849
919
  function confirmDelete(kind, key, extra, text) {
@@ -948,21 +1018,63 @@ window.__ModuleLoader__.load({
948
1018
  }
949
1019
  const treeNodes = tree.map(renderWorkspace)
950
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
+
951
1038
  return React.createElement('div', { className: 'dsh-recall-ex-card' },
952
1039
  React.createElement('div', { className: 'dsh-recall-ex-title' }, '快照管理'),
953
1040
  React.createElement('div', { className: 'dsh-recall-ex-note' },
954
1041
  usage === null
955
- ? '共 ' + (items ? items.length : '') + ' 条快照。'
956
- : '共 ' + (items ? items.length : '…') + ' 条快照,全部工作区快照存储占用 ' + sizeText(usage) + '。'
1042
+ ? countText + ''
1043
+ : countText + ',全部工作区快照存储占用 ' + sizeText(usage) + '。'
957
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
+ }),
958
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,
959
1065
  renderDeleteAllConfirm(),
960
1066
  React.createElement('div', { className: 'dsh-recall-panel-actions' },
961
- 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,
962
1074
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: state.busy, onClick: refresh }, '刷新'),
963
1075
  React.createElement('button', {
964
1076
  type: 'button',
965
- className: 'dsh-recall-btn',
1077
+ className: 'dsh-recall-btn dsh-recall-btn-danger',
966
1078
  disabled: state.busy,
967
1079
  title: '删除全部工作区的所有快照;会直接核对并删除 git tag(即使列表为空也可清理残留)',
968
1080
  onClick: () => setConfirming({ kind: 'all' })
@@ -976,9 +1088,15 @@ window.__ModuleLoader__.load({
976
1088
  }, '立即 gc')
977
1089
  ),
978
1090
  errors && errors.length > 0
979
- ? React.createElement('div', { className: 'dsh-recall-ex-note' },
980
- '最近错误:',
981
- 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
+ )
982
1100
  )
983
1101
  : null
984
1102
  )
@@ -1021,6 +1139,16 @@ window.__ModuleLoader__.load({
1021
1139
  )
1022
1140
  }
1023
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
+
1024
1152
  // 插件配置表单:值经 Host 的 settings namespace「dsh-recall」读写
1025
1153
  // (config-get / config-set 端点),保存即持久化进用户 settings 文档
1026
1154
  // 并热生效(Host watch 链路原地更新 cfg),无需重启。只提交相对
@@ -1032,6 +1160,9 @@ window.__ModuleLoader__.load({
1032
1160
  const [overridden, setOverridden] = React.useState({})
1033
1161
  const [writable, setWritable] = React.useState(true)
1034
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)
1035
1166
 
1036
1167
  function load() {
1037
1168
  api('config-get', {}).then((res) => {
@@ -1040,9 +1171,13 @@ window.__ModuleLoader__.load({
1040
1171
  const next = {
1041
1172
  gcSnaps: String(v.gcSnaps == null ? '' : v.gcSnaps),
1042
1173
  gcHours: String(v.gcHours == null ? '' : v.gcHours),
1043
- maxFileBytes: String(v.maxFileBytes == null ? '' : v.maxFileBytes),
1174
+ maxFileBytes: bytesToMb(v.maxFileBytes),
1175
+ maxSnapshotsPerWorkspace: String(v.maxSnapshotsPerWorkspace == null ? '' : v.maxSnapshotsPerWorkspace),
1044
1176
  baseExcludes: Array.isArray(v.baseExcludes) ? v.baseExcludes.join('\n') : '',
1045
1177
  refillDraft: v.refillDraft !== false,
1178
+ snapshotEnabled: v.snapshotEnabled !== false,
1179
+ archiveOriginal: v.archiveOriginal !== false,
1180
+ retentionDays: String(v.retentionDays == null ? '' : v.retentionDays),
1046
1181
  }
1047
1182
  setDraft(next)
1048
1183
  setBaseline(next)
@@ -1064,7 +1199,7 @@ window.__ModuleLoader__.load({
1064
1199
  function save() {
1065
1200
  if (state.busy || !draft || !baseline) return
1066
1201
  const patch = {}
1067
- for (const key of ['gcSnaps', 'gcHours', 'maxFileBytes', 'baseExcludes', 'refillDraft']) {
1202
+ for (const key of ['gcSnaps', 'gcHours', 'maxFileBytes', 'maxSnapshotsPerWorkspace', 'baseExcludes', 'refillDraft', 'snapshotEnabled', 'archiveOriginal', 'retentionDays']) {
1068
1203
  if (draft[key] !== baseline[key]) patch[key] = draft[key]
1069
1204
  }
1070
1205
  if (!Object.keys(patch).length) {
@@ -1083,11 +1218,25 @@ window.__ModuleLoader__.load({
1083
1218
  clean.gcHours = n
1084
1219
  }
1085
1220
  if (patch.maxFileBytes !== undefined) {
1086
- const n = parseInt(patch.maxFileBytes, 10)
1087
- if (!Number.isFinite(n) || n < 1024) { setState({ busy: false, message: '文件大小上限必须是 >= 1024 的字节数', error: true }); return }
1088
- 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
1089
1231
  }
1090
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
+ }
1091
1240
  if (patch.baseExcludes !== undefined) {
1092
1241
  clean.baseExcludes = String(patch.baseExcludes).split('\n').map((l) => l.trim()).filter(Boolean)
1093
1242
  }
@@ -1102,7 +1251,7 @@ window.__ModuleLoader__.load({
1102
1251
  }).catch((e) => setState({ busy: false, message: String(e), error: true }))
1103
1252
  }
1104
1253
 
1105
- function numRow(key, label, hint) {
1254
+ function numRow(key, label, hint, opts) {
1106
1255
  const locked = Boolean(envLocks && envLocks[key])
1107
1256
  const changed = Boolean(draft && baseline && draft[key] !== baseline[key])
1108
1257
  return React.createElement('div', { className: 'dsh-recall-cfg-row', key: key },
@@ -1113,8 +1262,11 @@ window.__ModuleLoader__.load({
1113
1262
  type: 'number',
1114
1263
  value: draft ? draft[key] : '',
1115
1264
  disabled: locked || !writable,
1265
+ min: opts && opts.min,
1266
+ step: opts && opts.step,
1116
1267
  onChange: (e) => edit(key, e.target.value),
1117
1268
  }),
1269
+ opts && opts.suffix ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, opts.suffix) : null,
1118
1270
  changed && !locked ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已修改') : null,
1119
1271
  overridden && overridden[key] !== undefined ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已覆盖') : null,
1120
1272
  locked ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '环境变量锁定') : null
@@ -1123,18 +1275,49 @@ window.__ModuleLoader__.load({
1123
1275
  )
1124
1276
  }
1125
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
+
1126
1291
  if (!draft) {
1127
1292
  return React.createElement('div', { className: 'dsh-recall-ex-note' }, state.message || '正在读取配置…')
1128
1293
  }
1129
1294
 
1130
1295
  return React.createElement('div', { className: 'dsh-recall-ex-card' },
1131
- numRow('gcSnaps', 'gc 触发条数', '每积累多少条快照触发一次 git gc'),
1132
- numRow('gcHours', 'gc 触发小时', '距上次 gc 超过多少小时触发(与条数先到先触发)'),
1133
- 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 }),
1134
1316
  React.createElement('div', { className: 'dsh-recall-cfg-row', key: 'refillDraft' },
1135
1317
  React.createElement('div', { className: 'dsh-recall-cfg-line' },
1136
- React.createElement('label', { className: 'dsh-recall-cfg-label' }, '撤回后回填输入框'),
1318
+ React.createElement('label', { className: 'dsh-recall-cfg-label', htmlFor: 'dsh-recall-cfg-refill' }, '撤回后回填输入框'),
1137
1319
  React.createElement('input', {
1320
+ id: 'dsh-recall-cfg-refill',
1138
1321
  type: 'checkbox',
1139
1322
  checked: Boolean(draft.refillDraft),
1140
1323
  disabled: !writable,
@@ -1145,7 +1328,23 @@ window.__ModuleLoader__.load({
1145
1328
  ),
1146
1329
  React.createElement('div', { className: 'dsh-recall-cfg-hint' }, '撤回成功后把被撤回的消息文本回填到输入框,方便修改后重新发送')
1147
1330
  ),
1148
- 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' },
1149
1348
  React.createElement('div', { className: 'dsh-recall-cfg-line' },
1150
1349
  React.createElement('label', { className: 'dsh-recall-cfg-label' }, '基础排除表'),
1151
1350
  draft.baseExcludes !== baseline.baseExcludes ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已修改') : null,
@@ -1158,11 +1357,18 @@ window.__ModuleLoader__.load({
1158
1357
  disabled: !writable,
1159
1358
  onChange: (e) => edit('baseExcludes', e.target.value),
1160
1359
  }),
1161
- React.createElement('div', { className: 'dsh-recall-cfg-hint' }, 'gitignore 语法,每行一条;优先级低于 exclude.txt,下一次快照/回退即时生效')
1162
- ),
1360
+ React.createElement('div', { className: 'dsh-recall-cfg-hint' }, '内置规则,每个工作区共享,建议保持默认;gitignore 语法每行一条,优先级低于「排除配置」里的 exclude.txt(S3-2 折叠)')
1361
+ ) : null,
1163
1362
  React.createElement('div', { className: 'dsh-recall-panel-actions' },
1164
- 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,
1165
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
+ }, '恢复默认'),
1166
1372
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: state.busy || !writable, onClick: save }, '保存'),
1167
1373
  !writable ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '只读设置源') : null
1168
1374
  )
@@ -1229,16 +1435,23 @@ window.__ModuleLoader__.load({
1229
1435
  // 整个插件;lowest renders,负值恰好覆盖默认渲染器实现撤回 UI。
1230
1436
  // priority -1 也可能被别的机器上的插件占用(同样抛冲突),所以
1231
1437
  // 递减重试三次——最坏情况只是撤回按钮不渲染,绝不让插件加载失败。
1232
- let mounted = false
1233
- for (let priority = -1; priority >= -3 && !mounted; priority--) {
1234
- try {
1235
- slots.inject('conversation.chat.node', () => slots.register(
1236
- { name: 'conversation.chat.node', key: 'user', priority },
1237
- UserRecallNode
1238
- ))
1239
- mounted = true
1240
- } catch (error) {
1241
- 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
+ }
1242
1455
  }
1243
1456
  }
1244
1457
 
package/lib/config.js CHANGED
@@ -19,18 +19,36 @@ export const Config = Schema.object({
19
19
  gcSnaps: Schema.number().default(50).description('每积累多少条快照触发一次 git gc'),
20
20
  gcHours: Schema.number().default(24).description('距上次 gc 超过多少小时触发(与条数先到先触发)'),
21
21
  maxFileBytes: Schema.number().default(104857600).description('超过该字节数的文件不进快照、不被回退触碰'),
22
+ maxSnapshotsPerWorkspace: Schema.number().default(500).description('每个工作区保留的最大快照数,超限删除最旧的'),
22
23
  // 排除表必须同时覆盖两种存储目录名:降级存储是项目内 .dsh-recall-snapshots/,
23
24
  // 而 home 存储目录名是 dsh-recall-snapshots/(无点)——工作区 root 恰为
24
25
  // HOME 时(容器 root=/root 等)它落在工作区内,漏排除会让 git add -A
25
26
  // 把影子仓库自己吞进去、快照全部失败(issue #6)
26
27
  baseExcludes: Schema.array(Schema.string()).default(['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']).description('基础排除表(gitignore 语法,优先级低于 exclude.txt)'),
27
28
  refillDraft: Schema.boolean().default(true).description('撤回后把被撤回的消息文本回填到输入框'),
29
+ snapshotEnabled: Schema.boolean().default(true).description('启用消息快照(关闭后不再新建,已有快照仍可撤回)'),
30
+ archiveOriginal: Schema.boolean().default(true).description('撤回后归档原会话(关闭后原会话保留在列表中)'),
31
+ retentionDays: Schema.number().default(0).description('按天数保留快照,超期自动删除;0 表示不启用'),
28
32
  })
29
33
 
30
34
  // schema 默认值的运行时镜像:settings 服务未组装时 createConfig 直接以
31
- // 入口 config 解析,这组兜底与 Config 保持一致(改默认值两处同步改)
35
+ // 入口 config 解析,这组兜底与 Config 保持一致(改默认值两处同步改)。
36
+ // DEFAULTS 同时供 config-reset 降级路径(settings.replace 不可用时的兜底,
37
+ // 见 index.js config-reset 端点)——默认值只此一份,避免重置与 schema 漂移。
32
38
  const BASE_EXCLUDES = ['.git', 'node_modules/', '.dsh-recall-snapshots/', 'dsh-recall-snapshots/']
33
39
 
40
+ export const DEFAULTS = {
41
+ gcSnaps: 50,
42
+ gcHours: 24,
43
+ maxFileBytes: 104857600,
44
+ maxSnapshotsPerWorkspace: 500,
45
+ baseExcludes: BASE_EXCLUDES,
46
+ refillDraft: true,
47
+ snapshotEnabled: true,
48
+ archiveOriginal: true,
49
+ retentionDays: 0,
50
+ }
51
+
34
52
  export function createConfig(raw) {
35
53
  const cfg = raw && typeof raw === 'object' ? raw : {}
36
54
 
@@ -44,6 +62,13 @@ export function createConfig(raw) {
44
62
  const gcSnaps = pickNumber(process.env.DSH_RECALL_GC_SNAPS, pickNumber(cfg.gcSnaps, 50, 1), 1)
45
63
  const gcHours = pickNumber(process.env.DSH_RECALL_GC_HOURS, pickNumber(cfg.gcHours, 24, 1), 1)
46
64
  const maxFileBytes = pickNumber(cfg.maxFileBytes, 104857600, 1024)
65
+ // 每工作区快照上限:0 或负值语义 = 不限制(给想全保留的用户出口);
66
+ // 非数值回退默认 500。默认 500 ≈ 重度使用一周量级,太小会静默丢历史
67
+ // 撤回点,太大失去防膨胀意义。
68
+ const rawMax = typeof cfg.maxSnapshotsPerWorkspace === 'number'
69
+ ? cfg.maxSnapshotsPerWorkspace
70
+ : parseInt(String(cfg.maxSnapshotsPerWorkspace == null ? '' : cfg.maxSnapshotsPerWorkspace), 10)
71
+ const maxSnapshotsPerWorkspace = Number.isFinite(rawMax) ? Math.max(0, rawMax) : 500
47
72
 
48
73
  const baseExcludes = Array.isArray(cfg.baseExcludes) && cfg.baseExcludes.length
49
74
  ? cfg.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
@@ -51,5 +76,21 @@ export function createConfig(raw) {
51
76
 
52
77
  const refillDraft = typeof cfg.refillDraft === 'boolean' ? cfg.refillDraft : true
53
78
 
54
- return { gcSnaps, gcHours, maxFileBytes, baseExcludes, refillDraft }
79
+ // 快照总开关:false 冻结「新建」(session/event 短路,见 index.js),
80
+ // 已有快照的撤回链路不受影响——关闭只停增量,不销毁存量。
81
+ const snapshotEnabled = typeof cfg.snapshotEnabled === 'boolean' ? cfg.snapshotEnabled : true
82
+
83
+ // 撤回后是否归档原会话:关闭时原会话保留在侧栏(fork 新会话仍打开),
84
+ // 供用户对照回退前后上下文;默认开(归档只是隐藏、可恢复)。
85
+ const archiveOriginal = typeof cfg.archiveOriginal === 'boolean' ? cfg.archiveOriginal : true
86
+
87
+ // 按时间保留(S2-3):0 或负值 = 不启用(静默删历史撤回点必须显式
88
+ // opt-in);非数值回退 0。与 maxSnapshotsPerWorkspace(条数维度)并存,
89
+ // 各自独立触发——见 maintenance.enforceRetention。
90
+ const rawDays = typeof cfg.retentionDays === 'number'
91
+ ? cfg.retentionDays
92
+ : parseInt(String(cfg.retentionDays == null ? '' : cfg.retentionDays), 10)
93
+ const retentionDays = Number.isFinite(rawDays) ? Math.max(0, rawDays) : 0
94
+
95
+ return { gcSnaps, gcHours, maxFileBytes, maxSnapshotsPerWorkspace, baseExcludes, refillDraft, snapshotEnabled, archiveOriginal, retentionDays }
55
96
  }