dsh-archived-chats 0.8.0 → 0.9.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/README.en.md ADDED
@@ -0,0 +1,196 @@
1
+ # dsh-archived-chats
2
+
3
+ [English](README.en.md) | [中文](README.md)
4
+
5
+ > ⚡ **Deletion takes effect immediately — no restart.** Even sessions still resident in the background are torn down safely along the official lifecycle and wiped from disk the moment you click delete, instead of being "parked until the next restart".
6
+
7
+ A settings page for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) that brings archived chats back into view.
8
+
9
+ Once a conversation is archived in DeepSeek Harness it disappears from the sidebar, and there is no built-in way to browse it again — only the workspace store (`~/.dsh/storages/workspace.json`) still remembers it. This plugin adds an **Archived Chats** page under Settings where every archived session is visible, searchable, and manageable.
10
+
11
+ ## 🚀 Install
12
+
13
+ ```sh
14
+ dsh plugin --profile web add dsh-archived-chats@latest
15
+ ```
16
+
17
+ Restart DSH once after installing, then open **Settings → Archived Chats**.
18
+
19
+ To update an existing installation:
20
+
21
+ ```sh
22
+ dsh plugin --profile web update dsh-archived-chats
23
+ ```
24
+
25
+ ## Compatibility
26
+
27
+ Version 0.9.0 uses DeepSeek Harness `0.1.0-rc.7` as its automated compatibility baseline. The plugin registers a top-level `settings.section`, so the rc.7 keyed-slot change for `settings.plugin.item` does not apply to it. A local real-host UI pass was also completed on Harness `0.1.0-rc.8` for the archive list, search, metadata editor, bulk and group actions, and backup import preview. Future Harness releases should still be checked with the smoke suite and a real-host UI pass before publishing a plugin update, because client slot and design-token contracts may evolve.
28
+
29
+ ## Preview
30
+
31
+ All screenshots below were captured from 0.9.0 in a local DeepSeek Harness `0.1.0-rc.8` web profile.
32
+
33
+ ![Archived Chats overview](assets/screenshots/1-archived-chats.png)
34
+ ![Search and filters](assets/screenshots/2-search.png)
35
+ ![Delete confirmation](assets/screenshots/3-delete-confirm.png)
36
+ ![Group actions](assets/screenshots/4-group-menu.png)
37
+ ![Metadata editor](assets/screenshots/5-metadata-editor.png)
38
+ ![Bulk actions](assets/screenshots/6-bulk-actions.png)
39
+ ![Import preview](assets/screenshots/7-import-preview.png)
40
+
41
+ ## Usage
42
+
43
+ 1. Archive a conversation from the normal DSH session menu. Archiving removes it from the sidebar but keeps its session data in the workspace store.
44
+ 2. Open **Settings → Archived Chats**. The page groups archived conversations by workspace and remembers collapsed groups in this browser.
45
+ 3. Search, filter, or sort conversations. When you need multi-select, click **Select multiple** to reveal the checkboxes; completing the workflow returns to the clean list automatically. You can also open a row's metadata editor to add tags and notes, or use the group menu for workspace-level actions.
46
+ 4. Click **Import backup** to choose a ZIP produced by this plugin and confirm non-conflicting sessions after the preview. Click **Export backup** to export the current selection, or every archived chat when nothing is selected. Individual rows also have an export action.
47
+ 5. Choose **Unarchive** to return a conversation to the sidebar. Choose **Delete** only when you want permanent removal; the confirmation dialog identifies the affected scope. **Delete All** lives under the top **More** menu.
48
+
49
+ ## Features
50
+
51
+ - **Complete archived-session list**, grouped by workspace (project) with a per-group count. Every group can be collapsed or expanded, and the state is remembered per browser.
52
+ - **Search and sort** by title, workspace title, tags, and note text; filter by type (all / regular / subagent), project, and tag; then order results by newest, oldest, or title.
53
+ - **Tags and notes**: open an editor from any row to attach up to 8 tags (24 Unicode characters each) and a note (2,000 Unicode characters). Tag chips render per row, overflowing past three into a `+N` indicator, and the tag filter narrows the list case-insensitively.
54
+ - **Storage insights**: a summary strip reports the archived count, total measured size, and how many sessions could not be measured; each row shows its own size. Measurement never follows symbolic links and skips sessions whose directories are unreadable.
55
+ - **JSON + Markdown backups**: export one row, the current selection, or every archived chat as a ZIP. Each package has a versioned manifest, a lossless machine-readable session record, and a human-readable transcript for every included session.
56
+ - **Preview-first import and restore**: choose a ZIP backup, inspect every session before writing, preselect only non-conflicting IDs, and restore selected sessions as archived chats. Existing IDs are skipped and never overwritten.
57
+ - **Compact top-level actions**: common **Import backup** / **Export backup** actions are direct, while the low-frequency destructive action lives under **More**. The page stays focused on DSH archive management without a persistent source selector or redundant menus.
58
+ - **On-demand multi-select**: checkboxes stay hidden by default and appear only after clicking **Select multiple**. Select individual chats, every visible result, or an entire project; the selection bar can export, unarchive, or permanently delete the chosen chats in one action, while selections hidden by another filter remain intact.
59
+ - **Unarchive** a single chat or a whole project group from the group's `⋯` menu — restored chats reappear in the sidebar immediately.
60
+ - **Delete** one chat, a project group, or everything (**Delete All**), each behind a confirmation dialog. Deletion is thorough: the session log is removed from disk, the session is detached from its workspace record, and the registry's in-memory header index is purged, so the sidebar drops the rows live.
61
+ - Sessions still resident in the background are **deleted in place too**: the plugin disposes the session through the official lifecycle teardown order (cancel → quiesce → flush → fiber teardown → registry detach), the persistence layer releases the write path, and the physical delete completes within the same request — no restart. If the running DSH build does not expose the required internal seams, the plugin falls back to "park permanently + delete on the next start", with parked sessions staying hidden meanwhile.
62
+ - Works in light and dark schemes; localized in English and 中文.
63
+
64
+ ## Tags, notes, and statistics
65
+
66
+ Tags and notes live **only on your machine** in `$DSH_HOME/plugin-data/archived-chats/metadata.json` — they are never uploaded, synced, or sent anywhere else. Unarchiving a session keeps its metadata; a completed physical deletion removes it, while a deferred or failed deletion keeps it intact. Metadata and statistics failures are always non-blocking: the list, unarchive, and deletion keep working even when the metadata store is unreadable or a session directory cannot be measured.
67
+
68
+ ## Export and backup
69
+
70
+ Every export is a local browser download. A single session and a batch use the same ZIP format:
71
+
72
+ ```text
73
+ manifest.json
74
+ sessions/001-<safe-title>-<id>/session.json
75
+ sessions/001-<safe-title>-<id>/transcript.md
76
+ ```
77
+
78
+ `session.json` is the authoritative backup record: it contains the complete metadata and event values returned by Harness persistence plus the archive title, workspace, timestamps, origin, tags, note, and storage facts. `transcript.md` is a readable companion derived with Harness's canonical message projection. ZIP paths are sanitized and collision-safe, and batches are generated one session at a time instead of buffering every transcript together.
79
+
80
+ Attachment references remain in JSON, but **attachment bytes and descendant sessions are not included**. Use Harness's official Session log export when you need its attachment-complete conversation-tree package.
81
+
82
+ ## Import and restore
83
+
84
+ Import accepts only this plugin's version-one export ZIPs. The browser first uploads the package for bounded validation and shows a preview containing titles, workspaces, tags, notes, storage facts, ID conflicts, unresolved-workspace warnings, and attachment-reference warnings; raw events and Markdown are never rendered in the preview. Existing session IDs are disabled and skipped, and unresolved workspaces are restored ungrouped. A confirmation token expires after 10 minutes and can be used once. Tags and notes are restored through the same local metadata limits as manual edits. No attachment bytes are restored. Hosts without the supported Harness writer capability return `restore-unsupported` without writing anything.
85
+
86
+ ## FAQ
87
+
88
+ <details>
89
+ <summary><b>Does archiving delete the conversation?</b></summary>
90
+
91
+ No. DSH hides the conversation from the sidebar and keeps its archived session record. This plugin gives you a settings page for finding, exporting, restoring, unarchiving, or deleting that record.
92
+
93
+ </details>
94
+
95
+ <details>
96
+ <summary><b>What happens when an imported backup contains an existing session ID?</b></summary>
97
+
98
+ The conflicting row is shown in the preview, disabled by default, and skipped. Import never overwrites an existing session.
99
+
100
+ </details>
101
+
102
+ <details>
103
+ <summary><b>Are attachments included in ZIP backups?</b></summary>
104
+
105
+ Attachment references are preserved in `session.json`, but attachment bytes and descendant sessions are not included. Use Harness's official Session log export for a complete attachment-bearing conversation tree.
106
+
107
+ </details>
108
+
109
+ <details>
110
+ <summary><b>Does deleting a live session require a restart?</b></summary>
111
+
112
+ On hosts that expose the required lifecycle hooks, deletion tears down the live session and removes its files in the same request. Older or incompatible hosts use the safe fallback queue and finish the physical delete on the next start.
113
+
114
+ </details>
115
+
116
+ ## Implementation overview
117
+
118
+ The plugin has two halves: the Host service reads and mutates local archive data, while the browser settings page provides search, filtering, backup, and restore actions. Mutations go through guarded local routes; imports are previewed before writing, and live deletion uses the safest lifecycle path available on the host before falling back to next-boot cleanup.
119
+
120
+ User-facing storage, backup limits, deletion outcomes, and compatibility notes stay in this README. Maintainer details such as route contracts, data flow, restore transactions, live-deletion lifecycle, and failure fallbacks are documented in [ARCHITECTURE.md](docs/ARCHITECTURE.en.md).
121
+
122
+ ## Development
123
+
124
+ ```sh
125
+ npm test
126
+ ```
127
+
128
+ The suite (`test/*.test.mjs`) covers export records and real ZIP decoding, bounded import validation, restore transactions, the metadata store, the statistics service, and host-and-browser smoke tests. It uses an isolated temporary DSH home plus mocked host and browser runtimes; it never reads or changes real sessions.
129
+
130
+ ## Version history
131
+
132
+ ### 0.9.0
133
+
134
+ - Added an on-demand multi-select mode: list checkboxes stay hidden until requested, then disappear automatically after a completed bulk action.
135
+ - Made common ZIP backup actions direct **Import backup / Export backup** controls and moved the destructive action under **More** for a cleaner header.
136
+ - Removed the cross-tool JSONL migration surface that could not provide native resume, keeping the plugin focused on DSH archived-chat management.
137
+ - Verified the new controls, backup preview, and single-line page title in a real DeepSeek Harness `0.1.0-rc.8` host.
138
+
139
+ ### 0.8.1
140
+
141
+ - Made the Chinese README the default repository and npm entry point; the English guide is now `README.en.md`.
142
+ - Moved maintainer architecture, routes, restore transactions, and deletion lifecycle details to `docs/ARCHITECTURE.md` and `docs/ARCHITECTURE.en.md`.
143
+ - Added a 🚀 marker to the install heading; runtime behavior remains unchanged from 0.8.0.
144
+
145
+ ### 0.8.0
146
+
147
+ - Added preview-first import for version-one ZIP backups.
148
+ - Added conflict-safe, transaction-based restore without overwriting existing sessions.
149
+ - Added workspace and attachment warnings, bounded validation, single-use confirmation tokens, and metadata restoration.
150
+
151
+ ### 0.7.0
152
+
153
+ - Added versioned JSON + Markdown ZIP backups for single, selected, and all archived sessions.
154
+ - Added streaming export, safe ZIP paths, manifest records, and canonical Markdown transcripts.
155
+
156
+ ### 0.6.0
157
+
158
+ - Added tags, notes, storage statistics, metadata persistence, and the archive insights UI.
159
+ - Hardened live deletion and added fallback handling for hosts that do not expose the internal lifecycle hooks.
160
+
161
+ ### 0.5.1
162
+
163
+ - Published a compatibility-focused patch release for DeepSeek Harness `0.1.0-rc.7`.
164
+ - Updated the browser settings section to use the rc.7 overlay and state design tokens.
165
+
166
+ ### 0.5.0
167
+
168
+ - Added bulk selection and bulk unarchive/delete workflows.
169
+ - Improved destructive-action focus handling and project-wide selection behavior.
170
+
171
+ ### 0.4.0
172
+
173
+ - Added in-place deletion for live sessions when the host exposes the required lifecycle hooks.
174
+ - Added the safe pending-deletion fallback, title caching, and a success toast after destructive actions.
175
+
176
+ ### 0.3.0
177
+
178
+ - First published release of the Archived Chats settings page.
179
+ - Added workspace-grouped browsing, title search, type/project filters, unarchive, and confirmed single/group/all deletion.
180
+ - Added host routes, the browser settings section, and the pending-deletion sweep for live sessions.
181
+
182
+ ### 0.1.0 and 0.2.0
183
+
184
+ - These versions were never published to npm and have no repository tags. `0.3.0` is the first public release.
185
+
186
+ ## Uninstall
187
+
188
+ ```sh
189
+ dsh plugin --profile web remove dsh-archived-chats
190
+ ```
191
+
192
+ The only leftovers are the small `pending-deletions.json` and `metadata.json` files under `$DSH_HOME/plugin-data/archived-chats/`; uninstalling does not process the delete queue or remove your tags/notes.
193
+
194
+ ## License
195
+
196
+ MIT
package/README.md CHANGED
@@ -1,97 +1,195 @@
1
1
  # dsh-archived-chats
