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/CHANGELOG.md +38 -0
- package/README.en.md +15 -4
- package/README.md +10 -8
- package/cordis.patch.yml +1 -0
- package/lib/client.js +349 -48
- package/lib/config.js +24 -9
- package/lib/index.js +198 -36
- package/lib/maintenance.js +33 -8
- package/lib/snapshots.js +216 -214
- package/lib/store.js +37 -4
- package/package.json +9 -7
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
407
|
+
schedule()
|
|
408
|
+
}).catch(() => {
|
|
409
|
+
if (alive && messageId) timer = setTimeout(schedule, RETRY_MS)
|
|
361
410
|
})
|
|
362
|
-
return () => {
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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() }, [
|
|
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
|
|
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 : '…') + '
|
|
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
|
|
823
|
-
title: '
|
|
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
|
-
//
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
function
|
|
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
|
-
|
|
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
|
-
}
|
|
862
|
-
|
|
863
|
-
|
|
960
|
+
}
|
|
961
|
+
if (files === null) {
|
|
962
|
+
return React.createElement('div', { className: 'dsh-recall-ex-note' }, '正在加载排除配置…')
|
|
963
|
+
}
|
|
964
|
+
if (!files.length) {
|
|
864
965
|
// 全新安装且从未发过消息:store 还没建,先告诉用户怎么触发
|
|
865
|
-
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
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.
|
|
905
|
-
{ name: 'settings.
|
|
906
|
-
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
48
|
+
const refillDraft = typeof cfg.refillDraft === 'boolean' ? cfg.refillDraft : true
|
|
49
|
+
|
|
50
|
+
return { gcSnaps, gcHours, maxFileBytes, baseExcludes, refillDraft }
|
|
36
51
|
}
|