dsh-recall-plugin 1.5.0 → 1.6.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
@@ -63,7 +63,6 @@ window.__ModuleLoader__.load({
63
63
  '.dsh-recall-toast{position:fixed;top:18px;left:50%;transform:translateX(-50%);z-index:10000;max-width:min(560px,86vw);box-sizing:border-box;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:10px 16px;font-size:13px;line-height:20px;box-shadow:0 8px 28px rgba(0,0,0,.22);display:flex;align-items:baseline;gap:8px;opacity:0;transition:opacity .25s ease;pointer-events:auto}',
64
64
  '.dsh-recall-toast.dsh-recall-toast-in{opacity:1}',
65
65
  '.dsh-recall-toast-tag{flex:none;font-weight:600;color:var(--dsw-alias-state-error-primary)}',
66
- '.dsh-recall-ex-tab{display:flex;flex-direction:column;gap:16px;max-width:720px;padding:4px 0;text-align:left}',
67
66
  '.dsh-recall-ex-card{display:flex;flex-direction:column;gap:8px}',
68
67
  '.dsh-recall-ex-title{color:var(--dsw-alias-label-primary);font-size:14px;font-weight:600;line-height:22px}',
69
68
  '.dsh-recall-ex-note{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;word-break:break-word}',
@@ -86,7 +85,22 @@ window.__ModuleLoader__.load({
86
85
  '.dsh-recall-tree-title{min-width:0;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
87
86
  '.dsh-recall-tree-meta{flex:none;font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
88
87
  '.dsh-recall-tree-children{display:flex;flex-direction:column;gap:1px;margin-left:16px;border-left:1px solid var(--dsw-alias-border-l1);padding-left:8px}',
89
- '.dsh-recall-tree-confirm{display:flex;gap:8px;align-items:center;font-size:12px;line-height:20px;color:var(--dsw-alias-label-secondary)}'
88
+ '.dsh-recall-tree-confirm{display:flex;gap:8px;align-items:center;font-size:12px;line-height:20px;color:var(--dsw-alias-label-secondary)}',
89
+ '.dsh-recall-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s;display:flex;flex-direction:column;text-align:left}',
90
+ '.dsh-recall-card.dsh-recall-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}',
91
+ '.dsh-recall-card-head{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}',
92
+ '.dsh-recall-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}',
93
+ '.dsh-recall-card-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}',
94
+ '.dsh-recall-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0 8px;display:flex;flex-direction:column;gap:12px}',
95
+ '.dsh-recall-cardbtn{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}',
96
+ '.dsh-recall-cfg-row{display:flex;flex-direction:column;gap:4px}',
97
+ '.dsh-recall-cfg-line{display:flex;align-items:center;gap:8px}',
98
+ '.dsh-recall-cfg-label{flex:none;width:130px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}',
99
+ '.dsh-recall-cfg-input{flex:1;min-width:0;box-sizing:border-box;font:inherit;font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:4px 8px}',
100
+ '.dsh-recall-cfg-input:disabled{opacity:.5}',
101
+ '.dsh-recall-cfg-area{font-family:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 8px;min-height:64px}',
102
+ '.dsh-recall-cfg-hint{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;padding-left:138px}',
103
+ '.dsh-recall-cfg-tag{flex:none;font-size:11px;line-height:16px;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-tertiary)}'
90
104
  ].join('')
91
105
  // 静态 bundle 的 ctx 可能不提供 styles 服务,降级为直接注入 <style>
92
106
  const stylesSvc = ctx.get('styles')
@@ -140,11 +154,17 @@ window.__ModuleLoader__.load({
140
154
  // 返回 init 的 promise:Host 端 init 要跑数条 PowerShell(建仓/loadIndex),
141
155
  // snapshot-info 必须等它完成后再查,否则冷启动时索引尚未载入会误判
142
156
  // has:false 且不再重试,撤回按钮将永不出现。
157
+ // init 顺带下发插件行为开关(refillDraft 等),存进 pluginConfig
158
+ // 供撤回执行链读取——设置页改配置 + 重启后随下一次 init 刷新。
159
+ const pluginConfig = { refillDraft: true }
143
160
  function ensureInit(sessionId) {
144
161
  if (!sessionId) return Promise.resolve()
145
162
  const cached = initMap.get(sessionId)
146
163
  if (cached) return cached
147
164
  const done = api('init', { sessionId }).then((res) => {
165
+ if (res && res.config && typeof res.config === 'object') {
166
+ if (typeof res.config.refillDraft === 'boolean') pluginConfig.refillDraft = res.config.refillDraft
167
+ }
148
168
  const notice = res && res.notice
149
169
  if (notice && notice.unsupported) {
150
170
  showNotice('unsupported', '撤回插件仅支持 Windows / Linux / macOS,当前平台的快照不可用。')
@@ -352,14 +372,46 @@ window.__ModuleLoader__.load({
352
372
 
353
373
  React.useEffect(() => {
354
374
  let alive = true
355
- // 先等 init 预热完成再查快照存在性:避免索引未载入时误判 has:false
375
+ let timer = null
376
+ let attempts = 0
377
+ // 快照捕获(Host 侧脚本)是异步的:消息节点挂载时 snapshot-info
378
+ // 可能先于捕获完成而返回 has:false。此前只查一次、失败不重试,
379
+ // 撤回按钮只能靠手动刷新才出现。改为有界轮询:has:true 或达到
380
+ // 上限即停(覆盖常规快照耗时,又避免无限请求),捕获完成后
381
+ // 按钮自动出现。
382
+ // 只对「近 5 分钟内」的消息轮询:快照只在消息发送当下捕获,
383
+ // 老消息若没有快照就永远不会再有,对它们轮询只会白打请求。
384
+ const RETRY_WINDOW_MS = 5 * 60 * 1000
385
+ const MAX_ATTEMPTS = 20
386
+ const RETRY_MS = 1000
387
+ const msgTime = data && typeof data.time === 'number' ? data.time : NaN
388
+ const recent = !isNaN(msgTime) && Date.now() - msgTime <= RETRY_WINDOW_MS
389
+ function schedule() {
390
+ if (!alive || !messageId) return
391
+ attempts++
392
+ api('snapshot-info', { messageId }).then((res) => {
393
+ if (!alive) return
394
+ if (res && res.has) {
395
+ setHasSnapshot(true)
396
+ return
397
+ }
398
+ if (recent && attempts < MAX_ATTEMPTS) timer = setTimeout(schedule, RETRY_MS)
399
+ }).catch(() => {
400
+ if (alive && recent && attempts < MAX_ATTEMPTS) timer = setTimeout(schedule, RETRY_MS)
401
+ })
402
+ }
403
+ // 先等 init 预热完成再查快照存在性:避免索引未载入时误判
404
+ // has:false;init 本身失败也进入轮询(预热可能只是慢,不是坏)。
356
405
  ensureInit(sessionId).then(() => {
357
406
  if (!messageId || !alive) return
358
- return api('snapshot-info', { messageId }).then((res) => {
359
- if (alive && res && res.has) setHasSnapshot(true)
360
- }).catch(() => {})
407
+ schedule()
408
+ }).catch(() => {
409
+ if (alive && messageId) timer = setTimeout(schedule, RETRY_MS)
361
410
  })
362
- return () => { alive = false }
411
+ return () => {
412
+ alive = false
413
+ if (timer !== null) clearTimeout(timer)
414
+ }
363
415
  }, [messageId, sessionId])
364
416
 
365
417
  const onCopy = () => {
@@ -410,6 +462,9 @@ window.__ModuleLoader__.load({
410
462
  const cutSeq = typeof res.cutSeq === 'number' ? res.cutSeq : previewCut
411
463
  let chatReverted = false
412
464
  let chatError = ''
465
+ // 回填目标会话:对话回退成功 → fork 出的新会话(视图已切过去);
466
+ // 失败/无切点 → 当前会话
467
+ let fillTarget = sessionId
413
468
  if (cutSeq !== null && sessionsSvc && typeof sessionsSvc.fork === 'function') {
414
469
  try {
415
470
  // 撤回语义是「回退」而非「复制」:新会话顶替原会话(原会话已
@@ -420,6 +475,7 @@ window.__ModuleLoader__.load({
420
475
  if (childId) {
421
476
  if (typeof sessionsSvc.open === 'function') sessionsSvc.open(childId)
422
477
  chatReverted = true
478
+ fillTarget = childId
423
479
  // 回退前的原会话归档:只是从列表隐藏、可恢复,避免侧栏出现两个近似会话
424
480
  if (workspacesSvc && typeof workspacesSvc.archiveSession === 'function') {
425
481
  workspacesSvc.archiveSession(sessionId).catch(() => {})
@@ -431,6 +487,10 @@ window.__ModuleLoader__.load({
431
487
  chatError = String(error)
432
488
  }
433
489
  }
490
+ // 把被撤回的消息文本回填到输入框(可在设置页关闭):方便用户
491
+ // 修改后重新发送。拿不到官方 conversation 服务时静默跳过,
492
+ // 绝不阻塞撤回主流程。
493
+ if (pluginConfig.refillDraft) fillDraft(fillTarget, text)
434
494
  setHasSnapshot(false)
435
495
  // 注:快照 tag 在 Host 侧有意保留(幂等回退),刷新页面后
436
496
  // 该消息的撤回按钮会重新出现——这是「可再次回退到同一点」
@@ -444,6 +504,37 @@ window.__ModuleLoader__.load({
444
504
 
445
505
  const closePanel = () => setRecall({ stage: 'idle' })
446
506
 
507
+ // 撤回后把被撤回消息的文本回填到输入框,方便用户修改后重新发送。
508
+ // 官方 conversation 服务提供 input(InputHub)→ per-session shell
509
+ // → actions.setDraft,走与输入框自身同一条官方写入通道(draft
510
+ // 镜像同步,输入框即时刷新)。fork + open 之后 shell 可能需要
511
+ // 一个 tick 才就绪(binding 异步解析),做有界重试:最多 8 次、
512
+ // 间隔 150ms,覆盖常规竞态又不无限空转;拿不到服务时静默跳过。
513
+ function fillDraft(targetSessionId, draftText) {
514
+ if (!draftText || !targetSessionId) return
515
+ let attempts = 0
516
+ const attempt = () => {
517
+ try {
518
+ const conversation = ctx.get('conversation')
519
+ if (conversation && conversation.input && typeof conversation.input.shell === 'function') {
520
+ const shell = conversation.input.shell(targetSessionId)
521
+ if (shell) {
522
+ if (shell.actions && typeof shell.actions.setDraft === 'function') {
523
+ shell.actions.setDraft(draftText)
524
+ return
525
+ }
526
+ if (typeof shell.setDraft === 'function') {
527
+ shell.setDraft(draftText)
528
+ return
529
+ }
530
+ }
531
+ }
532
+ } catch (e) { /* fall through to retry */ }
533
+ if (attempts++ < 8) setTimeout(attempt, 150)
534
+ }
535
+ attempt()
536
+ }
537
+
447
538
  const bubbleChildren = []
448
539
  if (text !== '') bubbleChildren.push(React.createElement('div', { className: 'dsh-recall-bubble', key: 'text' }, text))
449
540
  for (let i = 0; i < images.length; i++) {
@@ -579,7 +670,6 @@ window.__ModuleLoader__.load({
579
670
  // 清理与排障入口;全部操作走 Host 的 manage/status 端点(串行队列
580
671
  // 在 Host 侧保证,卡片只管发请求)。
581
672
  function ManageCard(props) {
582
- const sessionId = props.sessionId
583
673
  const [items, setItems] = React.useState(null)
584
674
  const [usage, setUsage] = React.useState(null)
585
675
  const [errors, setErrors] = React.useState(null)
@@ -639,23 +729,33 @@ window.__ModuleLoader__.load({
639
729
  fetchTitles(res.items || [])
640
730
  fetchMessages(res.items || [])
641
731
  }
642
- }).catch(() => {})
643
- if (sessionId) {
644
- api('manage', { op: 'usage', sessionId }).then((res) => {
732
+ // 列表返回后再补 usage/status:首次冷启动时磁盘占用和错误日志
733
+ // 都各要一条 shell,和 list 并发会抢资源拖慢首屏;延后到列表
734
+ // 渲染后,用户先看到树形内容,占用再异步补上(usage 不带
735
+ // sessionId,Host 汇总全部工作区)。
736
+ api('manage', { op: 'usage' }).then((res) => {
645
737
  if (res && res.ok) setUsage(res.bytes || 0)
646
738
  }).catch(() => {})
647
- }
648
- api('status', {}).then((res) => {
649
- if (res && res.ok) setErrors(res.errors || [])
650
- }).catch(() => {})
739
+ api('status', {}).then((res) => {
740
+ if (res && res.ok) setErrors(res.errors || [])
741
+ }).catch(() => {})
742
+ }).catch(() => {
743
+ // list 失败时仍尝试补 usage/status,避免整卡全空
744
+ api('manage', { op: 'usage' }).then((res) => {
745
+ if (res && res.ok) setUsage(res.bytes || 0)
746
+ }).catch(() => {})
747
+ api('status', {}).then((res) => {
748
+ if (res && res.ok) setErrors(res.errors || [])
749
+ }).catch(() => {})
750
+ })
651
751
  }
652
752
 
653
- React.useEffect(() => { refresh() }, [sessionId])
753
+ React.useEffect(() => { refresh() }, [])
654
754
 
655
755
  function run(op, extra, doneText) {
656
756
  if (state.busy) return
657
757
  setState({ busy: true, message: '执行中…', error: false })
658
- api('manage', Object.assign({ op, sessionId }, extra || {})).then((res) => {
758
+ api('manage', Object.assign({ op }, extra || {})).then((res) => {
659
759
  if (res && res.ok) {
660
760
  setState({ busy: false, message: typeof res.deleted === 'number' ? '已删除 ' + res.deleted + ' 条快照' : doneText, error: false })
661
761
  refresh()
@@ -810,7 +910,7 @@ window.__ModuleLoader__.load({
810
910
  React.createElement('div', { className: 'dsh-recall-ex-note' },
811
911
  usage === null
812
912
  ? '共 ' + (items ? items.length : '…') + ' 条快照。'
813
- : '共 ' + (items ? items.length : '…') + ' 条快照,当前工作区快照存储占用 ' + sizeText(usage) + '。'
913
+ : '共 ' + (items ? items.length : '…') + ' 条快照,全部工作区快照存储占用 ' + sizeText(usage) + '。'
814
914
  ),
815
915
  treeNodes.length > 0 ? React.createElement('div', { className: 'dsh-recall-tree' }, ...treeNodes) : null,
816
916
  React.createElement('div', { className: 'dsh-recall-panel-actions' },
@@ -819,8 +919,8 @@ window.__ModuleLoader__.load({
819
919
  React.createElement('button', {
820
920
  type: 'button',
821
921
  className: 'dsh-recall-btn',
822
- disabled: state.busy || !sessionId,
823
- title: '立即执行一次 git gc(压缩对象库释放空间)',
922
+ disabled: state.busy,
923
+ title: '立即对全部工作区执行一次 git gc(压缩对象库释放空间)',
824
924
  onClick: () => run('gc', {}, 'gc 完成')
825
925
  }, '立即 gc')
826
926
  ),
@@ -833,10 +933,10 @@ window.__ModuleLoader__.load({
833
933
  )
834
934
  }
835
935
 
836
- // 「插件」设置分区的标签页主体:拉取 Host 枚举的 exclude 文件列表
837
- // (home 存储通常合并为一条,降级工作区各一条)。标签页被选中后
838
- // 由设置外壳保持挂载,本地草稿在标签间切换时不丢失。
839
- function RecallSettingsTab(props) {
936
+ // 排除配置分区:拉取 Host 枚举的 exclude 文件列表(home 存储通常
937
+ // 合并为一条,降级工作区各一条)。折叠展开时由设置外壳保持挂载,
938
+ // 本地草稿在折叠期间不丢失。
939
+ function ExcludeFilesSection() {
840
940
  const [files, setFiles] = React.useState(null)
841
941
  const [error, setError] = React.useState('')
842
942
 
@@ -850,29 +950,226 @@ window.__ModuleLoader__.load({
850
950
 
851
951
  React.useEffect(() => { load() }, [])
852
952
 
853
- let body = null
854
953
  if (error) {
855
- body = React.createElement('div', { className: 'dsh-recall-ex-card' },
954
+ return React.createElement('div', { className: 'dsh-recall-ex-card' },
856
955
  React.createElement('div', { className: 'dsh-recall-ex-note' }, error),
857
956
  React.createElement('div', { className: 'dsh-recall-panel-actions' },
858
957
  React.createElement('button', { type: 'button', className: 'dsh-recall-btn', onClick: load }, '重试')
859
958
  )
860
959
  )
861
- } else if (files === null) {
862
- body = React.createElement('div', { className: 'dsh-recall-ex-note' }, '正在加载排除配置…')
863
- } else if (!files.length) {
960
+ }
961
+ if (files === null) {
962
+ return React.createElement('div', { className: 'dsh-recall-ex-note' }, '正在加载排除配置…')
963
+ }
964
+ if (!files.length) {
864
965
  // 全新安装且从未发过消息:store 还没建,先告诉用户怎么触发
865
- body = React.createElement('div', { className: 'dsh-recall-ex-note' }, '尚未创建任何快照存储:在任意工作区发送一条消息后,这里会出现可编辑的排除配置。')
866
- } else {
867
- body = React.createElement('div', { className: 'dsh-recall-ex-card' },
868
- ...files.map((f) => React.createElement(ExcludeCard, { key: f.path, file: f }))
966
+ return React.createElement('div', { className: 'dsh-recall-ex-note' }, '尚未创建任何快照存储:在任意工作区发送一条消息后,这里会出现可编辑的排除配置。')
967
+ }
968
+ return React.createElement('div', { className: 'dsh-recall-ex-card' },
969
+ ...files.map((f) => React.createElement(ExcludeCard, { key: f.path, file: f }))
970
+ )
971
+ }
972
+
973
+ // 插件配置表单:值经 Host 的 settings namespace「dsh-recall」读写
974
+ // (config-get / config-set 端点),保存即持久化进用户 settings 文档
975
+ // 并热生效(Host watch 链路原地更新 cfg),无需重启。只提交相对
976
+ // 基线修改过的字段,避免一次保存把全部字段标成「用户覆盖」。
977
+ function ConfigForm() {
978
+ const [baseline, setBaseline] = React.useState(null)
979
+ const [draft, setDraft] = React.useState(null)
980
+ const [envLocks, setEnvLocks] = React.useState({})
981
+ const [overridden, setOverridden] = React.useState({})
982
+ const [writable, setWritable] = React.useState(true)
983
+ const [state, setState] = React.useState({ busy: false, message: '', error: false })
984
+
985
+ function load() {
986
+ api('config-get', {}).then((res) => {
987
+ if (res && res.ok) {
988
+ const v = res.values || {}
989
+ const next = {
990
+ gcSnaps: String(v.gcSnaps == null ? '' : v.gcSnaps),
991
+ gcHours: String(v.gcHours == null ? '' : v.gcHours),
992
+ maxFileBytes: String(v.maxFileBytes == null ? '' : v.maxFileBytes),
993
+ baseExcludes: Array.isArray(v.baseExcludes) ? v.baseExcludes.join('\n') : '',
994
+ refillDraft: v.refillDraft !== false,
995
+ }
996
+ setDraft(next)
997
+ setBaseline(next)
998
+ setEnvLocks(res.envLocks || {})
999
+ setOverridden(res.overridden || {})
1000
+ setWritable(res.writable !== false)
1001
+ } else {
1002
+ setState({ busy: false, message: (res && (res.message || res.error)) || '无法读取配置', error: true })
1003
+ }
1004
+ }).catch((e) => setState({ busy: false, message: String(e), error: true }))
1005
+ }
1006
+
1007
+ React.useEffect(() => { load() }, [])
1008
+
1009
+ function edit(key, value) {
1010
+ setDraft((d) => Object.assign({}, d, { [key]: value }))
1011
+ }
1012
+
1013
+ function save() {
1014
+ if (state.busy || !draft || !baseline) return
1015
+ const patch = {}
1016
+ for (const key of ['gcSnaps', 'gcHours', 'maxFileBytes', 'baseExcludes', 'refillDraft']) {
1017
+ if (draft[key] !== baseline[key]) patch[key] = draft[key]
1018
+ }
1019
+ if (!Object.keys(patch).length) {
1020
+ setState({ busy: false, message: '没有修改', error: false })
1021
+ return
1022
+ }
1023
+ const clean = {}
1024
+ if (patch.gcSnaps !== undefined) {
1025
+ const n = parseInt(patch.gcSnaps, 10)
1026
+ if (!Number.isFinite(n) || n < 1) { setState({ busy: false, message: '快照条数阈值必须是 >= 1 的整数', error: true }); return }
1027
+ clean.gcSnaps = n
1028
+ }
1029
+ if (patch.gcHours !== undefined) {
1030
+ const n = parseInt(patch.gcHours, 10)
1031
+ if (!Number.isFinite(n) || n < 1) { setState({ busy: false, message: 'gc 小时阈值必须是 >= 1 的整数', error: true }); return }
1032
+ clean.gcHours = n
1033
+ }
1034
+ if (patch.maxFileBytes !== undefined) {
1035
+ const n = parseInt(patch.maxFileBytes, 10)
1036
+ if (!Number.isFinite(n) || n < 1024) { setState({ busy: false, message: '文件大小上限必须是 >= 1024 的字节数', error: true }); return }
1037
+ clean.maxFileBytes = n
1038
+ }
1039
+ if (patch.refillDraft !== undefined) clean.refillDraft = Boolean(patch.refillDraft)
1040
+ if (patch.baseExcludes !== undefined) {
1041
+ clean.baseExcludes = String(patch.baseExcludes).split('\n').map((l) => l.trim()).filter(Boolean)
1042
+ }
1043
+ setState({ busy: true, message: '保存中…', error: false })
1044
+ api('config-set', { patch: clean }).then((res) => {
1045
+ if (res && res.ok) {
1046
+ setState({ busy: false, message: '已保存并即时生效', error: false })
1047
+ load()
1048
+ } else {
1049
+ setState({ busy: false, message: (res && (res.message || res.error)) || '保存失败', error: true })
1050
+ }
1051
+ }).catch((e) => setState({ busy: false, message: String(e), error: true }))
1052
+ }
1053
+
1054
+ function numRow(key, label, hint) {
1055
+ const locked = Boolean(envLocks && envLocks[key])
1056
+ const changed = Boolean(draft && baseline && draft[key] !== baseline[key])
1057
+ return React.createElement('div', { className: 'dsh-recall-cfg-row', key: key },
1058
+ React.createElement('div', { className: 'dsh-recall-cfg-line' },
1059
+ React.createElement('label', { className: 'dsh-recall-cfg-label' }, label),
1060
+ React.createElement('input', {
1061
+ className: 'dsh-recall-cfg-input',
1062
+ type: 'number',
1063
+ value: draft ? draft[key] : '',
1064
+ disabled: locked || !writable,
1065
+ onChange: (e) => edit(key, e.target.value),
1066
+ }),
1067
+ changed && !locked ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已修改') : null,
1068
+ overridden && overridden[key] !== undefined ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已覆盖') : null,
1069
+ locked ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '环境变量锁定') : null
1070
+ ),
1071
+ React.createElement('div', { className: 'dsh-recall-cfg-hint' }, hint)
1072
+ )
1073
+ }
1074
+
1075
+ if (!draft) {
1076
+ return React.createElement('div', { className: 'dsh-recall-ex-note' }, state.message || '正在读取配置…')
1077
+ }
1078
+
1079
+ return React.createElement('div', { className: 'dsh-recall-ex-card' },
1080
+ numRow('gcSnaps', 'gc 触发条数', '每积累多少条快照触发一次 git gc'),
1081
+ numRow('gcHours', 'gc 触发小时', '距上次 gc 超过多少小时触发(与条数先到先触发)'),
1082
+ numRow('maxFileBytes', '文件大小上限', '超过该字节数的文件不进快照、不被回退触碰'),
1083
+ React.createElement('div', { className: 'dsh-recall-cfg-row', key: 'refillDraft' },
1084
+ React.createElement('div', { className: 'dsh-recall-cfg-line' },
1085
+ React.createElement('label', { className: 'dsh-recall-cfg-label' }, '撤回后回填输入框'),
1086
+ React.createElement('input', {
1087
+ type: 'checkbox',
1088
+ checked: Boolean(draft.refillDraft),
1089
+ disabled: !writable,
1090
+ onChange: (e) => edit('refillDraft', e.target.checked),
1091
+ }),
1092
+ draft.refillDraft !== baseline.refillDraft ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已修改') : null,
1093
+ overridden && overridden.refillDraft !== undefined ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已覆盖') : null
1094
+ ),
1095
+ React.createElement('div', { className: 'dsh-recall-cfg-hint' }, '撤回成功后把被撤回的消息文本回填到输入框,方便修改后重新发送')
1096
+ ),
1097
+ React.createElement('div', { className: 'dsh-recall-cfg-row', key: 'baseExcludes' },
1098
+ React.createElement('div', { className: 'dsh-recall-cfg-line' },
1099
+ React.createElement('label', { className: 'dsh-recall-cfg-label' }, '基础排除表'),
1100
+ draft.baseExcludes !== baseline.baseExcludes ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已修改') : null,
1101
+ overridden && overridden.baseExcludes !== undefined ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '已覆盖') : null
1102
+ ),
1103
+ React.createElement('textarea', {
1104
+ className: 'dsh-recall-cfg-area',
1105
+ rows: 4,
1106
+ value: draft.baseExcludes,
1107
+ disabled: !writable,
1108
+ onChange: (e) => edit('baseExcludes', e.target.value),
1109
+ }),
1110
+ React.createElement('div', { className: 'dsh-recall-cfg-hint' }, 'gitignore 语法,每行一条;优先级低于 exclude.txt,下一次快照/回退即时生效')
1111
+ ),
1112
+ React.createElement('div', { className: 'dsh-recall-panel-actions' },
1113
+ state.message ? React.createElement('span', { className: 'dsh-recall-ex-status' + (state.error ? ' dsh-recall-ex-status-error' : '') }, state.message) : null,
1114
+ React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: state.busy || !writable, onClick: () => setDraft(Object.assign({}, baseline)) }, '放弃修改'),
1115
+ React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: state.busy || !writable, onClick: save }, '保存'),
1116
+ !writable ? React.createElement('span', { className: 'dsh-recall-cfg-tag' }, '只读设置源') : null
869
1117
  )
1118
+ )
1119
+ }
1120
+
1121
+ // 分区折叠头:官方卡片列表纵向排布,排除配置/快照管理是重内容,
1122
+ // 默认折叠、按需展开(展开后由设置外壳保持挂载,草稿不丢)。
1123
+ function SectionToggle(props) {
1124
+ return React.createElement('button', {
1125
+ type: 'button',
1126
+ className: 'dsh-recall-cardbtn',
1127
+ 'aria-expanded': props.open,
1128
+ onClick: props.onToggle,
1129
+ },
1130
+ React.createElement('span', { className: 'dsh-recall-tree-toggle' }, props.open ? '▾' : '▸'),
1131
+ React.createElement('span', { style: { fontWeight: 600, fontSize: '14px', lineHeight: '22px' } }, props.title),
1132
+ props.meta ? React.createElement('span', { className: 'dsh-recall-tree-meta' }, props.meta) : null
1133
+ )
1134
+ }
1135
+
1136
+ // 「插件配置」分区里的撤回卡片(settings.plugin.item keyed slot,
1137
+ // key = Host 端注册的 settings namespace 'dsh-recall')。整卡默认
1138
+ // 收起、点卡片头展开,视觉规格对齐官方 PluginCard(ui-settings-
1139
+ // plugins 的同名 CSS:bg-layer-3 底、展开变 bg-layer-2 + 边框
1140
+ // label-dimmed、header 14px 16px、标题 15px/600、描述 13px、
1141
+ // 内容区上边框 + margin 0 16px 分隔)。展开后内含三段:插件配置
1142
+ // 表单 + 排除配置(折叠)+ 快照管理(折叠)。
1143
+ function RecallSettingsCard() {
1144
+ const [open, setOpen] = React.useState(false)
1145
+ const [sections, setSections] = React.useState({ exclude: false, manage: false })
1146
+ function toggle(key) {
1147
+ setSections((prev) => Object.assign({}, prev, { [key]: !prev[key] }))
870
1148
  }
871
- // 快照管理卡片挂标签页底部:与排除配置同属撤回治理面,
872
- // sessionId 从设置外壳 props 透传(usage/gc 按工作区定位 store)
873
- return React.createElement('div', { className: 'dsh-recall-ex-tab' },
874
- body,
875
- React.createElement(ManageCard, { sessionId: props && props.sessionId })
1149
+ return React.createElement('li', { className: 'dsh-recall-card' + (open ? ' dsh-recall-card-open' : '') },
1150
+ React.createElement('button', {
1151
+ type: 'button',
1152
+ className: 'dsh-recall-cardbtn',
1153
+ 'aria-expanded': open,
1154
+ 'aria-label': (open ? '收起' : '展开') + ': 撤回插件',
1155
+ onClick: () => setOpen((v) => !v),
1156
+ },
1157
+ React.createElement('span', { className: 'dsh-recall-card-head' },
1158
+ React.createElement('span', { className: 'dsh-recall-card-name' }, '撤回插件'),
1159
+ React.createElement('span', { className: 'dsh-recall-card-desc' }, '消息撤回(文件快照 + 对话回退)的阈值与治理')
1160
+ ),
1161
+ React.createElement('svg', {
1162
+ width: 14, height: 14, viewBox: '0 0 16 16',
1163
+ style: { color: 'var(--dsw-alias-label-tertiary)', flex: 'none', transition: 'transform .16s', transform: open ? 'rotate(180deg)' : 'none' }
1164
+ }, React.createElement('path', { d: 'M4 6l4 4 4-4', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }))
1165
+ ),
1166
+ open ? React.createElement('div', { className: 'dsh-recall-card-body' },
1167
+ React.createElement(ConfigForm),
1168
+ React.createElement(SectionToggle, { title: '排除配置(exclude.txt)', open: sections.exclude, onToggle: () => toggle('exclude') }),
1169
+ sections.exclude ? React.createElement(ExcludeFilesSection) : null,
1170
+ React.createElement(SectionToggle, { title: '快照管理', open: sections.manage, onToggle: () => toggle('manage') }),
1171
+ sections.manage ? React.createElement(ManageCard) : null
1172
+ ) : null
876
1173
  )
877
1174
  }
878
1175
 
@@ -894,19 +1191,23 @@ window.__ModuleLoader__.load({
894
1191
  }
895
1192
  }
896
1193
 
897
- // 「插件」设置分区挂自己的标签页:settings.plugins.tab 是 root 级
898
- // list slot,id 唯一即无冲突(与 keyed slot 的 priority 冲突是两套
899
- // 语义),order 20 排在官方「插件配置」(0) 与「插件清单」(10) 之后。
900
- // 该 slot 由 ui-settings-plugins 声明;未组装设置页的部署里 inject
901
- // 只是永不匹配的待定注册,天然无害。try/catch 是防御性兜底——注册
902
- // 失败绝不该拖垮撤回按钮的主功能。
1194
+ // 「插件配置」分区挂撤回卡片:settings.plugin.item 是 root 级
1195
+ // keyed slot(官方 ui-settings-plugins 的 configurable 标签页声明,
1196
+ // 按 settings namespace 作为 entryKey 分发)。key 必须与 Host 端
1197
+ // 注册的 namespace 'dsh-recall' 一致——卡片只渲染「Host 服务的
1198
+ // namespace」与「slot 注册的卡片」的交集,Host 端
1199
+ // installSettingsSection 完成注册后 describe 命中、卡片即出现。
1200
+ // 各 namespace 独占自己的 key,无同 key 抢占,不需要 priority
1201
+ // (与 conversation.chat.node 覆盖默认渲染器是两套语义)。该 slot
1202
+ // 未组装的部署里 inject 只是永不匹配的待定注册,天然无害;
1203
+ // try/catch 是防御性兜底——注册失败绝不该拖垮撤回按钮的主功能。
903
1204
  try {
904
- slots.inject('settings.plugins.tab', () => slots.register(
905
- { name: 'settings.plugins.tab', id: 'dsh-recall', order: 20, label: '撤回设置' },
906
- RecallSettingsTab
1205
+ slots.inject('settings.plugin.item', () => slots.register(
1206
+ { name: 'settings.plugin.item', key: 'dsh-recall' },
1207
+ RecallSettingsCard
907
1208
  ))
908
1209
  } catch (error) {
909
- console.error('[dsh-recall-plugin] settings tab register failed:', error)
1210
+ console.error('[dsh-recall-plugin] settings card register failed:', error)
910
1211
  }
911
1212
  }
912
1213
  }
package/lib/config.js CHANGED
@@ -1,17 +1,30 @@
1
1
  /**
2
2
  * dsh-recall-plugin — 配置域(ctx 绑定的工厂,无模块级副作用)
3
3
  *
4
- * 职责:把「用户大概率不改的默认值」从硬编码/环境变量迁移到 DSH 官方
5
- * 插件配置机制——cordis.patch.yml 的 insert 行声明 config 默认值,用户在
6
- * 自己 profile 的 cordis.patch.yml 里按 id 重述该行即可覆盖(层叠规则见
7
- * 《DSH 插件开发规范》第五节),无需设环境变量、无需改包。
8
- *
4
+ * 三层配置解析(官方 settings 模型,见 dsh-settings README):
5
+ * schema 默认值(Config)→ 组合 base(cordis.patch.yml insert 行 config
6
+ * 键)→ 用户文档(设置页「插件配置」卡片写入,dsh-settings 持久化)。
9
7
  * 环境变量 DSH_RECALL_GC_SNAPS / DSH_RECALL_GC_HOURS 保留为最高优先级
10
- * 覆盖:已用它们调档的用户(含冒烟测试脚本)升级后行为不漂移。
8
+ * 覆盖:已用它们调档的用户(含冒烟测试脚本)升级后行为不漂移;设了 env
9
+ * 的字段在设置卡片里锁定不可编辑。
10
+ *
11
+ * Config 同时承担两个角色:cordis 入口配置校验(index.js re-export 给
12
+ * 加载器,非法配置在插件加载时响亮失败)与 settings namespace
13
+ * 「dsh-recall」的注册 schema(installSettingsSection,见 index.js)。
11
14
  */
12
15
 
13
- // 平台分叉的默认值:Windows 用 \\ 拼基础排除表,POSIX 用 /——但排除表是
14
- // gitignore 语法(内部一律正斜杠),与平台无关,只有「是否含该项」的差异
16
+ import Schema from '@deepseek-ai/schemastery'
17
+
18
+ export const Config = Schema.object({
19
+ gcSnaps: Schema.number().default(50).description('每积累多少条快照触发一次 git gc'),
20
+ gcHours: Schema.number().default(24).description('距上次 gc 超过多少小时触发(与条数先到先触发)'),
21
+ maxFileBytes: Schema.number().default(104857600).description('超过该字节数的文件不进快照、不被回退触碰'),
22
+ baseExcludes: Schema.array(Schema.string()).default(['.git', 'node_modules/', '.dsh-recall-snapshots/']).description('基础排除表(gitignore 语法,优先级低于 exclude.txt)'),
23
+ refillDraft: Schema.boolean().default(true).description('撤回后把被撤回的消息文本回填到输入框'),
24
+ })
25
+
26
+ // schema 默认值的运行时镜像:settings 服务未组装时 createConfig 直接以
27
+ // 入口 config 解析,这组兜底与 Config 保持一致(改默认值两处同步改)
15
28
  const BASE_EXCLUDES = ['.git', 'node_modules/', '.dsh-recall-snapshots/']
16
29
 
17
30
  export function createConfig(raw) {
@@ -32,5 +45,7 @@ export function createConfig(raw) {
32
45
  ? cfg.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
33
46
  : BASE_EXCLUDES
34
47
 
35
- return { gcSnaps, gcHours, maxFileBytes, baseExcludes }
48
+ const refillDraft = typeof cfg.refillDraft === 'boolean' ? cfg.refillDraft : true
49
+
50
+ return { gcSnaps, gcHours, maxFileBytes, baseExcludes, refillDraft }
36
51
  }