dsh-recall-plugin 1.4.0 → 1.5.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 +18 -0
- package/README.md +7 -3
- package/lib/client.js +173 -39
- package/lib/index.js +220 -16
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
本文件格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
|
4
4
|
|
|
5
|
+
## [1.5.0] - 2026-08-18
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- 快照管理改为**树形结构**展示:第一级工作区(文件夹名)→ 第二级会话(会话标题)→ 第三级快照(消息 ID/消息内容摘要);工作区与会话支持展开/折叠。
|
|
10
|
+
- 树形每级右侧提供删除按钮:工作区删除该工作区全部快照,会话删除该工作区内该会话全部快照,叶子单条删除;均带行内二次确认。
|
|
11
|
+
- 快照叶子显示**对应消息内容摘要**(取 `user/message` 事件的 text 块),悬停显示完整内容;冷会话消息文本经新增 `manage/messages` 端点按会话分组异步补齐,避免为每条消息重复解压日志。
|
|
12
|
+
- Host `manage/delete` 扩展 `scope=workspace/session` 批量删除:内存 + 磁盘全量收集匹配快照,按 root 分组进串行队列,tag 分块(每 100 个)规避 Windows 命令行上限;单个 root 失败 best-effort 继续并进入错误缓冲。
|
|
13
|
+
|
|
14
|
+
### 变更
|
|
15
|
+
|
|
16
|
+
- `manage list` 同 id 去重由“首次命中即丢弃”改为字段补全(root/sessionId/time/标题/消息文本),避免磁盘先占位、内存后补全时树形节点落入「未知工作区」。
|
|
17
|
+
- 删除操作成功提示改为显示实际删除条数(`已删除 N 条快照`)。
|
|
18
|
+
|
|
19
|
+
### 兼容性
|
|
20
|
+
|
|
21
|
+
- 全部改动保持纯 JS 零构建;未改存储格式与脚本模板接口,旧索引/旧快照无需迁移。
|
|
22
|
+
|
|
5
23
|
## [1.4.0] - 2026-08-17
|
|
6
24
|
|
|
7
25
|
### 新增
|
package/README.md
CHANGED
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
|
|
26
26
|
- 设置页 · 排除项快速编辑(设置 → 插件 → 撤回设置)
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
- 设置页 · 快照管理(树形:工作区 → 会话 → 快照,叶子显示消息内容)
|
|
31
|
+
|
|
32
|
+
> 树形快照管理界面截图待补充(当前版本已实现该功能,截图可后续更新)。
|
|
31
33
|
|
|
32
34
|
## 功能亮点
|
|
33
35
|
|
|
@@ -38,6 +40,7 @@
|
|
|
38
40
|
- **先看清单再动手**:点撤回先弹出将变更的文件清单(修改 / 恢复 / 删除),确认后才执行,不会稀里糊涂覆盖。
|
|
39
41
|
- **磁盘友好**:快照走 git delta 压缩,是增量不是整目录拷贝;超过 100MB 的大文件自动跳过。
|
|
40
42
|
- **自动瘦身**:定期 `git gc` 把 loose 对象压 pack(无损,快照一个不丢);会话删除后其快照自动清理;构建产物可经 `exclude.txt` 全局排除(见下)。
|
|
43
|
+
- **树形快照管理**:设置页「快照管理」按 **工作区 → 会话 → 快照** 三级树形展示,支持展开/折叠;每级右侧都有删除按钮,可一次清掉整个工作区或某个会话的全部快照,叶子显示该快照对应的消息内容摘要,方便定位“这条消息当时改了什么”。
|
|
41
44
|
|
|
42
45
|
## 已知限制
|
|
43
46
|
|
|
@@ -92,6 +95,7 @@ pm2 restart <你的dsh进程名> # 若用 pm2 托管
|
|
|
92
95
|
```
|
|
93
96
|
|
|
94
97
|
对所有项目生效(home 不可写而降级到项目内存储时,该工作区有独立的排除配置,设置页会分卡片列出)。新增排除只影响之后的快照;**回退到更早的快照时,当时尚未排除的文件仍会被恢复**(回到当时的状态,这正是回退语义)。想彻底清掉已进快照的目录,可手动删除 home 下 `dsh-recall-snapshots/` 里对应项目的哈希目录。设置页标签依赖 DSH 自带设置页(0.1.0-rc.x 均含);极旧版本看不到该标签时,直接编辑文件等效。
|
|
98
|
+
- **树形快照管理**:打开「**设置 → 插件 → 撤回设置 → 快照管理**」可看到树形列表——第一级工作区(文件夹名)、第二级会话(会话标题)、第三级快照(时间 + 消息内容摘要,悬停看完整内容)。工作区和会话节点可展开/折叠;每一级右侧都有删除按钮,删除前会二次确认。删除工作区 = 清掉该工作区全部快照;删除会话 = 清掉该工作区内该会话的全部快照;删除叶子 = 只删那一条快照。
|
|
95
99
|
|
|
96
100
|
## 工作原理
|
|
97
101
|
|
package/lib/client.js
CHANGED
|
@@ -74,12 +74,19 @@ window.__ModuleLoader__.load({
|
|
|
74
74
|
'.dsh-recall-ex-chip:hover{color:var(--dsw-alias-label-primary)}',
|
|
75
75
|
'.dsh-recall-ex-status{margin-right:auto;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary)}',
|
|
76
76
|
'.dsh-recall-ex-status-error{color:var(--dsw-alias-state-error-primary)}',
|
|
77
|
-
'.dsh-recall-
|
|
78
|
-
'.dsh-recall-
|
|
79
|
-
'.dsh-recall-
|
|
80
|
-
'.dsh-recall-
|
|
81
|
-
'.dsh-recall-
|
|
82
|
-
'.dsh-recall-
|
|
77
|
+
'.dsh-recall-tree{display:flex;flex-direction:column;gap:2px;padding:4px 0}',
|
|
78
|
+
'.dsh-recall-tree-node{display:flex;flex-direction:column;gap:1px}',
|
|
79
|
+
'.dsh-recall-tree-row{display:flex;gap:6px;align-items:center;min-width:0;padding:2px 4px;border-radius:6px;cursor:default}',
|
|
80
|
+
'.dsh-recall-tree-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
81
|
+
'.dsh-recall-tree-toggle{flex:none;width:18px;height:18px;display:inline-flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary);cursor:pointer;border-radius:4px;font-size:11px;line-height:18px;user-select:none}',
|
|
82
|
+
'.dsh-recall-tree-toggle:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}',
|
|
83
|
+
'.dsh-recall-tree-toggle-placeholder{flex:none;width:18px;height:18px}',
|
|
84
|
+
'.dsh-recall-tree-label{flex:1;min-width:0;display:flex;gap:8px;align-items:baseline;font-size:12px;line-height:20px;overflow:hidden}',
|
|
85
|
+
'.dsh-recall-tree-name{flex:none;font-weight:600;color:var(--dsw-alias-label-secondary)}',
|
|
86
|
+
'.dsh-recall-tree-title{min-width:0;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
87
|
+
'.dsh-recall-tree-meta{flex:none;font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
|
|
88
|
+
'.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)}'
|
|
83
90
|
].join('')
|
|
84
91
|
// 静态 bundle 的 ctx 可能不提供 styles 服务,降级为直接注入 <style>
|
|
85
92
|
const stylesSvc = ctx.get('styles')
|
|
@@ -605,11 +612,32 @@ window.__ModuleLoader__.load({
|
|
|
605
612
|
}).catch(() => setTitlesPending(false))
|
|
606
613
|
}
|
|
607
614
|
|
|
615
|
+
// 消息文本补齐:只请求 live 拿不到文本的快照;同一会话多条消息
|
|
616
|
+
// 在 Host 端共享一次 readSession,避免为每条消息重复解压大日志。
|
|
617
|
+
function fetchMessages(list) {
|
|
618
|
+
const requests = (list || [])
|
|
619
|
+
// 只补「还没拿到结论」的快照:live 没命中时不带 messageText 字段,
|
|
620
|
+
// 冷查补齐后字段存在(字符串或 null),null 表示确实无文本不再请求。
|
|
621
|
+
.filter((it) => it.sessionId && it.id && !Object.prototype.hasOwnProperty.call(it, 'messageText'))
|
|
622
|
+
.map((it) => ({ sessionId: it.sessionId, messageId: it.id }))
|
|
623
|
+
.slice(0, 200)
|
|
624
|
+
if (!requests.length) return
|
|
625
|
+
api('manage', { op: 'messages', requests }).then((res) => {
|
|
626
|
+
const map = res && res.ok ? res.messageTexts : null
|
|
627
|
+
if (map) {
|
|
628
|
+
setItems((prev) => (prev || []).map((it) => (
|
|
629
|
+
it.id && Object.prototype.hasOwnProperty.call(map, it.id) ? Object.assign({}, it, { messageText: map[it.id] }) : it
|
|
630
|
+
)))
|
|
631
|
+
}
|
|
632
|
+
}).catch(() => {})
|
|
633
|
+
}
|
|
634
|
+
|
|
608
635
|
function refresh() {
|
|
609
636
|
api('manage', { op: 'list' }).then((res) => {
|
|
610
637
|
if (res && res.ok) {
|
|
611
638
|
setItems(res.items || [])
|
|
612
639
|
fetchTitles(res.items || [])
|
|
640
|
+
fetchMessages(res.items || [])
|
|
613
641
|
}
|
|
614
642
|
}).catch(() => {})
|
|
615
643
|
if (sessionId) {
|
|
@@ -629,7 +657,7 @@ window.__ModuleLoader__.load({
|
|
|
629
657
|
setState({ busy: true, message: '执行中…', error: false })
|
|
630
658
|
api('manage', Object.assign({ op, sessionId }, extra || {})).then((res) => {
|
|
631
659
|
if (res && res.ok) {
|
|
632
|
-
setState({ busy: false, message: doneText, error: false })
|
|
660
|
+
setState({ busy: false, message: typeof res.deleted === 'number' ? '已删除 ' + res.deleted + ' 条快照' : doneText, error: false })
|
|
633
661
|
refresh()
|
|
634
662
|
} else {
|
|
635
663
|
setState({ busy: false, message: (res && (res.message || res.error)) || '操作失败', error: true })
|
|
@@ -637,39 +665,145 @@ window.__ModuleLoader__.load({
|
|
|
637
665
|
}).catch((e) => setState({ busy: false, message: String(e), error: true }))
|
|
638
666
|
}
|
|
639
667
|
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
const [
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
668
|
+
// 树形管理:工作区 → 会话 → 快照三级。折叠状态用 Set<key> 记录,
|
|
669
|
+
// 工作区 key 用 root 路径(可能含特殊字符,但 Set 按字符串精确匹配);
|
|
670
|
+
// 会话 key 用 sessionId;叶子无子节点。删除走行内二次确认:
|
|
671
|
+
// confirming 存 { kind, key, extra, text },行内原地展开确认。
|
|
672
|
+
const [expanded, setExpanded] = React.useState(() => new Set())
|
|
673
|
+
const [confirming, setConfirming] = React.useState(null)
|
|
674
|
+
function toggle(key) {
|
|
675
|
+
setExpanded((prev) => {
|
|
676
|
+
const next = new Set(prev)
|
|
677
|
+
if (next.has(key)) next.delete(key)
|
|
678
|
+
else next.add(key)
|
|
679
|
+
return next
|
|
680
|
+
})
|
|
681
|
+
}
|
|
682
|
+
// 把扁平列表组装成树。同一快照只属于一个工作区/会话,root 或
|
|
683
|
+
// sessionId 缺失时归入「未知」节点,避免行凭空消失。
|
|
684
|
+
function buildTree(list) {
|
|
685
|
+
const workspaces = new Map()
|
|
686
|
+
for (const it of list || []) {
|
|
687
|
+
const rootKey = it.root || 'unknown-root'
|
|
688
|
+
if (!workspaces.has(rootKey)) workspaces.set(rootKey, { root: it.root || null, name: it.workspace || '未知工作区', sessions: new Map() })
|
|
689
|
+
const ws = workspaces.get(rootKey)
|
|
690
|
+
const sidKey = it.sessionId || 'unknown-session'
|
|
691
|
+
if (!ws.sessions.has(sidKey)) ws.sessions.set(sidKey, { root: ws.root, sessionId: it.sessionId || null, title: it.sessionTitle || null, items: [] })
|
|
692
|
+
ws.sessions.get(sidKey).items.push(it)
|
|
693
|
+
}
|
|
694
|
+
const wsList = Array.from(workspaces.values())
|
|
695
|
+
wsList.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
|
|
696
|
+
for (const ws of wsList) {
|
|
697
|
+
ws.sessions = Array.from(ws.sessions.values())
|
|
698
|
+
ws.sessions.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
|
|
699
|
+
for (const s of ws.sessions) s.items.sort((a, b) => (b.time || 0) - (a.time || 0))
|
|
700
|
+
}
|
|
701
|
+
return wsList
|
|
702
|
+
}
|
|
703
|
+
const tree = buildTree(items)
|
|
704
|
+
// 每个节点的删除按钮统一走 run();workspace/session 用新端点 scope
|
|
705
|
+
// 批量删,叶子沿用原单条删除。二次确认文案按节点层级区分。
|
|
706
|
+
function confirmDelete(kind, key, extra, text) {
|
|
707
|
+
setConfirming({ kind, key, extra, text })
|
|
708
|
+
}
|
|
709
|
+
function renderConfirm(kind, key, extra, text) {
|
|
710
|
+
if (!confirming || confirming.kind !== kind || confirming.key !== key) return null
|
|
711
|
+
return React.createElement('div', { className: 'dsh-recall-tree-confirm' },
|
|
712
|
+
text,
|
|
713
|
+
React.createElement('button', {
|
|
714
|
+
type: 'button',
|
|
715
|
+
className: 'dsh-recall-ex-chip',
|
|
716
|
+
onClick: () => {
|
|
717
|
+
const c = confirming
|
|
718
|
+
setConfirming(null)
|
|
719
|
+
run('delete', c.extra, '已删除')
|
|
720
|
+
}
|
|
721
|
+
}, '确认'),
|
|
722
|
+
React.createElement('button', { type: 'button', className: 'dsh-recall-ex-chip', onClick: () => setConfirming(null) }, '取消')
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
// 叶子节点:展开箭头占位 + 时间 + 消息内容摘要 + 截断 ID。
|
|
726
|
+
// 悬停 title 显示完整消息内容(优先)或完整 ID(无内容时)。
|
|
727
|
+
// 消息文本来自 Host live 事件或冷日志异步补齐;取到前/确实没有时
|
|
728
|
+
// 退化为显示 ID,避免假“加载中”占位。
|
|
729
|
+
function renderLeaf(it) {
|
|
730
|
+
const key = 'snap-' + it.id
|
|
731
|
+
const text = it.messageText
|
|
732
|
+
const title = text || it.id
|
|
733
|
+
const label = text
|
|
734
|
+
? clockText(it.time) + ' ' + text
|
|
735
|
+
: clockText(it.time) + ' ' + it.id.slice(0, 12) + '…'
|
|
736
|
+
return React.createElement('div', { className: 'dsh-recall-tree-node', key: key },
|
|
737
|
+
React.createElement('div', { className: 'dsh-recall-tree-row', title: title },
|
|
738
|
+
React.createElement('span', { className: 'dsh-recall-tree-toggle-placeholder' }),
|
|
739
|
+
React.createElement('span', { className: 'dsh-recall-tree-label' },
|
|
740
|
+
React.createElement('span', { className: 'dsh-recall-tree-title' }, label)
|
|
741
|
+
),
|
|
742
|
+
React.createElement('button', {
|
|
743
|
+
type: 'button',
|
|
744
|
+
className: 'dsh-recall-ex-chip',
|
|
745
|
+
title: '删除该快照(tag 与索引条目)',
|
|
746
|
+
onClick: () => confirmDelete('snapshot', key, { messageId: it.id, root: it.root || null }, '确认删除该快照?此操作不可恢复。')
|
|
747
|
+
}, '删除')
|
|
652
748
|
),
|
|
653
|
-
|
|
654
|
-
? React.createElement('div', { className: 'dsh-recall-snap-confirm' },
|
|
655
|
-
'确认删除该快照?此操作不可恢复。',
|
|
656
|
-
React.createElement('button', {
|
|
657
|
-
type: 'button',
|
|
658
|
-
className: 'dsh-recall-ex-chip',
|
|
659
|
-
onClick: () => { setConfirmingId(null); run('delete', { messageId: it.id, root: it.root || null }, '已删除') }
|
|
660
|
-
}, '确认'),
|
|
661
|
-
React.createElement('button', { type: 'button', className: 'dsh-recall-ex-chip', onClick: () => setConfirmingId(null) }, '取消')
|
|
662
|
-
)
|
|
663
|
-
: React.createElement('div', null,
|
|
664
|
-
React.createElement('button', {
|
|
665
|
-
type: 'button',
|
|
666
|
-
className: 'dsh-recall-ex-chip',
|
|
667
|
-
title: '删除该快照(tag 与索引条目)',
|
|
668
|
-
onClick: () => setConfirmingId(it.id)
|
|
669
|
-
}, '删除')
|
|
670
|
-
)
|
|
749
|
+
renderConfirm('snapshot', key, { messageId: it.id, root: it.root || null }, '确认删除该快照?此操作不可恢复。')
|
|
671
750
|
)
|
|
672
|
-
}
|
|
751
|
+
}
|
|
752
|
+
// 会话节点:折叠按钮 + 标题 + 快照数 + 删除按钮;子节点为叶子。
|
|
753
|
+
function renderSession(s) {
|
|
754
|
+
const key = 'session-' + (s.root || '') + '-' + s.sessionId
|
|
755
|
+
const open = expanded.has(key)
|
|
756
|
+
const label = s.title || (titlesPending && s.sessionId ? '…' : '(已删除会话)')
|
|
757
|
+
return React.createElement('div', { className: 'dsh-recall-tree-node', key: key },
|
|
758
|
+
React.createElement('div', { className: 'dsh-recall-tree-row' },
|
|
759
|
+
React.createElement('span', {
|
|
760
|
+
className: 'dsh-recall-tree-toggle',
|
|
761
|
+
onClick: () => toggle(key)
|
|
762
|
+
}, open ? '▾' : '▸'),
|
|
763
|
+
React.createElement('span', { className: 'dsh-recall-tree-label', title: s.sessionId || '' },
|
|
764
|
+
React.createElement('span', { className: 'dsh-recall-tree-title' }, label),
|
|
765
|
+
React.createElement('span', { className: 'dsh-recall-tree-meta' }, s.items.length + ' 条')
|
|
766
|
+
),
|
|
767
|
+
s.sessionId ? React.createElement('button', {
|
|
768
|
+
type: 'button',
|
|
769
|
+
className: 'dsh-recall-ex-chip',
|
|
770
|
+
title: '删除该会话全部快照',
|
|
771
|
+
onClick: () => confirmDelete('session', key, { scope: 'session', sessionId: s.sessionId, root: s.root || null }, '确认删除该会话全部快照?此操作不可恢复。')
|
|
772
|
+
}, '删除') : null
|
|
773
|
+
),
|
|
774
|
+
open ? React.createElement('div', { className: 'dsh-recall-tree-children' }, ...s.items.map(renderLeaf)) : null,
|
|
775
|
+
s.sessionId ? renderConfirm('session', key, { scope: 'session', sessionId: s.sessionId, root: s.root || null }, '确认删除该会话全部快照?此操作不可恢复。') : null
|
|
776
|
+
)
|
|
777
|
+
}
|
|
778
|
+
// 工作区节点:折叠按钮 + 文件夹名 + 会话数/快照数 + 删除按钮;
|
|
779
|
+
// 子节点为会话。叶子不展开箭头。
|
|
780
|
+
function renderWorkspace(ws) {
|
|
781
|
+
const key = 'ws-' + ws.root
|
|
782
|
+
const open = expanded.has(key)
|
|
783
|
+
const sessionCount = ws.sessions.length
|
|
784
|
+
const snapCount = ws.sessions.reduce((n, s) => n + s.items.length, 0)
|
|
785
|
+
return React.createElement('div', { className: 'dsh-recall-tree-node', key: key },
|
|
786
|
+
React.createElement('div', { className: 'dsh-recall-tree-row' },
|
|
787
|
+
React.createElement('span', {
|
|
788
|
+
className: 'dsh-recall-tree-toggle',
|
|
789
|
+
onClick: () => toggle(key)
|
|
790
|
+
}, open ? '▾' : '▸'),
|
|
791
|
+
React.createElement('span', { className: 'dsh-recall-tree-label', title: ws.root || '' },
|
|
792
|
+
React.createElement('span', { className: 'dsh-recall-tree-name' }, ws.name),
|
|
793
|
+
React.createElement('span', { className: 'dsh-recall-tree-meta' }, sessionCount + ' 会话 / ' + snapCount + ' 快照')
|
|
794
|
+
),
|
|
795
|
+
ws.root ? React.createElement('button', {
|
|
796
|
+
type: 'button',
|
|
797
|
+
className: 'dsh-recall-ex-chip',
|
|
798
|
+
title: '删除该工作区全部快照',
|
|
799
|
+
onClick: () => confirmDelete('workspace', key, { scope: 'workspace', root: ws.root }, '确认删除该工作区全部快照?此操作不可恢复。')
|
|
800
|
+
}, '删除') : null
|
|
801
|
+
),
|
|
802
|
+
open ? React.createElement('div', { className: 'dsh-recall-tree-children' }, ...ws.sessions.map(renderSession)) : null,
|
|
803
|
+
ws.root ? renderConfirm('workspace', key, { scope: 'workspace', root: ws.root }, '确认删除该工作区全部快照?此操作不可恢复。') : null
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
const treeNodes = tree.map(renderWorkspace)
|
|
673
807
|
|
|
674
808
|
return React.createElement('div', { className: 'dsh-recall-ex-card' },
|
|
675
809
|
React.createElement('div', { className: 'dsh-recall-ex-title' }, '快照管理'),
|
|
@@ -678,7 +812,7 @@ window.__ModuleLoader__.load({
|
|
|
678
812
|
? '共 ' + (items ? items.length : '…') + ' 条快照。'
|
|
679
813
|
: '共 ' + (items ? items.length : '…') + ' 条快照,当前工作区快照存储占用 ' + sizeText(usage) + '。'
|
|
680
814
|
),
|
|
681
|
-
|
|
815
|
+
treeNodes.length > 0 ? React.createElement('div', { className: 'dsh-recall-tree' }, ...treeNodes) : null,
|
|
682
816
|
React.createElement('div', { className: 'dsh-recall-panel-actions' },
|
|
683
817
|
state.message ? React.createElement('span', { className: 'dsh-recall-ex-status' + (state.error ? ' dsh-recall-ex-status-error' : '') }, state.message) : null,
|
|
684
818
|
React.createElement('button', { type: 'button', className: 'dsh-recall-btn', disabled: state.busy, onClick: refresh }, '刷新'),
|
package/lib/index.js
CHANGED
|
@@ -65,6 +65,36 @@ export function apply(ctx, config) {
|
|
|
65
65
|
}
|
|
66
66
|
return null
|
|
67
67
|
}
|
|
68
|
+
// 消息文本缓存(apply 级跨请求共享):冷会话 readSession 整日志解压很贵,
|
|
69
|
+
// 与标题同款两段式——live 秒回,冷会话由 Client 异步调 titles 端点补齐。
|
|
70
|
+
// 值为 null 表示「查过、确实没有」,同样命中缓存。
|
|
71
|
+
const messageTexts = new Map()
|
|
72
|
+
function messageTextFromEvents(events, messageId) {
|
|
73
|
+
if (!Array.isArray(events) || !messageId) return null
|
|
74
|
+
for (const e of events) {
|
|
75
|
+
if (e && e.type === 'user/message' && e.data && String(e.data.id) === String(messageId)) {
|
|
76
|
+
const blocks = Array.isArray(e.data.content) ? e.data.content : []
|
|
77
|
+
const text = blocks
|
|
78
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
79
|
+
.map((b) => b.text)
|
|
80
|
+
.join('')
|
|
81
|
+
return text || null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
function liveMessageTextFast(sessionId, messageId) {
|
|
87
|
+
if (!sessionId || !messageId) return null
|
|
88
|
+
const key = String(sessionId) + '\u0000' + String(messageId)
|
|
89
|
+
if (messageTexts.has(key)) return messageTexts.get(key)
|
|
90
|
+
let text = null
|
|
91
|
+
try {
|
|
92
|
+
const live = ctx.sessions.get(sessionId)
|
|
93
|
+
if (live) text = messageTextFromEvents(live.events, messageId)
|
|
94
|
+
} catch (error) { text = null }
|
|
95
|
+
if (text !== null) messageTexts.set(key, text)
|
|
96
|
+
return text
|
|
97
|
+
}
|
|
68
98
|
function liveTitleFast(sessionId) {
|
|
69
99
|
if (!sessionId) return null
|
|
70
100
|
if (sessionTitles.has(sessionId)) return sessionTitles.get(sessionId)
|
|
@@ -248,6 +278,97 @@ export function apply(ctx, config) {
|
|
|
248
278
|
return run
|
|
249
279
|
}
|
|
250
280
|
|
|
281
|
+
// 收集全量快照记录(内存 + 磁盘 dump 并集),供树形管理的按工作区/
|
|
282
|
+
// 会话批量删除使用。返回 Map<消息ID, {id, root, sessionId, time}>;
|
|
283
|
+
// 磁盘条目缺 root 时按 store 的 root.txt/内存映射补全(与 manage list
|
|
284
|
+
// 同一条解析链)。去重只按 id——同一消息 ID 全局唯一。
|
|
285
|
+
async function collectAllSnapshotRecords() {
|
|
286
|
+
const records = new Map()
|
|
287
|
+
function add(id, root, sessionId, time) {
|
|
288
|
+
if (!id || typeof id !== 'string') return
|
|
289
|
+
const old = records.get(id)
|
|
290
|
+
if (!old) {
|
|
291
|
+
records.set(id, {
|
|
292
|
+
id,
|
|
293
|
+
root: root || null,
|
|
294
|
+
sessionId: sessionId || null,
|
|
295
|
+
time: typeof time === 'number' ? time : 0
|
|
296
|
+
})
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
// 同一消息 ID 可能出现磁盘先占位、内存后补全的情况:用更全的
|
|
300
|
+
// root/sessionId/time 覆盖旧值,避免树形节点归到「未知」导致批量
|
|
301
|
+
// 删除按工作区/会话匹配不到。
|
|
302
|
+
if (!old.root && root) old.root = root
|
|
303
|
+
if (!old.sessionId && sessionId) old.sessionId = sessionId
|
|
304
|
+
if (!old.time && time) old.time = time
|
|
305
|
+
}
|
|
306
|
+
// 内存视图:当前会话/预热过的 store 已载入,秒回
|
|
307
|
+
for (const [id, s] of state.snapshots.entries()) {
|
|
308
|
+
if (s) add(id, s.root, s.sessionId, s.time)
|
|
309
|
+
}
|
|
310
|
+
// 磁盘全量:冷启动/非活跃工作区的快照也在这里
|
|
311
|
+
const dump = await dumpStores()
|
|
312
|
+
const hints = new Map()
|
|
313
|
+
for (const [root, st] of state.stores.entries()) {
|
|
314
|
+
if (st && st.dir) hints.set(st.dir, root)
|
|
315
|
+
}
|
|
316
|
+
for (const [dir, info] of dump) {
|
|
317
|
+
const baseRoot = info.root || hints.get(dir) || null
|
|
318
|
+
for (const e of info.entries || []) {
|
|
319
|
+
if (!e || typeof e.id !== 'string') continue
|
|
320
|
+
add(e.id, (typeof e.root === 'string' && e.root) || baseRoot, e.sessionId, e.time)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return records
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 按过滤条件批量删除快照(工作区/会话两个树节点共用):先收集匹配
|
|
327
|
+
// id 并按 root 分组,再整体进串行队列——与快照/gc 互斥,避免 git 锁
|
|
328
|
+
// 竞态。每个 root 先 purge tag 再补载索引后重写 index.json,防止冷启动
|
|
329
|
+
// 时用残缺内存覆盖同 store 其余磁盘快照。
|
|
330
|
+
async function deleteSnapshotsByFilter(match, sessionId) {
|
|
331
|
+
const records = await collectAllSnapshotRecords()
|
|
332
|
+
const byRoot = new Map()
|
|
333
|
+
for (const rec of records.values()) {
|
|
334
|
+
if (!match(rec) || !rec.root) continue
|
|
335
|
+
if (!byRoot.has(rec.root)) byRoot.set(rec.root, [])
|
|
336
|
+
byRoot.get(rec.root).push(rec.id)
|
|
337
|
+
}
|
|
338
|
+
let deleted = 0
|
|
339
|
+
await enqueue(async () => {
|
|
340
|
+
for (const [root, rootIds] of byRoot) {
|
|
341
|
+
let store = state.stores.get(root)
|
|
342
|
+
if (!store) {
|
|
343
|
+
try { store = await rt.resolveStore(root) } catch (error) { store = null }
|
|
344
|
+
}
|
|
345
|
+
if (!store) continue
|
|
346
|
+
try {
|
|
347
|
+
if (state.gitExe) {
|
|
348
|
+
// tag 分块删除:win32 命令行有 32767 字符上限,整批传大量 tag 会
|
|
349
|
+
// 在长历史工作区上爆掉;与 maintenance.purgeSession 同款 100 个/块。
|
|
350
|
+
const tags = rootIds.map((id) => 'snap-' + id)
|
|
351
|
+
for (let i = 0; i < tags.length; i += 100) {
|
|
352
|
+
await rt.runShell(rt.scripts.purgeTagsScript(store, state.gitExe, tags.slice(i, i + 100)), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (!state.indexLoaded.has(root)) {
|
|
356
|
+
try { await snaps.loadIndex(root, sessionId) } catch (error) { /* 载入失败照常重写,退化为旧行为 */ }
|
|
357
|
+
}
|
|
358
|
+
for (const id of rootIds) state.snapshots.delete(id)
|
|
359
|
+
await snaps.saveIndex(root, sessionId)
|
|
360
|
+
deleted += rootIds.length
|
|
361
|
+
} catch (error) {
|
|
362
|
+
// 单个 root 失败不阻断其他 root:与 maintenance.purgeSession 同款
|
|
363
|
+
// best-effort,错误进状态页可见的错误缓冲,剩余 root 继续清理。
|
|
364
|
+
rt.recordError('recall batch delete failed for ' + root + ': ' + String(error))
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
listCache.payload = null
|
|
368
|
+
})
|
|
369
|
+
return deleted
|
|
370
|
+
}
|
|
371
|
+
|
|
251
372
|
const endpoints = {
|
|
252
373
|
'init': async (args) => {
|
|
253
374
|
if (!supported) {
|
|
@@ -355,7 +476,6 @@ export function apply(ctx, config) {
|
|
|
355
476
|
// 结果缓存(30s + 删除/新快照失效):设置页反复打开、删除后刷新
|
|
356
477
|
// 都会重拉列表,缓存让二次打开零 shell。
|
|
357
478
|
if (listCache.payload && Date.now() - listCache.at < 30000) return listCache.payload
|
|
358
|
-
const seen = new Set()
|
|
359
479
|
const allItems = []
|
|
360
480
|
|
|
361
481
|
// 磁盘全量:一条 shell dump(dumpStores 见其注释——旧实现每目录
|
|
@@ -371,17 +491,34 @@ export function apply(ctx, config) {
|
|
|
371
491
|
}
|
|
372
492
|
// 去重只用 id(消息 ID 全局唯一):带 root 进 key 会让同一快照
|
|
373
493
|
// 因「磁盘来源 root 缺失 / 内存来源 root 齐全」出现两条重复行
|
|
494
|
+
const byId = new Map()
|
|
374
495
|
function push(id, time, root, sessionId) {
|
|
375
|
-
if (!id || typeof id !== 'string'
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
496
|
+
if (!id || typeof id !== 'string') return
|
|
497
|
+
const old = byId.get(id)
|
|
498
|
+
if (!old) {
|
|
499
|
+
const rec = {
|
|
500
|
+
id,
|
|
501
|
+
time: typeof time === 'number' ? time : 0,
|
|
502
|
+
root: root || null,
|
|
503
|
+
workspace: root ? root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() : null,
|
|
504
|
+
sessionId: sessionId || null,
|
|
505
|
+
sessionTitle: liveTitleFast(sessionId)
|
|
506
|
+
}
|
|
507
|
+
// 消息文本只放已确认值:live 命中字符串则带,否则不设字段。
|
|
508
|
+
// 客户端据此判断「还没请求冷日志」;messages 端点补齐后字符串
|
|
509
|
+
// 或 null 都会写入,null 表示确实无文本,避免每次刷新重复请求。
|
|
510
|
+
const liveText = liveMessageTextFast(sessionId, id)
|
|
511
|
+
if (liveText) rec.messageText = liveText
|
|
512
|
+
byId.set(id, rec)
|
|
513
|
+
allItems.push(rec)
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
// 与 collectAllSnapshotRecords 同款补全:磁盘先占位、内存后补全
|
|
517
|
+
// root 时,若按「首次命中即丢弃」会让树形一级节点落进未知工作区。
|
|
518
|
+
if (!old.root && root) { old.root = root; old.workspace = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || null }
|
|
519
|
+
if (!old.sessionId && sessionId) { old.sessionId = sessionId; old.sessionTitle = liveTitleFast(sessionId) }
|
|
520
|
+
if (!old.messageText && id) { old.messageText = liveMessageTextFast(sessionId, id) }
|
|
521
|
+
if (!old.time && time) old.time = time
|
|
385
522
|
}
|
|
386
523
|
for (const [dir, info] of dump) {
|
|
387
524
|
const baseRoot = info.root || hints.get(dir) || null
|
|
@@ -424,6 +561,52 @@ export function apply(ctx, config) {
|
|
|
424
561
|
}))
|
|
425
562
|
return { ok: true, titles: out }
|
|
426
563
|
}
|
|
564
|
+
if (op === 'messages') {
|
|
565
|
+
// 冷会话消息文本补齐:与 titles 同款两段式,独立端点避免和标题
|
|
566
|
+
// 请求的 sessionIds/messageIds 对应关系纠缠。输入 [{sessionId, messageId}],
|
|
567
|
+
// 缺 live 文本的消息才需要补;同一会话多个消息共享一次 readSession。
|
|
568
|
+
if (!supported) return { ok: false, unsupported: true }
|
|
569
|
+
const reqs = Array.isArray(args && args.requests) ? args.requests.slice(0, 200) : []
|
|
570
|
+
const bySession = new Map()
|
|
571
|
+
for (const r of reqs) {
|
|
572
|
+
const sid = r && r.sessionId ? String(r.sessionId) : null
|
|
573
|
+
const mid = r && r.messageId ? String(r.messageId) : null
|
|
574
|
+
if (!sid || !mid) continue
|
|
575
|
+
if (!bySession.has(sid)) bySession.set(sid, [])
|
|
576
|
+
bySession.get(sid).push(mid)
|
|
577
|
+
}
|
|
578
|
+
const texts = {}
|
|
579
|
+
await Promise.all(Array.from(bySession.entries()).map(async ([sid, mids]) => {
|
|
580
|
+
// 该会话所有消息都已缓存(含 null)时,不必 readSession 冷读
|
|
581
|
+
const allCached = mids.every((mid) => messageTexts.has(String(sid) + '\u0000' + String(mid)))
|
|
582
|
+
let log = null
|
|
583
|
+
if (!allCached) {
|
|
584
|
+
const query = ctx.get('sessionQuery')
|
|
585
|
+
if (query && typeof query.readSession === 'function') {
|
|
586
|
+
try {
|
|
587
|
+
log = await query.readSession(sid)
|
|
588
|
+
} catch (error) { log = null }
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
for (const mid of mids) {
|
|
592
|
+
const key = String(sid) + '\u0000' + String(mid)
|
|
593
|
+
// 缓存命中(含 null)直接复用,避免已确认无文本的消息反复冷读
|
|
594
|
+
if (messageTexts.has(key)) {
|
|
595
|
+
texts[mid] = messageTexts.get(key)
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
598
|
+
let text = liveMessageTextFast(sid, mid)
|
|
599
|
+
if (text === null && log && Array.isArray(log.events)) {
|
|
600
|
+
text = messageTextFromEvents(log.events, mid)
|
|
601
|
+
}
|
|
602
|
+
// 与 sessionTitles 同款缓存策略:null 也缓存,避免冷会话反复
|
|
603
|
+
// readSession 查同一个查不到文本的消息。
|
|
604
|
+
messageTexts.set(key, text)
|
|
605
|
+
texts[mid] = text
|
|
606
|
+
}
|
|
607
|
+
}))
|
|
608
|
+
return { ok: true, messageTexts: texts }
|
|
609
|
+
}
|
|
427
610
|
if (op === 'usage') {
|
|
428
611
|
const root = await rt.resolveRoot(sessionId)
|
|
429
612
|
if (!root) return { ok: false, code: 'NO_ROOT', message: '无法解析当前工作区' }
|
|
@@ -434,25 +617,46 @@ export function apply(ctx, config) {
|
|
|
434
617
|
return { ok: true, bytes }
|
|
435
618
|
}
|
|
436
619
|
if (op === 'delete') {
|
|
620
|
+
// 统一删除入口:scope=workspace 删除整个工作区全部快照;
|
|
621
|
+
// scope=session 删除某会话全部快照;scope=snapshot(缺省)单条删除。
|
|
622
|
+
// 树形管理每级右侧都有删除按钮,三种粒度共用此端点。
|
|
623
|
+
const scope = args && args.scope ? String(args.scope) : 'snapshot'
|
|
624
|
+
const root = args && args.root ? String(args.root) : null
|
|
625
|
+
const targetSessionId = args && args.sessionId ? String(args.sessionId) : null
|
|
437
626
|
const id = args && args.messageId ? String(args.messageId) : ''
|
|
627
|
+
if (scope === 'workspace') {
|
|
628
|
+
if (!root) return { ok: false, code: 'NO_ROOT', message: '缺少工作区路径' }
|
|
629
|
+
const deleted = await deleteSnapshotsByFilter((rec) => rec.root === root, sessionId)
|
|
630
|
+
return { ok: true, deleted }
|
|
631
|
+
}
|
|
632
|
+
if (scope === 'session') {
|
|
633
|
+
if (!targetSessionId) return { ok: false, code: 'NO_SESSION', message: '缺少会话 ID' }
|
|
634
|
+
// 树形中会话挂在具体工作区下,客户端会传 root 限定范围;不传则
|
|
635
|
+
// 保持旧语义(删该会话全部工作区的快照),兼容老调用方。
|
|
636
|
+
const deleted = await deleteSnapshotsByFilter(
|
|
637
|
+
(rec) => rec.sessionId === targetSessionId && (!root || rec.root === root),
|
|
638
|
+
sessionId
|
|
639
|
+
)
|
|
640
|
+
return { ok: true, deleted }
|
|
641
|
+
}
|
|
438
642
|
// 管理列表来自磁盘(跨工作区全量),而内存 state.snapshots 只含
|
|
439
643
|
// 当前工作区 + 预热过的——冷启动或别的会话先点开列表时,列表里有、
|
|
440
644
|
// 内存里没有,只查内存会把可删的快照误报「不存在」。解析链:
|
|
441
645
|
// 内存命中 → Client 透传的条目 root → 磁盘 index 反查归属 store。
|
|
442
646
|
let snap = state.snapshots.get(id) || null
|
|
443
|
-
let
|
|
647
|
+
let snapRoot = snap ? snap.root : root
|
|
444
648
|
let store = null
|
|
445
|
-
if (
|
|
446
|
-
try { store = await rt.resolveStore(
|
|
649
|
+
if (snapRoot) {
|
|
650
|
+
try { store = await rt.resolveStore(snapRoot) } catch (error) { store = null }
|
|
447
651
|
}
|
|
448
652
|
if (!store) {
|
|
449
653
|
// 兜底:扫 home 容器与降级目录的 index.json,找到含该 id 的 store
|
|
450
654
|
const found = await locateSnapshotOnDisk(id)
|
|
451
|
-
if (found) { store = found.store;
|
|
655
|
+
if (found) { store = found.store; snapRoot = found.root }
|
|
452
656
|
}
|
|
453
657
|
if (!store) return { ok: false, code: 'NO_SNAPSHOT', message: '该快照不存在' }
|
|
454
658
|
const finalStore = store
|
|
455
|
-
const finalRoot =
|
|
659
|
+
const finalRoot = snapRoot
|
|
456
660
|
await enqueue(async () => {
|
|
457
661
|
if (state.gitExe) {
|
|
458
662
|
await rt.runShell(rt.scripts.purgeTagsScript(finalStore, state.gitExe, ['snap-' + id]), { timeoutMs: 120000, stdoutMaxBytes: 4096 })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-recall-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "DSH 消息撤回插件:在用户消息气泡旁加「撤回」按钮,把项目文件(独立影子 git 仓库快照)与对话历史(官方 fork)一并回退到该消息发送之前。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
43
|
-
"@deepseek-ai/dsh-session": "^0.1.0-rc.
|
|
44
|
-
"@deepseek-ai/dsh-shell": "^0.1.0-rc.
|
|
45
|
-
"@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.
|
|
46
|
-
"@deepseek-ai/dsh-session-query": "^0.1.0-rc.
|
|
47
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.
|
|
48
|
-
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.
|
|
43
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
|
44
|
+
"@deepseek-ai/dsh-shell": "^0.1.0-rc.8",
|
|
45
|
+
"@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.8",
|
|
46
|
+
"@deepseek-ai/dsh-session-query": "^0.1.0-rc.8",
|
|
47
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8",
|
|
48
|
+
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.8",
|
|
49
49
|
"react": "^18.2.0"
|
|
50
50
|
},
|
|
51
51
|
"license": "MIT"
|