2
2
 
3
- > ⚡ **Deletion takes effect immediately — no restart.** Even sessions still resident in the background are torn down safely along the official lifecycle and wiped from disk the moment you click delete, instead of being "parked until the next restart".
3
+ [English](README.en.md) | 中文
4
4
 
5
- A settings page for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) that brings archived chats back into view.
5
+ > ⚡ **删除即生效,无需重启。** 即使会话仍驻留在后台,也会沿官方生命周期当场安全拆除并从磁盘彻底删除——点下删除的那一刻就删干净,而不是"停用后等下次重启"。
6
6
 
7
- Once a conversation is archived in DeepSeek Harness it disappears from the sidebar, and there is no built-in way to browse it again — only the workspace store (`~/.dsh/storages/workspace.json`) still remembers it. This plugin adds an **Archived Chats** page under Settings where every archived session is visible, searchable, and manageable.
7
+ 为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 新增一个「已归档的聊天」设置页,把被归档的会话重新找回来。
8
8
 
9
- ## Install
9
+ 在 DeepSeek Harness 里,聊天一旦归档就会从侧边栏消失,界面中没有任何入口可以再看到它,只有工作区存档(`~/.dsh/storages/workspace.json`)还记得它。这个插件在「设置」中补上一个「已归档的聊天」页面,让所有归档会话都可见、可搜索、可管理。
10
+
11
+ ## 🚀 安装
12
+
13
+ ```sh
14
+ dsh plugin --profile web add dsh-archived-chats@latest
15
+ ```
16
+
17
+ 安装后重启一次 DSH,然后打开 **设置 → 已归档的聊天**。
18
+
19
+ 更新已有安装:
10
20
 
11
21
  ```sh
12
- dsh plugin --profile web add dsh-archived-chats
22
+ dsh plugin --profile web update dsh-archived-chats
13
23
  ```
14
24
 
15
- Restart DSH once after installing, then open **Settings → Archived Chats**.
25
+ ## 兼容性
16
26
 
17
- ## Compatibility
27
+ 0.9.0 版本以 DeepSeek Harness `0.1.0-rc.7` 作为自动化兼容性基线。插件注册的是顶层 `settings.section`,因此 rc.7 针对 `settings.plugin.item` 的 keyed-slot 变更不影响本插件;同时已在 Harness `0.1.0-rc.8` 上完成真实宿主页面复核,覆盖归档列表、搜索、元数据编辑、批量与分组操作以及备份导入预览。以后 Harness 发布新版本时,仍应在发布插件更新前重跑冒烟测试并检查真实宿主页面,因为客户端插槽和设计令牌契约仍可能演进。
18
28
 
19
- Version 0.8.0 uses DeepSeek Harness `0.1.0-rc.7` as its automated compatibility baseline. The plugin registers a top-level `settings.section`, so the rc.7 keyed-slot change for `settings.plugin.item` does not apply to it. A local real-host UI pass was also completed on Harness `0.1.0-rc.8` for the archive list, search, metadata editor, bulk actions, group actions, and import preview. Future Harness releases should still be checked with the smoke suite and a real-host UI pass before publishing a plugin update, because client slot and design-token contracts may evolve.
29
+ ## 预览
20
30
 
21
- ## Screenshots
31
+ 以下截图均来自 `0.9.0` 在本地 DeepSeek Harness `0.1.0-rc.8` Web profile 中的实际操作。
22
32
 
23
- These screenshots were captured from the current 0.8.0 build in a local DeepSeek Harness web profile.
33
+ ![已归档的聊天总览](assets/screenshots/1-archived-chats.png)
34
+ ![搜索与筛选](assets/screenshots/2-search.png)
35
+ ![删除确认](assets/screenshots/3-delete-confirm.png)
36
+ ![分组操作](assets/screenshots/4-group-menu.png)
37
+ ![标签与备注编辑](assets/screenshots/5-metadata-editor.png)
38
+ ![批量操作](assets/screenshots/6-bulk-actions.png)
39
+ ![导入预览](assets/screenshots/7-import-preview.png)
24
40
 
25
- ![Archived Chats overview](assets/screenshots/1-archived-chats.png)
26
- ![Search and filters](assets/screenshots/2-search.png)
27
- ![Delete confirmation](assets/screenshots/3-delete-confirm.png)
28
- ![Group actions](assets/screenshots/4-group-menu.png)
29
- ![Metadata editor](assets/screenshots/5-metadata-editor.png)
30
- ![Bulk actions](assets/screenshots/6-bulk-actions.png)
31
- ![Import preview](assets/screenshots/7-import-preview.png)
41
+ ## 使用流程
32
42
 
33
- ## Features
43
+ 1. 在 DSH 正常聊天的会话菜单中点击归档。归档只会把会话从侧边栏隐藏,工作区存档仍会保留会话数据。
44
+ 2. 打开 **设置 → 已归档的聊天**。页面按工作区分组,并在当前浏览器中记住分组的折叠状态。
45
+ 3. 搜索、筛选或排序会话;需要多选时点击 **批量选择** 显示复选框,完成后会自动恢复简洁列表。也可打开某一行的元数据编辑器添加标签与备注,或使用分组菜单执行项目级操作。
46
+ 4. 点击顶部 **导入备份** 选择本插件导出的 ZIP,预览后确认无冲突会话;点击 **导出备份** 导出当前选中项,未选择时导出全部归档会话。单条会话也可以从行内操作导出。
47
+ 5. 点击 **取消归档** 将会话放回侧边栏;只有确实需要永久删除时才点击 **删除**,确认弹窗会明确显示受影响的范围。**全部删除** 收纳在顶部 **更多** 菜单中。
34
48
 
