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 +196 -0
- package/README.md +150 -52
- package/assets/screenshots/1-archived-chats.png +0 -0
- package/assets/screenshots/2-search.png +0 -0
- package/assets/screenshots/3-delete-confirm.png +0 -0
- package/assets/screenshots/4-group-menu.png +0 -0
- package/assets/screenshots/5-metadata-editor.png +0 -0
- package/assets/screenshots/6-bulk-actions.png +0 -0
- package/assets/screenshots/7-import-preview.png +0 -0
- package/lib/client.js +125 -57
- package/lib/index.js +35 -13
- package/package.json +3 -2
- package/README.zh.md +0 -98
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
|
+

|
|
34
|
+

|
|
35
|
+

|
|
36
|
+

|
|
37
|
+

|
|
38
|
+

|
|
39
|
+

|
|
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
|
-
|
|
3
|
+
[English](README.en.md) | 中文
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> ⚡ **删除即生效,无需重启。** 即使会话仍驻留在后台,也会沿官方生命周期当场安全拆除并从磁盘彻底删除——点下删除的那一刻就删干净,而不是"停用后等下次重启"。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 新增一个「已归档的聊天」设置页,把被归档的会话重新找回来。
|
|
8
8
|
|
|
9
|
-
|
|
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
|
|
22
|
+
dsh plugin --profile web update dsh-archived-chats
|
|
13
23
|
```
|
|
14
24
|
|
|
15
|
-
|
|
25
|
+
## 兼容性
|
|
16
26
|
|
|
17
|
-
|
|
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
|
-
|
|
29
|
+
## 预览
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
以下截图均来自 `0.9.0` 在本地 DeepSeek Harness `0.1.0-rc.8` Web profile 中的实际操作。
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+

|
|
34
|
+

|
|
35
|
+

|
|
36
|
+

|
|
37
|
+

|
|
38
|
+

|
|
39
|
+

|
|
24
40
|
|
|
25
|
-
|
|
26
|
-

|
|
27
|
-

|
|
28
|
-

|
|
29
|
-

|
|
30
|
-

|
|
31
|
-

|
|
41
|
+
## 使用流程
|
|
32
42
|
|
|
33
|
-
|
|
43
|
+
1. 在 DSH 正常聊天的会话菜单中点击归档。归档只会把会话从侧边栏隐藏,工作区存档仍会保留会话数据。
|
|
44
|
+
2. 打开 **设置 → 已归档的聊天**。页面按工作区分组,并在当前浏览器中记住分组的折叠状态。
|
|
45
|
+
3. 搜索、筛选或排序会话;需要多选时点击 **批量选择** 显示复选框,完成后会自动恢复简洁列表。也可打开某一行的元数据编辑器添加标签与备注,或使用分组菜单执行项目级操作。
|
|
46
|
+
4. 点击顶部 **导入备份** 选择本插件导出的 ZIP,预览后确认无冲突会话;点击 **导出备份** 导出当前选中项,未选择时导出全部归档会话。单条会话也可以从行内操作导出。
|
|
47
|
+
5. 点击 **取消归档** 将会话放回侧边栏;只有确实需要永久删除时才点击 **删除**,确认弹窗会明确显示受影响的范围。**全部删除** 收纳在顶部 **更多** 菜单中。
|
|
34
48
|
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
+
## 标签、备注与统计
|
|
50
65
|
|
|
51
|
-
|
|
66
|
+
标签和备注**只保存在本机**的 `$DSH_HOME/plugin-data/archived-chats/metadata.json` 中——不会被上传、同步或发送到任何其他地方。取消归档会保留元数据;物理删除完成后会移除它,而延后或失败的删除会保留它。元数据与统计失败永远不阻塞:即使元数据存储无法读取或某个会话目录无法统计,列表、取消归档和删除仍然可用。
|
|
52
67
|
|
|
53
|
-
|
|
68
|
+
## 导出与备份
|
|
69
|
+
|
|
70
|
+
导出只会触发本地浏览器下载。单条和批量使用同一种 ZIP 格式:
|
|
54
71
|
|
|
55
72
|
```text
|
|
56
73
|
manifest.json
|
|
57
|
-
sessions/001
|
|
58
|
-
sessions/001
|
|
74
|
+
sessions/001-<安全标题>-<id>/session.json
|
|
75
|
+
sessions/001-<安全标题>-<id>/transcript.md
|
|
59
76
|
```
|
|
60
77
|
|
|
61
|
-
`session.json`
|
|
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
|
-
|
|
112
|
+
在提供所需生命周期接口的宿主上,删除会在同一次请求中拆除运行中的会话并移除文件。较旧或不兼容的宿主会使用安全的待删队列,在下次启动时完成物理删除。
|
|
64
113
|
|
|
65
|
-
|
|
114
|
+
</details>
|
|
66
115
|
|
|
67
|
-
|
|
116
|
+
## 实现概览
|
|
68
117
|
|
|
69
|
-
|
|
118
|
+
插件由两部分组成:Host 服务层负责读取和修改本地归档数据,浏览器设置页负责搜索、筛选、备份和恢复。所有修改都通过受保护的本地路由完成;导入会先预览,删除会优先尝试安全的生命周期拆除,能力不足时回退到下次启动处理。
|
|
70
119
|
|
|
71
|
-
|
|
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
|
-
##
|
|
122
|
+
## 开发
|
|
81
123
|
|
|
82
124
|
```sh
|
|
83
125
|
npm test
|
|
84
126
|
```
|
|
85
127
|
|
|
86
|
-
|
|
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
|
-
##
|
|
186
|
+
## 卸载
|
|
89
187
|
|
|
90
188
|
```sh
|
|
91
189
|
dsh plugin --profile web remove dsh-archived-chats
|
|
92
190
|
```
|
|
93
191
|
|
|
94
|
-
|
|
192
|
+
唯一残留是 `$DSH_HOME/plugin-data/archived-chats/` 下的待删队列 `pending-deletions.json` 和 `metadata.json` 两个小文件;卸载不会触发队列处理,也不会删除你的标签与备注。
|
|
95
193
|
|
|
96
194
|
## License
|
|
97
195
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
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
|
|
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
|
|
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:
|
|
363
|
-
.dac-
|
|
364
|
-
.dac-
|
|
365
|
-
.dac-
|
|
366
|
-
.dac-
|
|
367
|
-
.dac-
|
|
368
|
-
.dac-
|
|
369
|
-
.dac-
|
|
370
|
-
.dac-
|
|
371
|
-
.dac-
|
|
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-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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-
|
|
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
|
|
1512
|
-
})
|
|
1513
|
-
|
|
1514
|
-
|
|
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-
|
|
1523
|
-
disabled: allIds.length === 0,
|
|
1524
|
-
onClick: () =>
|
|
1525
|
-
children: [(0, jsx.jsx)(
|
|
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)("
|
|
1607
|
-
className: "dac-selection-
|
|
1662
|
+
(visibleIds.length > 0 || selectionMode) && (0, jsx.jsxs)("div", {
|
|
1663
|
+
className: "dac-selection-controls",
|
|
1608
1664
|
children: [
|
|
1609
|
-
(0, jsx.
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
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)("
|
|
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
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
113
|
-
function
|
|
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 >
|
|
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
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
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.
|
|
4
|
-
"description": "DeepSeek Harness
|
|
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
|
-

|
|
26
|
-

|
|
27
|
-

|
|
28
|
-

|
|
29
|
-

|
|
30
|
-

|
|
31
|
-

|
|
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
|