35
- - **Complete archived-session list**, grouped by workspace (project) with a per-group count. Every group can be collapsed or expanded, and the state is remembered per browser.
36
- - **Search and sort** by title, workspace title, tags, and note text; filter by type (all / regular / subagent), project, and tag; then order results by newest, oldest, or title.
37
- - **Tags and notes**: open an editor from any row to attach up to 8 tags (24 Unicode characters each) and a note (2,000 Unicode characters). Tag chips render per row, overflowing past three into a `+N` indicator, and the tag filter narrows the list case-insensitively.
38
- - **Storage insights**: a summary strip reports the archived count, total measured size, and how many sessions could not be measured; each row shows its own size. Measurement never follows symbolic links and skips sessions whose directories are unreadable.
39
- - **JSON + Markdown backups**: export one row, the current selection, or every archived chat as a ZIP. Each package has a versioned manifest, a lossless machine-readable session record, and a human-readable transcript for every included session.
40
- - **Preview-first import and restore**: choose a ZIP backup, inspect every session before writing, preselect only non-conflicting IDs, and restore selected sessions as archived chats. Existing IDs are skipped and never overwritten.
41
- - **Flexible multi-select**: select individual chats, every visible result, or an entire project. The selection bar can export, unarchive, or permanently delete the chosen chats in one action, while selections hidden by another filter remain intact.
42
- - **Unarchive** a single chat or a whole project group from the group's `⋯` menu — restored chats reappear in the sidebar immediately.
43
- - **Delete** one chat, a project group, or everything (**Delete All**), each behind a confirmation dialog. Deletion is thorough: the session log is removed from disk, the session is detached from its workspace record, and the registry's in-memory header index is purged, so the sidebar drops the rows live.
44
- - Sessions still resident in the background are **deleted in place too**: the plugin disposes the session through the official lifecycle teardown order (cancel → quiesce → flush → fiber teardown → registry detach), the persistence layer releases the write path, and the physical delete completes within the same request — no restart. If the running DSH build does not expose the required internal seams, the plugin falls back to "park permanently + delete on the next start", with parked sessions staying hidden meanwhile.
45
- - Works in light and dark schemes; localized in English and 中文.
49
+ ## 功能
46
50
 
47
- ## Tags, notes, and statistics
51
+ - **完整归档列表**:按工作区(项目)分组并显示每组数量;每个分组都可折叠/展开,状态按浏览器记忆。
52
+ - **搜索与排序**:按标题、项目名、标签和备注内容搜索,用类型(全部 / 普通会话 / 子代理会话)、项目和标签筛选,并按最新、最早或标题排序。
53
+ - **标签与备注**:任意行打开编辑器即可添加最多 8 个标签(每个最多 24 个 Unicode 字符)和一条备注(最多 2,000 个 Unicode 字符)。每行渲染标签小徽章,超过 3 个折叠为 `+N`,标签筛选不区分大小写。
54
+ - **存储统计**:概览条显示归档数量、已统计总大小与无法统计的会话数;每行显示各自占用。统计不会跟随符号链接,无法读取的会话目录显示为「无法统计」而非让请求失败。
55
+ - **JSON + Markdown 备份**:可导出单条、当前选中项或全部归档会话。每个 ZIP 都包含带版本的清单、用于机器恢复的完整会话 JSON,以及方便阅读的 Markdown 对话稿。
56
+ - **预览后导入与恢复**:选择 ZIP 备份后先检查全部会话,默认选中无冲突 ID 的项目,确认后作为已归档聊天恢复。已有 ID 会跳过,绝不会覆盖。
57
+ - **紧凑顶部操作**:常用的 **导入备份** / **导出备份** 直接可用,低频危险操作收纳在 **更多**;页面专注于 DSH 归档管理,不常驻来源选择器或冗余菜单。
58
+ - **按需多选**:复选框默认隐藏,点击 **批量选择** 后才显示;可逐条选择、选择当前筛选结果或整个项目。选中后可一次导出、取消归档或永久删除,隐藏在其他筛选结果中的选择不会丢失。
59
+ - **取消归档**单个聊天,或从分组的 `⋯` 菜单整组取消——恢复的聊天会立刻回到侧边栏。
60
+ - **删除**单个聊天、某个项目分组或全部(**全部删除**),均有确认弹窗。删除是彻底的:会话日志从磁盘移除、从工作区记录中摘除、注册表内存索引同步清理,主侧边栏的条目也会立即消失。
61
+ - 仍驻留后台的会话也**当场删除**:插件按官方生命周期的拆除顺序原地停用并注销会话(取消 → 静默 → 落盘 → 拆纤程 → 摘出注册表),持久层随之释放写入通道,同一次请求内即完成物理删除——无需重启。若当前 DSH 版本不提供所需内部接口,则自动回退为「永久停用 + 下次启动完成删除」,停用期间会话保持隐藏。
62
+ - 适配浅色/深色主题,支持中文和英文界面。
48
63
 
49
- Tags and notes live **only on your machine** in `$DSH_HOME/plugin-data/archived-chats/metadata.json` — they are never uploaded, synced, or sent anywhere else. Unarchiving a session keeps its metadata; a completed physical deletion removes it, while a deferred or failed deletion keeps it intact. Metadata and statistics failures are always non-blocking: the list, unarchive, and deletion keep working even when the metadata store is unreadable or a session directory cannot be measured.
64
+ ## 标签、备注与统计
50
65
 
51
- ## Export and backup
66
+ 标签和备注**只保存在本机**的 `$DSH_HOME/plugin-data/archived-chats/metadata.json` 中——不会被上传、同步或发送到任何其他地方。取消归档会保留元数据;物理删除完成后会移除它,而延后或失败的删除会保留它。元数据与统计失败永远不阻塞:即使元数据存储无法读取或某个会话目录无法统计,列表、取消归档和删除仍然可用。
52
67
 
53
- Every export is a local browser download. A single session and a batch use the same ZIP format:
68
+ ## 导出与备份
69
+
70
+ 导出只会触发本地浏览器下载。单条和批量使用同一种 ZIP 格式:
54
71
 
55
72
  ```text
56
73
  manifest.json
57
- sessions/001-<safe-title>-<id>/session.json
58
- sessions/001-<safe-title>-<id>/transcript.md
74
+ sessions/001-<安全标题>-<id>/session.json
75
+ sessions/001-<安全标题>-<id>/transcript.md
59
76
  ```
60
77
 
61
- `session.json` is the authoritative backup record: it contains the complete metadata and event values returned by Harness persistence plus the archive title, workspace, timestamps, origin, tags, note, and storage facts. `transcript.md` is a readable companion derived with Harness's canonical message projection. ZIP paths are sanitized and collision-safe, and batches are generated one session at a time instead of buffering every transcript together.
78
+ `session.json` 是权威备份记录:原样保存 Harness 持久层返回的完整元数据和事件,并附带归档标题、工作区、时间、来源、标签、备注和存储统计。`transcript.md` 是通过 Harness 官方消息投影生成的可读副本。ZIP 路径会净化并处理重名,批量导出逐个会话生成,不会同时把所有会话内容堆进内存。
79
+
80
+ JSON 会保留附件引用,但**本版不复制附件二进制,也不包含子会话**。需要带完整附件的会话树时,请使用 Harness 官方的 Session log 导出。
81
+
82
+ ## 导入与恢复
83
+
84
+ 导入只接受本插件版本一的导出 ZIP。浏览器会先上传并进行有界校验,然后展示标题、项目、标签、备注、存储信息、ID 冲突、项目不存在警告和附件引用警告;预览不会渲染原始事件或 Markdown。已有会话 ID 会被禁用并跳过,找不到的项目会恢复为未分组。确认令牌 10 分钟后过期且只能使用一次。标签和备注通过现有本地元数据限制恢复,不会恢复附件二进制。宿主没有可用的 Harness 写入能力时返回 `restore-unsupported`,不会写入任何数据。
85
+
86
+ ## 常见问题
87
+
88
+ <details>
89
+ <summary><b>归档会删除聊天吗?</b></summary>
90
+
91
+ 不会。DSH 只是把聊天从侧边栏隐藏,并保留归档会话记录。这个插件提供设置页,用来查找、导出、恢复、取消归档或删除这些记录。
92
+
93
+ </details>
94
+
95
+ <details>
96
+ <summary><b>导入备份包含已存在的会话 ID 时会怎样?</b></summary>
97
+
98
+ 冲突行会在预览中明确标记,默认禁用并跳过。导入流程绝不会覆盖已有会话。
99
+
100
+ </details>
101
+
102
+ <details>
103
+ <summary><b>ZIP 备份包含附件吗?</b></summary>
104
+
105
+ `session.json` 会保留附件引用,但不会包含附件二进制或子会话。需要完整附件会话树时,请使用 Harness 官方 Session log 导出。
106
+
107
+ </details>
108
+
109
+ <details>
110
+ <summary><b>删除仍在运行的会话需要重启吗?</b></summary>
62
111
 
63
- Attachment references remain in JSON, but **attachment bytes and descendant sessions are not included**. Use Harness's official Session log export when you need its attachment-complete conversation-tree package.
112
+ 在提供所需生命周期接口的宿主上,删除会在同一次请求中拆除运行中的会话并移除文件。较旧或不兼容的宿主会使用安全的待删队列,在下次启动时完成物理删除。
64
113
 
65
- ## Import and restore
114
+ </details>
66
115
 
67
- Import accepts only this plugin's version-one export ZIPs. The browser first uploads the package for bounded validation and shows a preview containing titles, workspaces, tags, notes, storage facts, ID conflicts, unresolved-workspace warnings, and attachment-reference warnings; raw events and Markdown are never rendered in the preview. Existing session IDs are disabled and skipped, and unresolved workspaces are restored ungrouped. A confirmation token expires after 10 minutes and can be used once. Tags and notes are restored through the same local metadata limits as manual edits. No attachment bytes are restored. Hosts without the supported Harness writer capability return `restore-unsupported` without writing anything.
116
+ ## 实现概览
68
117
 
69
- ## How it works
118
+ 插件由两部分组成:Host 服务层负责读取和修改本地归档数据,浏览器设置页负责搜索、筛选、备份和恢复。所有修改都通过受保护的本地路由完成;导入会先预览,删除会优先尝试安全的生命周期拆除,能力不足时回退到下次启动处理。
70
119
 
71
- - **Host half** (`lib/index.js`) registers the `/plugins/dsh-archived-chats/*` routes on the DSH web server: `GET /state`, `GET /stats`, `POST /export`, `POST /import/inspect`, `POST /import/restore`, `POST /metadata`, `POST /unarchive`, `POST /unarchive-all`, `POST /delete`, `POST /delete-all`. `/state` joins tags, notes, and `metadataUpdatedAt` onto every row; `/stats` returns byte/file totals; `/export` streams a ZIP response from a bounded native-form request; the import routes validate a bounded multipart ZIP, keep a short-lived single-use preview token, and commit through the feature-detected restore adapter. Unarchiving writes through the workspace registry's own state path, so every connected client receives the `host/archived-sessions-changed` push. Mutating routes require a custom `x-dsh-archived-chats: 1` header as CSRF hardening; read-only export does not mutate plugin or Harness state.
72
- - **Export writer** (`lib/export.js`): owns format-versioned records, safe filenames, Harness transcript projection, and sequential ZIP entries. It preflights the first session before response headers and keeps at most one inspected session payload during a batch.
73
- - **Metadata store** (`lib/metadata.js`): a versioned, atomic JSON store. Writes serialize through a queue and replace the file via a temp-file rename, so simultaneous saves cannot interleave; unreadable or unsupported files are never overwritten.
74
- - **Storage statistics** (`lib/stats.js`): measures session directories at concurrency 4, skips symbolic links, caches results for 30 seconds, and reports unavailable rows instead of failing the request. Delete invalidates the cached row.
75
- - **In-place live deletion**: deleting a resident session replays the agent factory's own disposer sequence — `cancel({ kind: 'disposed' })` → `whenIdle` → `flush` → `agent.scope.dispose()` → detach of the `agents` and `sessions` store entries. The session detach emits `session/disposed`, the persistence coordinator retires (drains and releases) the write path, and the ordinary cold delete completes in the same request. The store entries are internal surfaces, so every step is feature-detected; anything missing falls back to park-and-defer.
76
- - **Pending-deletion store** (fallback path and crash bracket): the id is recorded in `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json` while the session stays archived and hidden; the next boot sweeps the queue through the ordinary delete path. In-place deletes are bracketed by the same store (recorded before disposal, cleared once the files are gone), so a crash mid-delete is completed on the next start. Parked sessions are excluded from the listing; unarchiving cancels a pending deletion.
77
- - **Title cache**: resolved titles are memoized per id across list refreshes instead of re-reading every archived log; delete and unarchive invalidate their entries.
78
- - **Browser half** (`lib/client.js`) registers a `settings.section` slot entry (order 30) and renders the page with React and the rc.7 DSH overlay/state design tokens.
120
+ 普通用户需要了解的数据保存、备份限制、删除结果和兼容性说明已列在本 README 中。路由清单、数据流、恢复事务、实时删除生命周期和失败回退等维护者细节请参阅 [架构文档](docs/ARCHITECTURE.md)。
79
121
 
80
- ## Development
122
+ ## 开发
81
123
 
82
124
  ```sh
83
125
  npm test
84
126
  ```
85
127
 
86
- The suite (`test/*.test.mjs`) covers export records and real ZIP decoding, bounded import validation, restore transactions, the metadata store, the statistics service, and host-and-browser smoke tests. It uses an isolated temporary DSH home plus mocked host and browser runtimes; it never reads or changes real sessions.
128
+ 测试套件(`test/*.test.mjs`)覆盖导出记录与真实 ZIP 解包、有界导入校验、恢复事务、元数据存储、统计服务,以及宿主+浏览器冒烟测试。测试使用隔离的临时 DSH 主目录和模拟运行时,不会读取或修改真实会话。
129
+
130
+ ## 版本更新记录
131
+
132
+ ### 0.9.0
133
+
134
+ - 新增按需显示的批量选择模式:列表默认不展示复选框,点击入口后才显示,完成批量操作后自动退出。
135
+ - 将常用 ZIP 备份操作改为直接的 **导入备份 / 导出备份**,危险操作收纳到 **更多**,精简页头布局。
136
+ - 移除未提供原生继续能力的跨工具 JSONL 迁移入口,让插件专注于 DSH 已归档聊天管理。
137
+ - 在 DeepSeek Harness `0.1.0-rc.8` 真实宿主中复核新控件、备份预览和标题单行布局。
138
+
139
+ ### 0.8.1
140
+
141
+ - 将中文 README 设为仓库和 npm 包的默认入口,英文文档改为 `README.en.md`。
142
+ - 将维护者架构、路由、恢复事务和删除生命周期细节移到 `docs/ARCHITECTURE.md` 与 `docs/ARCHITECTURE.en.md`。
143
+ - 安装章节增加快速识别用的 🚀 图标;插件运行时行为保持与 0.8.0 一致。
144
+
145
+ ### 0.8.0
146
+
147
+ - 新增版本一 ZIP 备份的预览后导入。
148
+ - 新增不会覆盖已有会话的冲突安全恢复和事务式写入。
149
+ - 新增工作区/附件警告、有界校验、一次性确认令牌和元数据恢复。
150
+
151
+ ### 0.7.0
152
+
153
+ - 新增单条、选中项和全部归档会话的带版本 JSON + Markdown ZIP 备份。
154
+ - 新增流式导出、安全 ZIP 路径、清单记录和官方消息投影生成的 Markdown 对话稿。
155
+
156
+ ### 0.6.0
157
+
158
+ - 新增标签、备注、存储统计、元数据持久化和归档洞察界面。
159
+ - 加固仍在运行会话的删除流程,并为不提供内部生命周期接口的宿主增加回退处理。
160
+
161
+ ### 0.5.1
162
+
163
+ - 发布面向 DeepSeek Harness `0.1.0-rc.7` 的兼容性修订版本。
164
+ - 更新浏览器设置区块,使用 rc.7 的浮层和状态设计令牌。
165
+
166
+ ### 0.5.0
167
+
168
+ - 新增多选以及批量取消归档/删除流程。
169
+ - 改进破坏性操作后的焦点恢复和项目范围选择行为。
170
+
171
+ ### 0.4.0
172
+
173
+ - 在宿主提供所需生命周期接口时,新增仍在运行会话的原地删除。
174
+ - 新增安全的待删队列回退、标题缓存,以及破坏性操作完成后的成功提示。
175
+
176
+ ### 0.3.0
177
+
178
+ - 首个公开发布版本,提供「已归档的聊天」设置页。
179
+ - 新增按工作区分组浏览、标题搜索、类型/项目筛选、取消归档,以及带确认的单条/分组/全部删除。
180
+ - 新增 Host 路由、浏览器设置区块,以及用于处理运行中会话的待删队列清扫。
181
+
182
+ ### 0.1.0 和 0.2.0
183
+
184
+ - 这两个版本从未发布到 npm,也没有对应的仓库标签;`0.3.0` 是首个公开版本。
87
185
 
88
- ## Uninstall
186
+ ## 卸载
89
187
 
90
188
  ```sh
91
189
  dsh plugin --profile web remove dsh-archived-chats
92
190
  ```
93
191
 
94
- The only leftovers are the small `pending-deletions.json` and `metadata.json` files under `$DSH_HOME/plugin-data/archived-chats/`; uninstalling does not process the delete queue or remove your tags/notes.
192
+ 唯一残留是 `$DSH_HOME/plugin-data/archived-chats/` 下的待删队列 `pending-deletions.json` 和 `metadata.json` 两个小文件;卸载不会触发队列处理,也不会删除你的标签与备注。
95
193
 
96
194
  ## License
97
195
 
Binary file
Binary file
package/lib/client.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // packages emit.
8
8
  //
9
9
  // What this plugin is: one settings section — 已归档的聊天 / Archived Chats —
10
- // modeled 1:1 on the CodeX archived-chats page. The stock DSH sidebar hides
10
+ // modeled on the host archive-management layout. The stock DSH sidebar hides
11
11
  // archived sessions with no list surface; this page restores one: search,
12
12
  // type/project filters, sorting, multi-selection, unarchive, delete, and
13
13
  // batch actions, all driven through the host half's /plugins/dsh-archived-chats/*
@@ -48,6 +48,9 @@ window.__ModuleLoader__.load({
48
48
  "locale.intl": "zh-CN",
49
49
  "nav": "已归档的聊天",
50
50
  "page.title": "已归档的聊天",
51
+ "action.import": "导入备份",
52
+ "action.export": "导出备份",
53
+ "action.more": "更多",
51
54
  "delete.all": "全部删除",
52
55
  "import.action": "导入备份",
53
56
  "import.title": "导入归档备份",
@@ -80,6 +83,8 @@ window.__ModuleLoader__.load({
80
83
  "sort.title": "按标题",
81
84
  "selection.visible": "选择当前结果",
82
85
  "selection.group": "选择此项目",
86
+ "selection.start": "批量选择",
87
+ "selection.done": "完成",
83
88
  "selection.clear": "清除",
84
89
  "bulk.unarchive": "取消归档",
85
90
  "bulk.delete": "删除",
@@ -120,6 +125,9 @@ window.__ModuleLoader__.load({
120
125
  "locale.intl": "en-US",
121
126
  "nav": "Archived Chats",
122
127
  "page.title": "Archived Chats",
128
+ "action.import": "Import backup",
129
+ "action.export": "Export backup",
130
+ "action.more": "More",
123
131
  "delete.all": "Delete All",
124
132
  "import.action": "Import backup",
125
133
  "import.title": "Import archived backup",
@@ -152,6 +160,8 @@ window.__ModuleLoader__.load({
152
160
  "sort.title": "Title",
153
161
  "selection.visible": "Select visible chats",
154
162
  "selection.group": "Select this project",
163
+ "selection.start": "Select multiple",
164
+ "selection.done": "Done",
155
165
  "selection.clear": "Clear",
156
166
  "bulk.unarchive": "Unarchive",
157
167
  "bulk.delete": "Delete",
@@ -217,7 +227,7 @@ window.__ModuleLoader__.load({
217
227
  : n === 1
218
228
  ? "1 chat is parked and will be permanently deleted after DSH restarts"
219
229
  : `${n} chats are parked and will be permanently deleted after DSH restarts`;
220
- // Success confirmation after a completed delete — same copy as CodeX.
230
+ // Success confirmation after a completed delete.
221
231
  const deletedText = (t, n) => isZh(t)
222
232
  ? "已删除归档聊天"
223
233
  : n === 1 ? "Archived chat deleted" : `${n} archived chats deleted`;
@@ -358,17 +368,18 @@ window.__ModuleLoader__.load({
358
368
  const CSS = `
359
369
  .dac-page{position:relative;display:flex;flex-direction:column;gap:14px;padding:4px 0 28px;font-family:inherit}
360
370
  .dac-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
361
- .dac-title{margin:0;color:var(--dsw-alias-label-primary);font-size:18px;font-weight:500;line-height:28px;outline:none}
362
- .dac-head-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:wrap}
363
- .dac-exportall{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:999px;padding:6px 12px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background .15s,color .15s}
364
- .dac-exportall:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary)}
365
- .dac-exportall:disabled{opacity:.45;cursor:default}
366
- .dac-import{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:5px 11px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;cursor:pointer}
367
- .dac-import:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
368
- .dac-import:disabled{opacity:.45;cursor:default}
369
- .dac-deleteall{display:inline-flex;align-items:center;gap:6px;border:none;border-radius:999px;padding:6px 14px;background:transparent;color:var(--dsw-alias-state-error-primary);font:inherit;font-size:13px;line-height:20px;cursor:pointer;transition:background .15s}
370
- .dac-deleteall:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
371
- .dac-deleteall:disabled{opacity:.45;cursor:default}
371
+ .dac-title{flex:none;white-space:nowrap;margin:0;color:var(--dsw-alias-label-primary);font-size:18px;font-weight:500;line-height:28px;outline:none}
372
+ .dac-head-actions{position:relative;display:flex;flex:0 1 auto;min-width:0;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:nowrap}
373
+ .dac-action-wrap{position:relative;display:inline-flex}
374
+ .dac-action-trigger{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:5px 11px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;white-space:nowrap;cursor:pointer}
375
+ .dac-action-trigger:hover:not(:disabled),.dac-action-trigger[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
376
+ .dac-action-trigger:disabled{opacity:.45;cursor:default}
377
+ .dac-action-menu{position:absolute;right:0;top:calc(100% + 6px);z-index:60;min-width:190px;display:flex;flex-direction:column;gap:2px;padding:4px;border:1px solid var(--dsw-alias-border-inverted);border-radius:10px;background:var(--dsw-specific-menu);box-shadow:var(--dsw-shadow-lv3)}
378
+ .dac-action-menu-item{display:flex;align-items:center;width:100%;border:none;border-radius:7px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;text-align:left;padding:7px 10px;white-space:nowrap;cursor:pointer}
379
+ .dac-action-menu-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
380
+ .dac-action-menu-item:disabled{opacity:.45;cursor:default}
381
+ .dac-action-menu-item.dac-danger{color:var(--dsw-alias-state-error-primary)}
382
+ .dac-action-menu-item.dac-danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
372
383
  .dac-search{display:flex;align-items:center;gap:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1);padding:8px 12px;color:var(--dsw-alias-label-tertiary);transition:border-color .15s}
373
384
  .dac-search:focus-within{border-color:var(--dsw-alias-border-l1,var(--dsw-alias-border-l2))}
374
385
  .dac-search input{flex:1;min-width:0;border:none;outline:none;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:0}
@@ -378,7 +389,10 @@ window.__ModuleLoader__.load({
378
389
  .dac-select{appearance:none;-webkit-appearance:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:5px 28px 5px 14px;cursor:pointer;outline:none}
379
390
  .dac-select:hover{border-color:var(--dsw-alias-border-l1,var(--dsw-alias-border-l2))}
380
391
  .dac-chevron{position:absolute;right:10px;pointer-events:none;color:var(--dsw-alias-label-tertiary);display:inline-flex}
381
- .dac-selection-toggle{display:inline-flex;align-items:center;gap:7px;margin-left:auto;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;cursor:pointer;user-select:none}
392
+ .dac-selection-controls{display:flex;align-items:center;gap:8px;margin-left:auto}
393
+ .dac-selection-toggle{display:inline-flex;align-items:center;gap:7px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;cursor:pointer;user-select:none}
394
+ .dac-selection-mode{border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;line-height:20px;padding:5px 12px;cursor:pointer}
395
+ .dac-selection-mode:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}
382
396
  .dac-checkbox{width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-interactive-primary,#6e6ef7);cursor:pointer;flex:none}
383
397
  .dac-checkbox:disabled{cursor:default;opacity:.45}
384
398
  .dac-bulkbar{position:sticky;top:8px;z-index:30;display:flex;align-items:center;justify-content:space-between;gap:12px;border:1px solid color-mix(in srgb,var(--dsw-alias-interactive-primary,#6e6ef7) 35%,var(--dsw-alias-border-l2));border-radius:12px;background:color-mix(in srgb,var(--dsw-alias-interactive-primary,#6e6ef7) 8%,var(--dsw-alias-bg-layer-2));padding:9px 12px;box-shadow:0 5px 18px rgba(0,0,0,.08)}
@@ -479,7 +493,7 @@ window.__ModuleLoader__.load({
479
493
  .dac-btn-primary{border:1px solid var(--dsw-alias-interactive-primary,#6e6ef7);border-radius:9px;background:var(--dsw-alias-interactive-primary,#6e6ef7);color:var(--dsw-alias-bg-layer-1);font:inherit;font-size:13px;line-height:20px;padding:5px 14px;cursor:pointer}
480
494
  .dac-btn-primary:hover:not(:disabled){opacity:.9}
481
495
  .dac-btn-primary:disabled{opacity:.5;cursor:default}
482
- @media (max-width:640px){[role="dialog"][data-dac-section-active="1"]>nav{display:none}[role="dialog"][data-dac-section-active="1"]>nav+div{width:100%;min-width:0}.dac-head{align-items:flex-start}.dac-head-actions{max-width:65%}.dac-selection-toggle{margin-left:0}.dac-bulkbar{align-items:flex-start;flex-direction:column}.dac-bulk-actions{width:100%;flex-wrap:wrap}.dac-row{align-items:flex-start}.dac-row-actions{gap:4px}.dac-unarchive{padding:5px 10px}.dac-summary{gap:6px}.dac-row-meta{gap:4px}}
496
+ @media (max-width:640px){[role="dialog"][data-dac-section-active="1"]>nav{display:none}[role="dialog"][data-dac-section-active="1"]>nav+div{width:100%;min-width:0}.dac-head{align-items:flex-start;flex-wrap:wrap}.dac-head-actions{width:100%;justify-content:flex-start}.dac-action-trigger{padding:5px 9px}.dac-action-menu{left:0;right:auto;max-width:calc(100vw - 32px)}.dac-action-wrap:last-child .dac-action-menu{left:auto;right:0}.dac-selection-controls{width:100%;justify-content:space-between}.dac-bulkbar{align-items:flex-start;flex-direction:column}.dac-bulk-actions{width:100%;flex-wrap:wrap}.dac-row{align-items:flex-start}.dac-row-actions{gap:4px}.dac-unarchive{padding:5px 10px}.dac-summary{gap:6px}.dac-row-meta{gap:4px}}
483
497
  `;
484
498
 
485
499
  function ensureStyle() {
@@ -987,7 +1001,7 @@ window.__ModuleLoader__.load({
987
1001
  });
988
1002
  }
989
1003
 
990
- function GroupSection({ group, t, collapsed, onToggleCollapsed, menuOpen, onToggleMenu, onUnarchive, onDelete, onExport, busy, selected, onToggleSelected, stats, metadataStatus, onEditMetadata }) {
1004
+ function GroupSection({ group, t, collapsed, onToggleCollapsed, menuOpen, onToggleMenu, onUnarchive, onDelete, onExport, busy, selectionMode, selected, onToggleSelected, stats, metadataStatus, onEditMetadata }) {
991
1005
  const wrapRef = _react.useRef(null);
992
1006
  _react.useEffect(() => {
993
1007
  if (!menuOpen) return void 0;
@@ -1010,7 +1024,7 @@ window.__ModuleLoader__.load({
1010
1024
  (0, jsx.jsxs)("div", {
1011
1025
  className: "dac-group-left",
1012
1026
  children: [
1013
- (0, jsx.jsx)(SelectionCheckbox, {
1027
+ selectionMode && (0, jsx.jsx)(SelectionCheckbox, {
1014
1028
  checked: allSelected,
1015
1029
  indeterminate: someSelected,
1016
1030
  disabled: groupBusy,
@@ -1069,12 +1083,12 @@ window.__ModuleLoader__.load({
1069
1083
  collapsed !== true && (0, jsx.jsx)("div", {
1070
1084
  className: "dac-list",
1071
1085
  children: group.items.map((session) => (0, jsx.jsxs)("div", {
1072
- className: selected.has(session.id) ? "dac-row dac-selected" : "dac-row",
1086
+ className: selectionMode && selected.has(session.id) ? "dac-row dac-selected" : "dac-row",
1073
1087
  children: [
1074
- (0, jsx.jsxs)("label", {
1088
+ (0, jsx.jsxs)(selectionMode ? "label" : "div", {
1075
1089
  className: "dac-row-select",
1076
1090
  children: [
1077
- (0, jsx.jsx)(SelectionCheckbox, {
1091
+ selectionMode && (0, jsx.jsx)(SelectionCheckbox, {
1078
1092
  checked: selected.has(session.id),
1079
1093
  disabled: busy[session.id] === true,
1080
1094
  ariaLabel: selectChatLabel(t, session.title ?? t("chat.untitled")),
@@ -1140,7 +1154,7 @@ window.__ModuleLoader__.load({
1140
1154
  }, group.key);
1141
1155
  }
1142
1156
 
1143
- /** The settings section page: archived chats, CodeX-layout. */
1157
+ /** The settings section page for archived-chat management. */
1144
1158
  function ArchivedChatsSection({ t, refreshSidebar }) {
1145
1159
  const [sessions, setSessions] = _react.useState(null);
1146
1160
  const [loadError, setLoadError] = _react.useState(null);
@@ -1161,18 +1175,41 @@ window.__ModuleLoader__.load({
1161
1175
  const [metaBusy, setMetaBusy] = _react.useState(false);
1162
1176
  const [importPreview, setImportPreview] = _react.useState(null);
1163
1177
  const [importBusy, setImportBusy] = _react.useState(false);
1178
+ const [selectionMode, setSelectionMode] = _react.useState(false);
1179
+ const [actionMenu, setActionMenu] = _react.useState(null);
1164
1180
  const pageRef = _react.useRef(null);
1165
1181
  const pageHeadingRef = _react.useRef(null);
1182
+ const actionMenuRef = _react.useRef(null);
1183
+ const moreActionRef = _react.useRef(null);
1166
1184
  const deleteReturnFocusRef = _react.useRef(null);
1167
1185
  const metaReturnFocusRef = _react.useRef(null);
1168
1186
  const importInputRef = _react.useRef(null);
1169
1187
 
1188
+ _react.useEffect(() => {
1189
+ if (actionMenu === null) return undefined;
1190
+ const onDown = (event) => {
1191
+ if (actionMenuRef.current && !actionMenuRef.current.contains(event.target)) setActionMenu(null);
1192
+ };
1193
+ document.addEventListener("mousedown", onDown);
1194
+ return () => {
1195
+ document.removeEventListener("mousedown", onDown);
1196
+ };
1197
+ }, [actionMenu]);
1198
+
1199
+ const closeActionMenuFromKeyboard = (event) => {
1200
+ if (event.key !== "Escape" || actionMenu === null) return;
1201
+ event.preventDefault();
1202
+ event.stopPropagation();
1203
+ setActionMenu(null);
1204
+ moreActionRef.current?.focus?.();
1205
+ };
1206
+
1170
1207
  _react.useEffect(() => {
1171
1208
  if (sessions === null || loadError !== null) return undefined;
1172
1209
  return markArchiveDialog(pageRef.current);
1173
1210
  }, [sessions, loadError]);
1174
1211
 
1175
- // Success confirmations are transient toasts (CodeX behavior);
1212
+ // Success confirmations are transient toasts;
1176
1213
  // errors stay until dismissed.
1177
1214
  _react.useEffect(() => {
1178
1215
  if (notice?.kind !== "ok") return undefined;
@@ -1242,6 +1279,10 @@ window.__ModuleLoader__.load({
1242
1279
  const toggleSelected = (ids, checked) => {
1243
1280
  setSelected((current) => setVisibleSelection(current, ids, checked));
1244
1281
  };
1282
+ const finishSelectionMode = () => {
1283
+ setSelected(new Set());
1284
+ setSelectionMode(false);
1285
+ };
1245
1286
 
1246
1287
  const markBusy = (ids, value) => {
1247
1288
  setBusy((prev) => {
@@ -1254,7 +1295,7 @@ window.__ModuleLoader__.load({
1254
1295
  });
1255
1296
  };
1256
1297
 
1257
- const unarchive = async (ids) => {
1298
+ const unarchive = async (ids, finishSelectionAfter = false) => {
1258
1299
  if (ids.length === 0) return;
1259
1300
  markBusy(ids, true);
1260
1301
  setNotice(null);
@@ -1262,6 +1303,7 @@ window.__ModuleLoader__.load({
1262
1303
  await post("/unarchive-all", { sessionIds: ids });
1263
1304
  setSessions((prev) => (prev ?? []).filter((s) => !ids.includes(s.id)));
1264
1305
  setSelected((current) => setVisibleSelection(current, ids, false));
1306
+ if (finishSelectionAfter) finishSelectionMode();
1265
1307
  } catch (error) {
1266
1308
  setNotice({ kind: "error", text: String(error.message ?? error) });
1267
1309
  } finally {
@@ -1269,7 +1311,7 @@ window.__ModuleLoader__.load({
1269
1311
  }
1270
1312
  };
1271
1313
 
1272
- const runDelete = async (ids) => {
1314
+ const runDelete = async (ids, finishSelectionAfter = false) => {
1273
1315
  if (ids.length === 0) return;
1274
1316
  markBusy(ids, true);
1275
1317
  setNotice(null);
@@ -1291,6 +1333,7 @@ window.__ModuleLoader__.load({
1291
1333
  if (failed.length > 0) setNotice({ kind: "error", text: failureText(t, failed) });
1292
1334
  else if (pending.length > 0) setNotice({ kind: "error", text: pendingText(t, pending.length) });
1293
1335
  else if (deleted.length > 0) setNotice({ kind: "ok", text: deletedText(t, deleted.length) });
1336
+ if (finishSelectionAfter && failed.length === 0 && deleted.length + pending.length === ids.length) finishSelectionMode();
1294
1337
  } catch (error) {
1295
1338
  const deleted = error.body?.deleted ?? [];
1296
1339
  const pending = error.body?.pending ?? [];
@@ -1317,7 +1360,7 @@ window.__ModuleLoader__.load({
1317
1360
  if (ids.length === 0) return;
1318
1361
  deleteReturnFocusRef.current = document.activeElement;
1319
1362
  if (selectedScope) {
1320
- setConfirm({ title: t("confirm.deleteSelected.title"), body: deleteSelectedBody(t, ids.length), ids });
1363
+ setConfirm({ title: t("confirm.deleteSelected.title"), body: deleteSelectedBody(t, ids.length), ids, finishSelectionAfter: true });
1321
1364
  } else if (ids.length === 1 && groupName === null) {
1322
1365
  setConfirm({ title: t("confirm.deleteOne.title"), body: t("confirm.deleteOne.body"), ids });
1323
1366
  } else if (groupName !== null && groupName !== void 0) {
@@ -1354,8 +1397,9 @@ window.__ModuleLoader__.load({
1354
1397
  };
1355
1398
 
1356
1399
  const exportSessions = (ids) => {
1357
- if (!submitExport(ids)) return;
1400
+ if (!submitExport(ids)) return false;
1358
1401
  setNotice({ kind: "ok", text: t("export.started") });
1402
+ return true;
1359
1403
  };
1360
1404
 
1361
1405
  const importFile = async (file) => {
@@ -1501,29 +1545,41 @@ window.__ModuleLoader__.load({
1501
1545
  children: [
1502
1546
  (0, jsx.jsx)("h2", { ref: pageHeadingRef, tabIndex: -1, className: "dac-title", children: t("page.title") }),
1503
1547
  (0, jsx.jsxs)("div", {
1548
+ ref: actionMenuRef,
1504
1549
  className: "dac-head-actions",
1505
- children: [
1550
+ onKeyDown: closeActionMenuFromKeyboard,
1551
+ children: [
1552
+ (0, jsx.jsx)("div", { className: "dac-action-wrap", children:
1506
1553
  (0, jsx.jsxs)("button", {
1507
1554
  type: "button",
1508
- className: "dac-import",
1555
+ className: "dac-action-trigger",
1509
1556
  disabled: importBusy,
1510
1557
  onClick: () => importInputRef.current?.click?.(),
1511
- children: [(0, jsx.jsx)(IconUpload, {}), (0, jsx.jsx)("span", { children: t("import.action") })]
1512
- }),
1513
- selectedIds.length === 0 && (0, jsx.jsxs)("button", {
1514
- type: "button",
1515
- className: "dac-exportall",
1516
- disabled: allIds.length === 0 || allBusy,
1517
- onClick: () => exportSessions(allIds),
1518
- children: [(0, jsx.jsx)(IconDownload, {}), (0, jsx.jsx)("span", { children: t("export.all") })]
1519
- }),
1558
+ children: [(0, jsx.jsx)(IconUpload, {}), (0, jsx.jsx)("span", { children: t("action.import") })]
1559
+ })
1560
+ }),
1561
+ (0, jsx.jsx)("div", { className: "dac-action-wrap", children:
1520
1562
  (0, jsx.jsxs)("button", {
1521
1563
  type: "button",
1522
- className: "dac-deleteall",
1523
- disabled: allIds.length === 0,
1524
- onClick: () => askDelete(allIds, void 0),
1525
- children: [(0, jsx.jsx)(IconTrash, {}), (0, jsx.jsx)("span", { children: t("delete.all") })]
1564
+ className: "dac-action-trigger",
1565
+ disabled: allIds.length === 0 || allBusy,
1566
+ onClick: () => { const didExport = exportSessions(selectedIds.length > 0 ? selectedIds : allIds); if (didExport && selectedIds.length > 0) finishSelectionMode(); },
1567
+ children: [(0, jsx.jsx)(IconDownload, {}), (0, jsx.jsx)("span", { children: t("action.export") })]
1526
1568
  })
1569
+ }),
1570
+ (0, jsx.jsxs)("div", { className: "dac-action-wrap", children: [
1571
+ (0, jsx.jsxs)("button", {
1572
+ ref: moreActionRef,
1573
+ type: "button",
1574
+ className: "dac-action-trigger",
1575
+ disabled: allIds.length === 0,
1576
+ "aria-controls": "dac-more-actions",
1577
+ "aria-expanded": actionMenu === "more",
1578
+ onClick: () => setActionMenu((current) => current === "more" ? null : "more"),
1579
+ children: [(0, jsx.jsx)(IconDots, {}), (0, jsx.jsx)("span", { children: t("action.more") })]
1580
+ }),
1581
+ actionMenu === "more" && (0, jsx.jsx)("div", { id: "dac-more-actions", className: "dac-action-menu", children: (0, jsx.jsx)("button", { type: "button", className: "dac-action-menu-item dac-danger", onClick: () => { setActionMenu(null); askDelete(allIds, void 0); }, children: t("delete.all") }) })
1582
+ ] })
1527
1583
  ]
1528
1584
  }),
1529
1585
  (0, jsx.jsx)("input", {
@@ -1603,17 +1659,28 @@ window.__ModuleLoader__.load({
1603
1659
  { value: "title", label: t("sort.title") }
1604
1660
  ]
1605
1661
  }),
1606
- visibleIds.length > 0 && (0, jsx.jsxs)("label", {
1607
- className: "dac-selection-toggle",
1662
+ (visibleIds.length > 0 || selectionMode) && (0, jsx.jsxs)("div", {
1663
+ className: "dac-selection-controls",
1608
1664
  children: [
1609
- (0, jsx.jsx)(SelectionCheckbox, {
1610
- checked: allVisibleSelected,
1611
- indeterminate: someVisibleSelected,
1612
- disabled: visibleBusy,
1613
- ariaLabel: t("selection.visible"),
1614
- onChange: (checked) => toggleSelected(visibleIds, checked)
1665
+ selectionMode && visibleIds.length > 0 && (0, jsx.jsxs)("label", {
1666
+ className: "dac-selection-toggle",
1667
+ children: [
1668
+ (0, jsx.jsx)(SelectionCheckbox, {
1669
+ checked: allVisibleSelected,
1670
+ indeterminate: someVisibleSelected,
1671
+ disabled: visibleBusy,
1672
+ ariaLabel: t("selection.visible"),
1673
+ onChange: (checked) => toggleSelected(visibleIds, checked)
1674
+ }),
1675
+ (0, jsx.jsx)("span", { children: t("selection.visible") })
1676
+ ]
1615
1677
  }),
1616
- (0, jsx.jsx)("span", { children: t("selection.visible") })
1678
+ (0, jsx.jsx)("button", {
1679
+ type: "button",
1680
+ className: "dac-selection-mode",
1681
+ onClick: selectionMode ? finishSelectionMode : () => setSelectionMode(true),
1682
+ children: t(selectionMode ? "selection.done" : "selection.start")
1683
+ })
1617
1684
  ]
1618
1685
  })
1619
1686
  ]
@@ -1631,15 +1698,15 @@ window.__ModuleLoader__.load({
1631
1698
  type: "button",
1632
1699
  className: "dac-bulk-btn",
1633
1700
  disabled: selectedBusy,
1634
- onClick: () => exportSessions(selectedIds),
1701
+ onClick: () => { if (exportSessions(selectedIds)) finishSelectionMode(); },
1635
1702
  children: t("export.selected")
1636
1703
  }),
1637
1704
  (0, jsx.jsx)("button", {
1638
1705
  type: "button",
1639
1706
  className: "dac-bulk-btn",
1640
- disabled: selectedBusy,
1641
- onClick: () => unarchive(selectedIds),
1642
- children: t("bulk.unarchive")
1707
+ disabled: selectedBusy,
1708
+ onClick: () => unarchive(selectedIds, true),
1709
+ children: t("bulk.unarchive")
1643
1710
  }),
1644
1711
  (0, jsx.jsx)("button", {
1645
1712
  type: "button",
@@ -1694,12 +1761,13 @@ window.__ModuleLoader__.load({
1694
1761
  onDelete: askDelete,
1695
1762
  onExport: exportSessions,
1696
1763
  busy,
1764
+ selectionMode,
1697
1765
  selected,
1698
1766
  onToggleSelected: toggleSelected,
1699
1767
  stats,
1700
1768
  metadataStatus,
1701
1769
  onEditMetadata: openMetadataEditor
1702
- }, group.key)),
1770
+ }, group.key)),
1703
1771
  importPreview !== null && (0, jsx.jsx)(ImportDialog, {
1704
1772
  preview: importPreview,
1705
1773
  t,
@@ -1727,7 +1795,7 @@ window.__ModuleLoader__.load({
1727
1795
  busy: confirm.ids.some((id) => busy[id] === true),
1728
1796
  returnFocus: deleteReturnFocusRef.current,
1729
1797
  fallbackFocusRef: pageHeadingRef,
1730
- onConfirm: () => runDelete(confirm.ids),
1798
+ onConfirm: () => runDelete(confirm.ids, confirm.finishSelectionAfter === true),
1731
1799
  onCancel: () => setConfirm(null)
1732
1800
  })
1733
1801
  ]
@@ -1765,7 +1833,7 @@ window.__ModuleLoader__.load({
1765
1833
  //#endregion
1766
1834
 
1767
1835
  exports.SETTINGS_NS = SETTINGS_NS;
1768
- exports.__test = { formatBytes, matchesArchivedSession, filterByTag, sortArchivedSessions, setVisibleSelection, reconcileSelection, markArchiveDialog, submitExport, submitImportFile, editIconSpec: EDIT_ICON_SPEC };
1836
+ exports.__test = { formatBytes, matchesArchivedSession, filterByTag, sortArchivedSessions, setVisibleSelection, reconcileSelection, markArchiveDialog, submitExport, submitImportFile, editIconSpec: EDIT_ICON_SPEC };
1769
1837
  exports.apply = apply;
1770
1838
  exports.inject = inject;
1771
1839
  return module.exports;
package/lib/index.js CHANGED
@@ -89,7 +89,7 @@ const PERSISTENCE_KEYS = ['sessionPersistence'];
89
89
  const ROUTE_PREFIX = '/plugins/dsh-archived-chats';
90
90
  /** Custom header required on POSTs: cheap CSRF hardening for a loopback UI. */
91
91
  const GUARD_HEADER = 'x-dsh-archived-chats';
92
- const PLUGIN_VERSION = '0.8.0';
92
+ const PLUGIN_VERSION = '0.9.0';
93
93
 
94
94
  //#region wire helpers
95
95
  /** Read and JSON-parse a request body (empty body → {}). */
@@ -109,8 +109,8 @@ function readBody(req) {
109
109
 
110
110
  const IMPORT_UPLOAD_LIMIT = IMPORT_LIMITS.maxCompressedBytes;
111
111
 
112
- /** Read one bounded multipart ZIP part. The body is never accepted as a path. */
113
- function readImportUpload(req) {
112
+ /** Read bounded multipart fields. Values stay in memory and are never paths. */
113
+ function readMultipartFields(req, limit, tooLargeMessage) {
114
114
  return new Promise((resolve, reject) => {
115
115
  const chunks = [];
116
116
  let bytes = 0;
@@ -128,29 +128,50 @@ function readImportUpload(req) {
128
128
  if (settled) return;
129
129
  const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
130
130
  bytes += value.length;
131
- if (bytes > IMPORT_UPLOAD_LIMIT) { fail('import upload is too large', 413); return; }
131
+ if (bytes > limit) { fail(tooLargeMessage, 413); return; }
132
132
  chunks.push(value);
133
133
  });
134
134
  req.on('end', () => {
135
135
  if (settled) return;
136
136
  try {
137
137
  const body = Buffer.concat(chunks);
138
- const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'));
139
- const firstBoundary = body.indexOf(boundary);
140
- if (firstBoundary !== 0 || headerEnd < 0) throw new Error('ZIP file field is missing');
141
- const headers = body.subarray(boundary.length + 2, headerEnd).toString('utf8').toLowerCase();
142
- if (!headers.includes('content-disposition:') || !headers.includes('name="file"')) throw new Error('ZIP file field is missing');
143
- const closing = Buffer.from(`\r\n${boundary.toString()}--`);
144
- const end = body.indexOf(closing, headerEnd + 4);
145
- if (end < 0) throw new Error('multipart body is incomplete');
138
+ if (!body.subarray(0, boundary.length).equals(boundary)) throw new Error('multipart body is incomplete');
139
+ const fields = new Map();
140
+ let cursor = 0;
141
+ while (cursor < body.length) {
142
+ if (!body.subarray(cursor, cursor + boundary.length).equals(boundary)) throw new Error('multipart body is incomplete');
143
+ cursor += boundary.length;
144
+ if (body.subarray(cursor, cursor + 2).toString('ascii') === '--') break;
145
+ if (body.subarray(cursor, cursor + 2).toString('ascii') !== '\r\n') throw new Error('multipart body is incomplete');
146
+ cursor += 2;
147
+ const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), cursor);
148
+ if (headerEnd < 0) throw new Error('multipart body is incomplete');
149
+ const headers = body.subarray(cursor, headerEnd).toString('utf8').toLowerCase();
150
+ const nameMatch = headers.match(/(?:^|\r\n)content-disposition:[^\r\n]*\bname="([^"]+)"/i);
151
+ if (!nameMatch) throw new Error('multipart field name is missing');
152
+ const contentStart = headerEnd + 4;
153
+ const next = body.indexOf(Buffer.from(`\r\n${boundary.toString()}`), contentStart);
154
+ if (next < 0) throw new Error('multipart body is incomplete');
155
+ if (!fields.has(nameMatch[1])) fields.set(nameMatch[1], body.subarray(contentStart, next));
156
+ cursor = next + 2;
157
+ }
146
158
  settled = true;
147
- resolve(new Uint8Array(body.subarray(headerEnd + 4, end)));
159
+ resolve(fields);
148
160
  } catch (error) { fail(String(error?.message ?? error)); }
149
161
  });
150
162
  req.on('error', (error) => { if (!settled) { settled = true; reject(error); } });
151
163
  });
152
164
  }
153
165
 
166
+ /** Read one bounded multipart ZIP part using the shared multipart reader. */
167
+ function readImportUpload(req) {
168
+ return readMultipartFields(req, IMPORT_UPLOAD_LIMIT, 'import upload is too large').then((fields) => {
169
+ const bytes = fields.get('file');
170
+ if (bytes === undefined) throw Object.assign(new Error('ZIP file field is missing'), { status: 400 });
171
+ return new Uint8Array(bytes);
172
+ });
173
+ }
174
+
154
175
  function createImportTokenStore({ now = () => Date.now(), ttlMs = 10 * 60 * 1000 } = {}) {
155
176
  const tokens = new Map();
156
177
  function cleanup() {
@@ -629,6 +650,7 @@ async function sweepPendingDeletions(ctx, registry, persistence, statsService, m
629
650
  }
630
651
  }
631
652
  }
653
+
632
654
  //#endregion
633
655
 
634
656
  //#region routes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-archived-chats",
3
- "version": "0.8.0",
4
- "description": "DeepSeek Harness 已归档会话管理页:搜索、标签、备注、存储统计、批量取消归档/删除、JSON/Markdown ZIP 备份,以及预览后导入恢复。Archived Chats management with search, tags, notes, storage insights, bulk actions, unarchive, delete, and preview-first JSON/Markdown ZIP import and restore.",
3
+ "version": "0.9.0",
4
+ "description": "DeepSeek Harness 已归档会话管理页:搜索、标签、批量操作与备份恢复。Archived Chats management with search, metadata, bulk actions, and backup restore.",
5
5
  "license": "MIT",
6
6
  "author": "Ultronen",
7
7
  "repository": {
@@ -17,6 +17,7 @@
17
17
  "dsh",
18
18
  "dsh-plugin",
19
19
  "archive",
20
+ "backup",
20
21
  "sessions",
21
22
  "web-ui"
22
23
  ],
package/README.zh.md DELETED
@@ -1,98 +0,0 @@
1
- # dsh-archived-chats
2
-
3
- > ⚡ **删除即生效,无需重启。** 即使会话仍驻留在后台,也会沿官方生命周期当场安全拆除并从磁盘彻底删除——点下删除的那一刻就删干净,而不是"停用后等下次重启"。
4
-
5
- 为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 新增一个「已归档的聊天」设置页,把被归档的会话重新找回来。
6
-
7
- 在 DeepSeek Harness 里,聊天一旦归档就会从侧边栏消失,界面中没有任何入口可以再看到它,只有工作区存档(`~/.dsh/storages/workspace.json`)还记得它。这个插件在「设置」中补上一个「已归档的聊天」页面,让所有归档会话都可见、可搜索、可管理。
8
-
9
- ## 安装
10
-
11
- ```sh
12
- dsh plugin --profile web add dsh-archived-chats
13
- ```
14
-
15
- 安装后重启一次 DSH,然后打开 **设置 → 已归档的聊天**。
16
-
17
- ## 兼容性
18
-
19
- 0.8.0 版本以 DeepSeek Harness `0.1.0-rc.7` 作为自动化兼容性基线。插件注册的是顶层 `settings.section`,因此 rc.7 针对 `settings.plugin.item` 的 keyed-slot 变更不影响本插件;同时已在 Harness `0.1.0-rc.8` 上完成真实宿主页面复核,覆盖归档列表、搜索、元数据编辑、批量操作、分组操作和导入预览。以后 Harness 发布新版本时,仍应在发布插件更新前重跑冒烟测试并检查真实宿主页面,因为客户端插槽和设计令牌契约仍可能演进。
20
-
21
- ## 截图
22
-
23
- 以下截图来自当前 `0.8.0` 构建,并在本地 DeepSeek Harness Web profile 中实际操作生成。
24
-
25
- ![已归档的聊天总览](assets/screenshots/1-archived-chats.png)
26
- ![搜索与筛选](assets/screenshots/2-search.png)
27
- ![删除确认](assets/screenshots/3-delete-confirm.png)
28
- ![分组操作](assets/screenshots/4-group-menu.png)
29
- ![标签与备注编辑](assets/screenshots/5-metadata-editor.png)
30
- ![批量操作](assets/screenshots/6-bulk-actions.png)
31
- ![导入预览](assets/screenshots/7-import-preview.png)
32
-
33
- ## 功能
34
-
35
- - **完整归档列表**:按工作区(项目)分组并显示每组数量;每个分组都可折叠/展开,状态按浏览器记忆。
36
- - **搜索与排序**:按标题、项目名、标签和备注内容搜索,用类型(全部 / 普通会话 / 子代理会话)、项目和标签筛选,并按最新、最早或标题排序。
37
- - **标签与备注**:任意行打开编辑器即可添加最多 8 个标签(每个最多 24 个 Unicode 字符)和一条备注(最多 2,000 个 Unicode 字符)。每行渲染标签小徽章,超过 3 个折叠为 `+N`,标签筛选不区分大小写。
38
- - **存储统计**:概览条显示归档数量、已统计总大小与无法统计的会话数;每行显示各自占用。统计不会跟随符号链接,无法读取的会话目录显示为「无法统计」而非让请求失败。
39
- - **JSON + Markdown 备份**:可导出单条、当前选中项或全部归档会话。每个 ZIP 都包含带版本的清单、用于机器恢复的完整会话 JSON,以及方便阅读的 Markdown 对话稿。
40
- - **预览后导入与恢复**:选择 ZIP 备份后先检查全部会话,默认选中无冲突 ID 的项目,确认后作为已归档聊天恢复。已有 ID 会跳过,绝不会覆盖。
41
- - **灵活多选**:逐条选择、选择当前筛选结果或选择整个项目;选中后可一次导出、取消归档或永久删除,隐藏在其他筛选结果中的选择不会丢失。
42
- - **取消归档**单个聊天,或从分组的 `⋯` 菜单整组取消——恢复的聊天会立刻回到侧边栏。
43
- - **删除**单个聊天、某个项目分组或全部(**全部删除**),均有确认弹窗。删除是彻底的:会话日志从磁盘移除、从工作区记录中摘除、注册表内存索引同步清理,主侧边栏的条目也会立即消失。
44
- - 仍驻留后台的会话也**当场删除**:插件按官方生命周期的拆除顺序原地停用并注销会话(取消 → 静默 → 落盘 → 拆纤程 → 摘出注册表),持久层随之释放写入通道,同一次请求内即完成物理删除——无需重启。若当前 DSH 版本不提供所需内部接口,则自动回退为「永久停用 + 下次启动完成删除」,停用期间会话保持隐藏。
45
- - 适配浅色/深色主题,支持中文和英文界面。
46
-
47
- ## 标签、备注与统计
48
-
49
- 标签和备注**只保存在本机**的 `$DSH_HOME/plugin-data/archived-chats/metadata.json` 中——不会被上传、同步或发送到任何其他地方。取消归档会保留元数据;物理删除完成后会移除它,而延后或失败的删除会保留它。元数据与统计失败永远不阻塞:即使元数据存储无法读取或某个会话目录无法统计,列表、取消归档和删除仍然可用。
50
-
51
- ## 导出与备份
52
-
53
- 导出只会触发本地浏览器下载。单条和批量使用同一种 ZIP 格式:
54
-
55
- ```text
56
- manifest.json
57
- sessions/001-<安全标题>-<id>/session.json
58
- sessions/001-<安全标题>-<id>/transcript.md
59
- ```
60
-
61
- `session.json` 是权威备份记录:原样保存 Harness 持久层返回的完整元数据和事件,并附带归档标题、工作区、时间、来源、标签、备注和存储统计。`transcript.md` 是通过 Harness 官方消息投影生成的可读副本。ZIP 路径会净化并处理重名,批量导出逐个会话生成,不会同时把所有会话内容堆进内存。
62
-
63
- JSON 会保留附件引用,但**本版不复制附件二进制,也不包含子会话**。需要带完整附件的会话树时,请使用 Harness 官方的 Session log 导出。
64
-
65
- ## 导入与恢复
66
-
67
- 导入只接受本插件版本一的导出 ZIP。浏览器会先上传并进行有界校验,然后展示标题、项目、标签、备注、存储信息、ID 冲突、项目不存在警告和附件引用警告;预览不会渲染原始事件或 Markdown。已有会话 ID 会被禁用并跳过,找不到的项目会恢复为未分组。确认令牌 10 分钟后过期且只能使用一次。标签和备注通过现有本地元数据限制恢复,不会恢复附件二进制。宿主没有可用的 Harness 写入能力时返回 `restore-unsupported`,不会写入任何数据。
68
-
69
- ## 实现原理
70
-
71
- - **Host 半**(`lib/index.js`)在 DSH Web 服务器上注册 `/plugins/dsh-archived-chats/*` 路由:`GET /state`、`GET /stats`、`POST /export`、`POST /import/inspect`、`POST /import/restore`、`POST /metadata`、`POST /unarchive`、`POST /unarchive-all`、`POST /delete`、`POST /delete-all`。`/state` 拼接标签与备注,`/stats` 返回字节数/文件数,`/export` 从有界的原生表单请求流式返回 ZIP;导入路由对 multipart ZIP 做有界校验,使用短期一次性预览令牌,并通过能力探测的恢复适配器提交。取消归档走 workspace registry 自身的状态写入通道,所有已连接的客户端都会收到 `host/archived-sessions-changed` 推送。会改变状态的路由要求 `x-dsh-archived-chats: 1` 作为 CSRF 加固;只读导出不会修改插件或 Harness 状态。
72
- - **导出生成器**(`lib/export.js`):负责带版本的备份记录、安全文件名、Harness 对话投影和顺序 ZIP 条目。首个会话会在发送响应头前预检,批量过程中最多保留一个已检查会话的载荷。
73
- - **元数据存储**(`lib/metadata.js`):带版本号的原子 JSON 存储。写入通过队列串行化,并以临时文件重命名的方式替换原文件,因此并发保存不会互相交叠;无法读取或不支持的版本绝不被覆盖。
74
- - **存储统计**(`lib/stats.js`):以并发 4 测量会话目录,跳过符号链接,结果缓存 30 秒,无法统计的会话上报为「不可用」而不是让请求失败。删除会使对应缓存失效。
75
- - **活会话原地删除**:删除仍驻留后台的会话时,插件复刻 agent 工厂自身 disposer 的顺序——`cancel({ kind: 'disposed' })` → `whenIdle` → `flush` → `agent.scope.dispose()` → 依次 detach `agents` 与 `sessions` 两个 store 条目;session detach 发出 `session/disposed`,持久化协调器随之 retire(排空并释放)该会话的写入通道,之后冷删除路径在同一请求内完成。所涉 store 条目属于内部接口,每一步都做特性探测,探测失败即回退为停用+延后。
76
- - **待删队列**(回退路径与崩溃兜底):id 记入 `$DSH_HOME/plugin-data/archived-chats/pending-deletions.json`,会话保持归档与隐藏;下次启动时插件清扫该队列,通过常规删除路径完成物理删除。原地删除也用该队列包裹(删除前登记、文件移除后清除),中途崩溃由下次启动补完。已入队的会话不会出现在列表中;取消归档会撤销待删标记。
77
- - **标题缓存**:列表刷新时按 id 记忆已解析的标题,不再每次全量读日志;删除与取消归档会使对应缓存失效。
78
- - **浏览器半**(`lib/client.js`)注册 `settings.section` 插槽项(order 30),用 React 和 rc.7 的 DSH 浮层/状态设计令牌渲染页面。
79
-
80
- ## 开发
81
-
82
- ```sh
83
- npm test
84
- ```
85
-
86
- 测试套件(`test/*.test.mjs`)覆盖导出记录与真实 ZIP 解包、有界导入校验、恢复事务、元数据存储、统计服务以及宿主+浏览器冒烟测试,使用隔离的临时 DSH 主目录和模拟运行时,不会读取或修改真实会话。
87
-
88
- ## 卸载
89
-
90
- ```sh
91
- dsh plugin --profile web remove dsh-archived-chats
92
- ```
93
-
94
- 唯一残留是 `$DSH_HOME/plugin-data/archived-chats/` 下的待删队列 `pending-deletions.json` 和 `metadata.json` 两个小文件;卸载不会触发队列处理,也不会删除你的标签与备注。
95
-
96
- ## License
97
-
98
- MIT