dsh-archived-chats 0.6.0 → 0.7.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.md +20 -4
- package/README.zh.md +20 -4
- package/lib/client.js +100 -30
- package/lib/export.js +349 -0
- package/lib/index.js +155 -0
- package/lib/types/client/index.d.ts +2 -2
- package/lib/types/index.d.ts +5 -5
- package/package.json +12 -4
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ Restart DSH once after installing, then open **Settings → Archived Chats**.
|
|
|
16
16
|
|
|
17
17
|
## Compatibility
|
|
18
18
|
|
|
19
|
-
Version 0.
|
|
19
|
+
Version 0.7.0 is tested against DeepSeek Harness `0.1.0-rc.7`. The plugin registers a top-level `settings.section`, so the rc.7 keyed-slot change for `settings.plugin.item` does not apply to it. 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.
|
|
20
20
|
|
|
21
21
|
## Features
|
|
22
22
|
|
|
@@ -24,7 +24,8 @@ Version 0.6.0 is tested against DeepSeek Harness `0.1.0-rc.7`. The plugin regist
|
|
|
24
24
|
- **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.
|
|
25
25
|
- **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.
|
|
26
26
|
- **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.
|
|
27
|
-
- **
|
|
27
|
+
- **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.
|
|
28
|
+
- **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.
|
|
28
29
|
- **Unarchive** a single chat or a whole project group from the group's `⋯` menu — restored chats reappear in the sidebar immediately.
|
|
29
30
|
- **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.
|
|
30
31
|
- 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.
|
|
@@ -34,9 +35,24 @@ Version 0.6.0 is tested against DeepSeek Harness `0.1.0-rc.7`. The plugin regist
|
|
|
34
35
|
|
|
35
36
|
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.
|
|
36
37
|
|
|
38
|
+
## Export and backup
|
|
39
|
+
|
|
40
|
+
Every export is a local browser download. A single session and a batch use the same ZIP format:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
manifest.json
|
|
44
|
+
sessions/001-<safe-title>-<id>/session.json
|
|
45
|
+
sessions/001-<safe-title>-<id>/transcript.md
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`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.
|
|
49
|
+
|
|
50
|
+
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. Version 0.7.0 exports only; validated import/restore and conflict handling are planned for 0.8.0.
|
|
51
|
+
|
|
37
52
|
## How it works
|
|
38
53
|
|
|
39
|
-
- **Host half** (`lib/index.js`) registers the `/plugins/dsh-archived-chats/*` routes on the DSH web server: `GET /state`, `GET /stats`, `POST /metadata`, `POST /unarchive`, `POST /unarchive-all`, `POST /delete`, `POST /delete-all`. `/state` joins tags, notes, and `metadataUpdatedAt` onto every row
|
|
54
|
+
- **Host half** (`lib/index.js`) registers the `/plugins/dsh-archived-chats/*` routes on the DSH web server: `GET /state`, `GET /stats`, `POST /export`, `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. 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.
|
|
55
|
+
- **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.
|
|
40
56
|
- **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.
|
|
41
57
|
- **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.
|
|
42
58
|
- **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.
|
|
@@ -50,7 +66,7 @@ Tags and notes live **only on your machine** in `$DSH_HOME/plugin-data/archived-
|
|
|
50
66
|
npm test
|
|
51
67
|
```
|
|
52
68
|
|
|
53
|
-
The suite (`test/*.test.mjs`) covers the metadata store, the statistics service, and
|
|
69
|
+
The suite (`test/*.test.mjs`) covers export records and real ZIP decoding, 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.
|
|
54
70
|
|
|
55
71
|
## Uninstall
|
|
56
72
|
|
package/README.zh.md
CHANGED
|
@@ -16,7 +16,7 @@ dsh plugin --profile web add dsh-archived-chats
|
|
|
16
16
|
|
|
17
17
|
## 兼容性
|
|
18
18
|
|
|
19
|
-
0.
|
|
19
|
+
0.7.0 版本以 DeepSeek Harness `0.1.0-rc.7` 为验证基线。插件注册的是顶层 `settings.section`,因此 rc.7 针对 `settings.plugin.item` 的 keyed-slot 变更不影响本插件。以后 Harness 发布新版本时,仍应在发布插件更新前重跑冒烟测试并检查真实宿主页面,因为客户端插槽和设计令牌契约仍可能演进。
|
|
20
20
|
|
|
21
21
|
## 功能
|
|
22
22
|
|
|
@@ -24,7 +24,8 @@ dsh plugin --profile web add dsh-archived-chats
|
|
|
24
24
|
- **搜索与排序**:按标题、项目名、标签和备注内容搜索,用类型(全部 / 普通会话 / 子代理会话)、项目和标签筛选,并按最新、最早或标题排序。
|
|
25
25
|
- **标签与备注**:任意行打开编辑器即可添加最多 8 个标签(每个最多 24 个 Unicode 字符)和一条备注(最多 2,000 个 Unicode 字符)。每行渲染标签小徽章,超过 3 个折叠为 `+N`,标签筛选不区分大小写。
|
|
26
26
|
- **存储统计**:概览条显示归档数量、已统计总大小与无法统计的会话数;每行显示各自占用。统计不会跟随符号链接,无法读取的会话目录显示为「无法统计」而非让请求失败。
|
|
27
|
-
-
|
|
27
|
+
- **JSON + Markdown 备份**:可导出单条、当前选中项或全部归档会话。每个 ZIP 都包含带版本的清单、用于机器恢复的完整会话 JSON,以及方便阅读的 Markdown 对话稿。
|
|
28
|
+
- **灵活多选**:逐条选择、选择当前筛选结果或选择整个项目;选中后可一次导出、取消归档或永久删除,隐藏在其他筛选结果中的选择不会丢失。
|
|
28
29
|
- **取消归档**单个聊天,或从分组的 `⋯` 菜单整组取消——恢复的聊天会立刻回到侧边栏。
|
|
29
30
|
- **删除**单个聊天、某个项目分组或全部(**全部删除**),均有确认弹窗。删除是彻底的:会话日志从磁盘移除、从工作区记录中摘除、注册表内存索引同步清理,主侧边栏的条目也会立即消失。
|
|
30
31
|
- 仍驻留后台的会话也**当场删除**:插件按官方生命周期的拆除顺序原地停用并注销会话(取消 → 静默 → 落盘 → 拆纤程 → 摘出注册表),持久层随之释放写入通道,同一次请求内即完成物理删除——无需重启。若当前 DSH 版本不提供所需内部接口,则自动回退为「永久停用 + 下次启动完成删除」,停用期间会话保持隐藏。
|
|
@@ -34,9 +35,24 @@ dsh plugin --profile web add dsh-archived-chats
|
|
|
34
35
|
|
|
35
36
|
标签和备注**只保存在本机**的 `$DSH_HOME/plugin-data/archived-chats/metadata.json` 中——不会被上传、同步或发送到任何其他地方。取消归档会保留元数据;物理删除完成后会移除它,而延后或失败的删除会保留它。元数据与统计失败永远不阻塞:即使元数据存储无法读取或某个会话目录无法统计,列表、取消归档和删除仍然可用。
|
|
36
37
|
|
|
38
|
+
## 导出与备份
|
|
39
|
+
|
|
40
|
+
导出只会触发本地浏览器下载。单条和批量使用同一种 ZIP 格式:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
manifest.json
|
|
44
|
+
sessions/001-<安全标题>-<id>/session.json
|
|
45
|
+
sessions/001-<安全标题>-<id>/transcript.md
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`session.json` 是权威备份记录:原样保存 Harness 持久层返回的完整元数据和事件,并附带归档标题、工作区、时间、来源、标签、备注和存储统计。`transcript.md` 是通过 Harness 官方消息投影生成的可读副本。ZIP 路径会净化并处理重名,批量导出逐个会话生成,不会同时把所有会话内容堆进内存。
|
|
49
|
+
|
|
50
|
+
JSON 会保留附件引用,但**本版不复制附件二进制,也不包含子会话**。需要带完整附件的会话树时,请使用 Harness 官方的 Session log 导出。0.7.0 只负责导出;经过校验的导入/恢复与冲突策略留到 0.8.0。
|
|
51
|
+
|
|
37
52
|
## 实现原理
|
|
38
53
|
|
|
39
|
-
- **Host 半**(`lib/index.js`)在 DSH Web 服务器上注册 `/plugins/dsh-archived-chats/*` 路由:`GET /state`、`GET /stats`、`POST /metadata`、`POST /unarchive`、`POST /unarchive-all`、`POST /delete`、`POST /delete-all`。`/state`
|
|
54
|
+
- **Host 半**(`lib/index.js`)在 DSH Web 服务器上注册 `/plugins/dsh-archived-chats/*` 路由:`GET /state`、`GET /stats`、`POST /export`、`POST /metadata`、`POST /unarchive`、`POST /unarchive-all`、`POST /delete`、`POST /delete-all`。`/state` 拼接标签与备注,`/stats` 返回字节数/文件数,`/export` 从有界的原生表单请求流式返回 ZIP。取消归档走 workspace registry 自身的状态写入通道,所有已连接的客户端都会收到 `host/archived-sessions-changed` 推送。会改变状态的路由要求 `x-dsh-archived-chats: 1` 作为 CSRF 加固;只读导出不会修改插件或 Harness 状态。
|
|
55
|
+
- **导出生成器**(`lib/export.js`):负责带版本的备份记录、安全文件名、Harness 对话投影和顺序 ZIP 条目。首个会话会在发送响应头前预检,批量过程中最多保留一个已检查会话的载荷。
|
|
40
56
|
- **元数据存储**(`lib/metadata.js`):带版本号的原子 JSON 存储。写入通过队列串行化,并以临时文件重命名的方式替换原文件,因此并发保存不会互相交叠;无法读取或不支持的版本绝不被覆盖。
|
|
41
57
|
- **存储统计**(`lib/stats.js`):以并发 4 测量会话目录,跳过符号链接,结果缓存 30 秒,无法统计的会话上报为「不可用」而不是让请求失败。删除会使对应缓存失效。
|
|
42
58
|
- **活会话原地删除**:删除仍驻留后台的会话时,插件复刻 agent 工厂自身 disposer 的顺序——`cancel({ kind: 'disposed' })` → `whenIdle` → `flush` → `agent.scope.dispose()` → 依次 detach `agents` 与 `sessions` 两个 store 条目;session detach 发出 `session/disposed`,持久化协调器随之 retire(排空并释放)该会话的写入通道,之后冷删除路径在同一请求内完成。所涉 store 条目属于内部接口,每一步都做特性探测,探测失败即回退为停用+延后。
|
|
@@ -50,7 +66,7 @@ dsh plugin --profile web add dsh-archived-chats
|
|
|
50
66
|
npm test
|
|
51
67
|
```
|
|
52
68
|
|
|
53
|
-
测试套件(`test/*.test.mjs
|
|
69
|
+
测试套件(`test/*.test.mjs`)覆盖导出记录与真实 ZIP 解包、元数据存储、统计服务以及宿主+浏览器冒烟测试,使用隔离的临时 DSH 主目录和模拟运行时,不会读取或修改真实会话。
|
|
54
70
|
|
|
55
71
|
## 卸载
|
|
56
72
|
|
package/lib/client.js
CHANGED
|
@@ -47,8 +47,12 @@ window.__ModuleLoader__.load({
|
|
|
47
47
|
const zh = {
|
|
48
48
|
"locale.intl": "zh-CN",
|
|
49
49
|
"nav": "已归档的聊天",
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
"page.title": "已归档的聊天",
|
|
51
|
+
"delete.all": "全部删除",
|
|
52
|
+
"export.all": "全部导出",
|
|
53
|
+
"export.selected": "导出选中项",
|
|
54
|
+
"export.row": "导出备份",
|
|
55
|
+
"export.started": "已开始下载备份",
|
|
52
56
|
"search.placeholder": "搜索已归档聊天",
|
|
53
57
|
"filter.allChats": "全部聊天",
|
|
54
58
|
"filter.normal": "普通会话",
|
|
@@ -99,8 +103,12 @@ window.__ModuleLoader__.load({
|
|
|
99
103
|
const en = {
|
|
100
104
|
"locale.intl": "en-US",
|
|
101
105
|
"nav": "Archived Chats",
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
"page.title": "Archived Chats",
|
|
107
|
+
"delete.all": "Delete All",
|
|
108
|
+
"export.all": "Export all",
|
|
109
|
+
"export.selected": "Export selected",
|
|
110
|
+
"export.row": "Export backup",
|
|
111
|
+
"export.started": "Backup download started",
|
|
104
112
|
"search.placeholder": "Search archived chats",
|
|
105
113
|
"filter.allChats": "All chats",
|
|
106
114
|
"filter.normal": "Regular chats",
|
|
@@ -319,6 +327,10 @@ window.__ModuleLoader__.load({
|
|
|
319
327
|
.dac-page{position:relative;display:flex;flex-direction:column;gap:14px;padding:4px 0 28px;font-family:inherit}
|
|
320
328
|
.dac-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
321
329
|
.dac-title{margin:0;color:var(--dsw-alias-label-primary);font-size:18px;font-weight:500;line-height:28px;outline:none}
|
|
330
|
+
.dac-head-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:wrap}
|
|
331
|
+
.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}
|
|
332
|
+
.dac-exportall:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-primary)}
|
|
333
|
+
.dac-exportall:disabled{opacity:.45;cursor:default}
|
|
322
334
|
.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}
|
|
323
335
|
.dac-deleteall:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
|
|
324
336
|
.dac-deleteall:disabled{opacity:.45;cursor:default}
|
|
@@ -421,7 +433,7 @@ window.__ModuleLoader__.load({
|
|
|
421
433
|
.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}
|
|
422
434
|
.dac-btn-primary:hover:not(:disabled){opacity:.9}
|
|
423
435
|
.dac-btn-primary:disabled{opacity:.5;cursor:default}
|
|
424
|
-
@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-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}}
|
|
436
|
+
@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}}
|
|
425
437
|
`;
|
|
426
438
|
|
|
427
439
|
function ensureStyle() {
|
|
@@ -519,9 +531,30 @@ window.__ModuleLoader__.load({
|
|
|
519
531
|
return res.json();
|
|
520
532
|
}
|
|
521
533
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
534
|
+
async function saveMetadata(sessionId, tags, note) {
|
|
535
|
+
return post("/metadata", { sessionId, tags, note });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function submitExport(sessionIds) {
|
|
539
|
+
if (typeof document === "undefined") return false;
|
|
540
|
+
const ids = [...new Set((Array.isArray(sessionIds) ? sessionIds : [])
|
|
541
|
+
.filter((id) => typeof id === "string" && id !== ""))];
|
|
542
|
+
if (ids.length === 0) return false;
|
|
543
|
+
const form = document.createElement("form");
|
|
544
|
+
form.method = "POST";
|
|
545
|
+
form.action = `${API_BASE}/export`;
|
|
546
|
+
form.enctype = "application/x-www-form-urlencoded";
|
|
547
|
+
form.hidden = true;
|
|
548
|
+
const input = document.createElement("input");
|
|
549
|
+
input.type = "hidden";
|
|
550
|
+
input.name = "sessionIds";
|
|
551
|
+
input.value = JSON.stringify(ids);
|
|
552
|
+
form.appendChild(input);
|
|
553
|
+
document.body.appendChild(form);
|
|
554
|
+
form.submit();
|
|
555
|
+
setTimeout(() => form.remove(), 0);
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
525
558
|
|
|
526
559
|
async function post(path, body) {
|
|
527
560
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
@@ -564,9 +597,12 @@ window.__ModuleLoader__.load({
|
|
|
564
597
|
function IconClose({ size = 12 }) {
|
|
565
598
|
return (0, jsx.jsx)("svg", { ...svgProps(size), children: (0, jsx.jsx)("path", { d: "M6 6l12 12M18 6 6 18" }) });
|
|
566
599
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
600
|
+
function IconCheckCircle({ size = 15 }) {
|
|
601
|
+
return (0, jsx.jsxs)("svg", { ...svgProps(size), children: [(0, jsx.jsx)("circle", { cx: 12, cy: 12, r: 9 }), (0, jsx.jsx)("path", { d: "m8.5 12.2 2.4 2.4 4.6-5" })] });
|
|
602
|
+
}
|
|
603
|
+
function IconDownload({ size = 15 }) {
|
|
604
|
+
return (0, jsx.jsxs)("svg", { ...svgProps(size), children: [(0, jsx.jsx)("path", { d: "M12 3v12" }), (0, jsx.jsx)("path", { d: "m7 10 5 5 5-5" }), (0, jsx.jsx)("path", { d: "M5 20h14" })] });
|
|
605
|
+
}
|
|
570
606
|
const EDIT_ICON_SPEC = {
|
|
571
607
|
size: 16,
|
|
572
608
|
viewBox: "0 0 1024 1024",
|
|
@@ -805,7 +841,7 @@ window.__ModuleLoader__.load({
|
|
|
805
841
|
});
|
|
806
842
|
}
|
|
807
843
|
|
|
808
|
-
|
|
844
|
+
function GroupSection({ group, t, collapsed, onToggleCollapsed, menuOpen, onToggleMenu, onUnarchive, onDelete, onExport, busy, selected, onToggleSelected, stats, metadataStatus, onEditMetadata }) {
|
|
809
845
|
const wrapRef = _react.useRef(null);
|
|
810
846
|
_react.useEffect(() => {
|
|
811
847
|
if (!menuOpen) return void 0;
|
|
@@ -926,6 +962,14 @@ window.__ModuleLoader__.load({
|
|
|
926
962
|
onClick: () => onEditMetadata(session),
|
|
927
963
|
children: (0, jsx.jsx)(IconEdit, {})
|
|
928
964
|
}),
|
|
965
|
+
(0, jsx.jsx)("button", {
|
|
966
|
+
type: "button",
|
|
967
|
+
className: "dac-iconbtn",
|
|
968
|
+
"aria-label": t("export.row"),
|
|
969
|
+
disabled: busy[session.id] === true,
|
|
970
|
+
onClick: () => onExport([session.id]),
|
|
971
|
+
children: (0, jsx.jsx)(IconDownload, {})
|
|
972
|
+
}),
|
|
929
973
|
(0, jsx.jsx)("button", {
|
|
930
974
|
type: "button",
|
|
931
975
|
className: "dac-iconbtn dac-danger",
|
|
@@ -1139,7 +1183,7 @@ window.__ModuleLoader__.load({
|
|
|
1139
1183
|
setMetadataEdit(session);
|
|
1140
1184
|
};
|
|
1141
1185
|
|
|
1142
|
-
|
|
1186
|
+
const saveMetadataFor = async (sessionId, tags, note) => {
|
|
1143
1187
|
setNotice(null);
|
|
1144
1188
|
setMetaBusy(true);
|
|
1145
1189
|
try {
|
|
@@ -1158,7 +1202,12 @@ window.__ModuleLoader__.load({
|
|
|
1158
1202
|
} finally {
|
|
1159
1203
|
setMetaBusy(false);
|
|
1160
1204
|
}
|
|
1161
|
-
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
const exportSessions = (ids) => {
|
|
1208
|
+
if (!submitExport(ids)) return;
|
|
1209
|
+
setNotice({ kind: "ok", text: t("export.started") });
|
|
1210
|
+
};
|
|
1162
1211
|
|
|
1163
1212
|
const projects = _react.useMemo(() => {
|
|
1164
1213
|
const seen = new Map();
|
|
@@ -1238,8 +1287,9 @@ window.__ModuleLoader__.load({
|
|
|
1238
1287
|
const selectedVisibleCount = visibleIds.filter((id) => selected.has(id)).length;
|
|
1239
1288
|
const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length;
|
|
1240
1289
|
const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;
|
|
1241
|
-
|
|
1242
|
-
|
|
1290
|
+
const selectedBusy = selectedIds.some((id) => busy[id] === true);
|
|
1291
|
+
const visibleBusy = visibleIds.some((id) => busy[id] === true);
|
|
1292
|
+
const allBusy = allIds.some((id) => busy[id] === true);
|
|
1243
1293
|
const filtering = query.trim() !== "" || projectFilter !== "all" || typeFilter !== "all" || tagFilter !== "";
|
|
1244
1294
|
|
|
1245
1295
|
return (0, jsx.jsxs)("div", {
|
|
@@ -1250,19 +1300,31 @@ window.__ModuleLoader__.load({
|
|
|
1250
1300
|
className: "dac-head",
|
|
1251
1301
|
children: [
|
|
1252
1302
|
(0, jsx.jsx)("h2", { ref: pageHeadingRef, tabIndex: -1, className: "dac-title", children: t("page.title") }),
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1303
|
+
(0, jsx.jsxs)("div", {
|
|
1304
|
+
className: "dac-head-actions",
|
|
1305
|
+
children: [
|
|
1306
|
+
selectedIds.length === 0 && (0, jsx.jsxs)("button", {
|
|
1307
|
+
type: "button",
|
|
1308
|
+
className: "dac-exportall",
|
|
1309
|
+
disabled: allIds.length === 0 || allBusy,
|
|
1310
|
+
onClick: () => exportSessions(allIds),
|
|
1311
|
+
children: [(0, jsx.jsx)(IconDownload, {}), (0, jsx.jsx)("span", { children: t("export.all") })]
|
|
1312
|
+
}),
|
|
1313
|
+
(0, jsx.jsxs)("button", {
|
|
1314
|
+
type: "button",
|
|
1315
|
+
className: "dac-deleteall",
|
|
1316
|
+
disabled: allIds.length === 0,
|
|
1317
|
+
onClick: () => askDelete(allIds, void 0),
|
|
1318
|
+
children: [(0, jsx.jsx)(IconTrash, {}), (0, jsx.jsx)("span", { children: t("delete.all") })]
|
|
1319
|
+
})
|
|
1320
|
+
]
|
|
1321
|
+
})
|
|
1260
1322
|
]
|
|
1261
1323
|
}),
|
|
1262
1324
|
(0, jsx.jsxs)("div", {
|
|
1263
1325
|
className: "dac-summary",
|
|
1264
|
-
|
|
1265
|
-
|
|
1326
|
+
role: "status",
|
|
1327
|
+
children: [
|
|
1266
1328
|
(0, jsx.jsx)("span", { children: chatsCount(t, (sessions ?? []).length) }),
|
|
1267
1329
|
(0, jsx.jsx)("span", { className: "dac-summary-sep", "aria-hidden": "true", children: "·" }),
|
|
1268
1330
|
(0, jsx.jsx)("span", { children: summarySizeText(t, stats) }),
|
|
@@ -1349,8 +1411,15 @@ window.__ModuleLoader__.load({
|
|
|
1349
1411
|
children: [
|
|
1350
1412
|
(0, jsx.jsx)("span", { className: "dac-bulk-count", children: selectedCount(t, selectedIds.length) }),
|
|
1351
1413
|
(0, jsx.jsxs)("div", {
|
|
1352
|
-
|
|
1353
|
-
|
|
1414
|
+
className: "dac-bulk-actions",
|
|
1415
|
+
children: [
|
|
1416
|
+
(0, jsx.jsx)("button", {
|
|
1417
|
+
type: "button",
|
|
1418
|
+
className: "dac-bulk-btn",
|
|
1419
|
+
disabled: selectedBusy,
|
|
1420
|
+
onClick: () => exportSessions(selectedIds),
|
|
1421
|
+
children: t("export.selected")
|
|
1422
|
+
}),
|
|
1354
1423
|
(0, jsx.jsx)("button", {
|
|
1355
1424
|
type: "button",
|
|
1356
1425
|
className: "dac-bulk-btn",
|
|
@@ -1407,8 +1476,9 @@ window.__ModuleLoader__.load({
|
|
|
1407
1476
|
onToggleCollapsed: toggleCollapsed,
|
|
1408
1477
|
menuOpen: menuFor === group.key,
|
|
1409
1478
|
onToggleMenu: setMenuFor,
|
|
1410
|
-
|
|
1411
|
-
|
|
1479
|
+
onUnarchive: unarchive,
|
|
1480
|
+
onDelete: askDelete,
|
|
1481
|
+
onExport: exportSessions,
|
|
1412
1482
|
busy,
|
|
1413
1483
|
selected,
|
|
1414
1484
|
onToggleSelected: toggleSelected,
|
|
@@ -1471,7 +1541,7 @@ window.__ModuleLoader__.load({
|
|
|
1471
1541
|
//#endregion
|
|
1472
1542
|
|
|
1473
1543
|
exports.SETTINGS_NS = SETTINGS_NS;
|
|
1474
|
-
exports.__test = { formatBytes, matchesArchivedSession, filterByTag, sortArchivedSessions, setVisibleSelection, reconcileSelection, markArchiveDialog, editIconSpec: EDIT_ICON_SPEC };
|
|
1544
|
+
exports.__test = { formatBytes, matchesArchivedSession, filterByTag, sortArchivedSessions, setVisibleSelection, reconcileSelection, markArchiveDialog, submitExport, editIconSpec: EDIT_ICON_SPEC };
|
|
1475
1545
|
exports.apply = apply;
|
|
1476
1546
|
exports.inject = inject;
|
|
1477
1547
|
return module.exports;
|
package/lib/export.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deriveEventMessage,
|
|
3
|
+
isAppendSurfaceEvent,
|
|
4
|
+
} from '@deepseek-ai/dsh-session';
|
|
5
|
+
import ZipStream from 'zip-stream';
|
|
6
|
+
|
|
7
|
+
const EXPORT_FORMAT = 'dsh-archived-chats/export';
|
|
8
|
+
const SESSION_FORMAT = 'dsh-archived-chats/session';
|
|
9
|
+
const TRANSCRIPT_FORMAT = 'dsh-archived-chats/transcript';
|
|
10
|
+
const FORMAT_VERSION = 1;
|
|
11
|
+
const RESERVED_BASENAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
12
|
+
|
|
13
|
+
function codePoints(value) {
|
|
14
|
+
return [...value];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Normalize untrusted text into one cross-platform archive path segment. */
|
|
18
|
+
export function safeSegment(value, fallback = 'untitled', maxLength = 80) {
|
|
19
|
+
const limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength : 80;
|
|
20
|
+
let segment = typeof value === 'string' ? value.normalize('NFKC') : '';
|
|
21
|
+
segment = segment
|
|
22
|
+
.replace(/[\u0000-\u001f\u007f<>:"/\\|?*\s]+/g, '-')
|
|
23
|
+
.replace(/\.{2,}/g, '-')
|
|
24
|
+
.replace(/-+/g, '-')
|
|
25
|
+
.replace(/^[. -]+|[. -]+$/g, '');
|
|
26
|
+
|
|
27
|
+
if (segment === '') segment = String(fallback).normalize('NFKC');
|
|
28
|
+
if (RESERVED_BASENAME.test(segment)) segment = `${segment}-file`;
|
|
29
|
+
|
|
30
|
+
segment = codePoints(segment).slice(0, limit).join('')
|
|
31
|
+
.replace(/^[. -]+|[. -]+$/g, '');
|
|
32
|
+
if (segment === '') return 'untitled';
|
|
33
|
+
if (RESERVED_BASENAME.test(segment)) return `${segment}-file`;
|
|
34
|
+
return segment;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nullableString(value) {
|
|
38
|
+
return typeof value === 'string' ? value : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function nullableNumber(value) {
|
|
42
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeStorage(storage) {
|
|
46
|
+
if (storage?.status === 'ready'
|
|
47
|
+
&& Number.isFinite(storage.sizeBytes)
|
|
48
|
+
&& Number.isFinite(storage.fileCount)) {
|
|
49
|
+
return {
|
|
50
|
+
status: 'ready',
|
|
51
|
+
sizeBytes: storage.sizeBytes,
|
|
52
|
+
fileCount: storage.fileCount,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { status: 'unavailable', sizeBytes: null, fileCount: null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeDescriptor(descriptor) {
|
|
59
|
+
return {
|
|
60
|
+
id: descriptor.id,
|
|
61
|
+
title: nullableString(descriptor.title),
|
|
62
|
+
workspace: {
|
|
63
|
+
id: nullableString(descriptor.workspaceId),
|
|
64
|
+
title: nullableString(descriptor.workspaceTitle),
|
|
65
|
+
},
|
|
66
|
+
createdAt: nullableNumber(descriptor.createdAt),
|
|
67
|
+
origin: nullableString(descriptor.origin),
|
|
68
|
+
metadataUpdatedAt: nullableString(descriptor.metadataUpdatedAt),
|
|
69
|
+
tags: Array.isArray(descriptor.tags)
|
|
70
|
+
? descriptor.tags.filter((tag) => typeof tag === 'string')
|
|
71
|
+
: [],
|
|
72
|
+
note: nullableString(descriptor.note),
|
|
73
|
+
storage: normalizeStorage(descriptor.storage),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function safeIdSuffix(id) {
|
|
78
|
+
const safe = safeSegment(id, 'session', 160);
|
|
79
|
+
return codePoints(safe).slice(-8).join('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function uniqueDirectory(base, used) {
|
|
83
|
+
let candidate = base;
|
|
84
|
+
let suffix = 2;
|
|
85
|
+
while (used.has(candidate.toLocaleLowerCase('en-US'))) {
|
|
86
|
+
candidate = `${base}-${suffix}`;
|
|
87
|
+
suffix += 1;
|
|
88
|
+
}
|
|
89
|
+
used.add(candidate.toLocaleLowerCase('en-US'));
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Build stable filenames and normalized descriptors for an export request. */
|
|
94
|
+
export function planExport(descriptors, exportedAt = new Date()) {
|
|
95
|
+
const instant = exportedAt instanceof Date ? exportedAt : new Date(exportedAt);
|
|
96
|
+
if (Number.isNaN(instant.getTime())) throw new TypeError('exportedAt must be a valid date');
|
|
97
|
+
|
|
98
|
+
const seenIds = new Set();
|
|
99
|
+
const usedDirectories = new Set();
|
|
100
|
+
const items = [];
|
|
101
|
+
for (const descriptor of Array.isArray(descriptors) ? descriptors : []) {
|
|
102
|
+
if (descriptor === null || typeof descriptor !== 'object' || typeof descriptor.id !== 'string') continue;
|
|
103
|
+
if (seenIds.has(descriptor.id)) continue;
|
|
104
|
+
seenIds.add(descriptor.id);
|
|
105
|
+
|
|
106
|
+
const archive = normalizeDescriptor(descriptor);
|
|
107
|
+
const index = String(items.length + 1).padStart(3, '0');
|
|
108
|
+
const title = safeSegment(archive.title, 'untitled', 80);
|
|
109
|
+
const leaf = uniqueDirectory(`${index}-${title}-${safeIdSuffix(archive.id)}`, usedDirectories);
|
|
110
|
+
const directory = `sessions/${leaf}`;
|
|
111
|
+
items.push({
|
|
112
|
+
...archive,
|
|
113
|
+
directory,
|
|
114
|
+
files: {
|
|
115
|
+
json: `${directory}/session.json`,
|
|
116
|
+
markdown: `${directory}/transcript.md`,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const date = instant.toISOString().slice(0, 10);
|
|
122
|
+
const filename = items.length === 1
|
|
123
|
+
? `dsh-archived-chat-${safeSegment(items[0].title, 'untitled', 60)}-${date}.zip`
|
|
124
|
+
: `dsh-archived-chats-${items.length}-${date}.zip`;
|
|
125
|
+
return { exportedAt: instant.toISOString(), filename, items };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function manifestSession(item) {
|
|
129
|
+
return {
|
|
130
|
+
id: item.id,
|
|
131
|
+
title: item.title,
|
|
132
|
+
workspace: item.workspace,
|
|
133
|
+
createdAt: item.createdAt,
|
|
134
|
+
origin: item.origin,
|
|
135
|
+
metadataUpdatedAt: item.metadataUpdatedAt,
|
|
136
|
+
tags: item.tags,
|
|
137
|
+
note: item.note,
|
|
138
|
+
storage: item.storage,
|
|
139
|
+
files: item.files,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Create the authoritative inventory for one ZIP package. */
|
|
144
|
+
export function createManifest(plan, generatorVersion) {
|
|
145
|
+
return {
|
|
146
|
+
format: EXPORT_FORMAT,
|
|
147
|
+
version: FORMAT_VERSION,
|
|
148
|
+
exportedAt: plan.exportedAt,
|
|
149
|
+
generator: { name: 'dsh-archived-chats', version: generatorVersion },
|
|
150
|
+
sessionCount: plan.items.length,
|
|
151
|
+
attachmentsIncluded: false,
|
|
152
|
+
sessions: plan.items.map(manifestSession),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Create one lossless session record around Harness persistence output. */
|
|
157
|
+
export function createSessionRecord(item, inspected, exportedAt) {
|
|
158
|
+
return {
|
|
159
|
+
format: SESSION_FORMAT,
|
|
160
|
+
version: FORMAT_VERSION,
|
|
161
|
+
exportedAt,
|
|
162
|
+
archive: manifestSession(item),
|
|
163
|
+
source: {
|
|
164
|
+
meta: inspected?.meta ?? null,
|
|
165
|
+
events: inspected?.events ?? [],
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function yamlValue(value) {
|
|
171
|
+
return value === null || value === undefined ? 'null' : JSON.stringify(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function jsonText(value) {
|
|
175
|
+
try {
|
|
176
|
+
const rendered = JSON.stringify(value, null, 2);
|
|
177
|
+
return rendered === undefined ? String(value) : rendered;
|
|
178
|
+
} catch {
|
|
179
|
+
return String(value);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function fenced(language, value) {
|
|
184
|
+
const text = String(value);
|
|
185
|
+
const runs = text.match(/`+/g) ?? [];
|
|
186
|
+
const width = Math.max(3, ...runs.map((run) => run.length + 1));
|
|
187
|
+
const fence = '`'.repeat(width);
|
|
188
|
+
return `${fence}${language}\n${text}\n${fence}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function renderImage(block) {
|
|
192
|
+
const attachment = block?.attachment ?? {};
|
|
193
|
+
const name = typeof attachment.name === 'string' && attachment.name !== ''
|
|
194
|
+
? attachment.name
|
|
195
|
+
: 'unnamed image';
|
|
196
|
+
const details = [
|
|
197
|
+
attachment.mediaType,
|
|
198
|
+
Number.isFinite(attachment.width) && Number.isFinite(attachment.height)
|
|
199
|
+
? `${attachment.width}x${attachment.height}`
|
|
200
|
+
: null,
|
|
201
|
+
Number.isFinite(attachment.bytes) ? `${attachment.bytes} bytes` : null,
|
|
202
|
+
attachment.attachmentId,
|
|
203
|
+
].filter((value) => value !== null && value !== undefined && value !== '');
|
|
204
|
+
return `[Image: ${name}${details.length > 0 ? ` - ${details.join(', ')}` : ''}]`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderContentBlock(block) {
|
|
208
|
+
if (block === null || typeof block !== 'object') return fenced('json', jsonText(block));
|
|
209
|
+
switch (block.type) {
|
|
210
|
+
case 'text':
|
|
211
|
+
return typeof block.text === 'string' ? block.text : fenced('json', jsonText(block));
|
|
212
|
+
case 'reasoning':
|
|
213
|
+
return `### Reasoning\n\n${fenced('text', typeof block.text === 'string' ? block.text : jsonText(block))}`;
|
|
214
|
+
case 'image':
|
|
215
|
+
return renderImage(block);
|
|
216
|
+
case 'tool-call': {
|
|
217
|
+
const name = typeof block.name === 'string' && block.name !== '' ? block.name : 'unknown';
|
|
218
|
+
const args = typeof block.arguments === 'string' ? block.arguments : jsonText(block.arguments);
|
|
219
|
+
return `### Tool call: ${name}\n\nCall ID: \`${String(block.id ?? 'unknown')}\`\n\n${fenced('json', args)}`;
|
|
220
|
+
}
|
|
221
|
+
case 'tool-result': {
|
|
222
|
+
const status = block.isError === true ? ' (error)' : '';
|
|
223
|
+
const nested = Array.isArray(block.content)
|
|
224
|
+
? block.content.map(renderContentBlock).filter(Boolean).join('\n\n')
|
|
225
|
+
: fenced('json', jsonText(block.content));
|
|
226
|
+
return `### Tool result \`${String(block.toolCallId ?? 'unknown')}\`${status}\n\n${nested}`;
|
|
227
|
+
}
|
|
228
|
+
default:
|
|
229
|
+
return fenced('json', jsonText(block));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function messageLabel(message) {
|
|
234
|
+
if (message?.source?.kind === 'tool') return 'Tool result';
|
|
235
|
+
if (message?.role === 'assistant') return 'Assistant';
|
|
236
|
+
if (message?.role === 'user') return 'User';
|
|
237
|
+
return 'System';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Render the durable human transcript from append-origin Harness messages. */
|
|
241
|
+
export function renderTranscript(item, events, exportedAt) {
|
|
242
|
+
const lines = [
|
|
243
|
+
'---',
|
|
244
|
+
`format: ${yamlValue(TRANSCRIPT_FORMAT)}`,
|
|
245
|
+
`version: ${FORMAT_VERSION}`,
|
|
246
|
+
`exportedAt: ${yamlValue(exportedAt)}`,
|
|
247
|
+
`id: ${yamlValue(item.id)}`,
|
|
248
|
+
`title: ${yamlValue(item.title)}`,
|
|
249
|
+
`workspaceId: ${yamlValue(item.workspace.id)}`,
|
|
250
|
+
`workspaceTitle: ${yamlValue(item.workspace.title)}`,
|
|
251
|
+
`createdAt: ${yamlValue(item.createdAt)}`,
|
|
252
|
+
`origin: ${yamlValue(item.origin)}`,
|
|
253
|
+
`tags: ${yamlValue(item.tags)}`,
|
|
254
|
+
`note: ${yamlValue(item.note)}`,
|
|
255
|
+
`metadataUpdatedAt: ${yamlValue(item.metadataUpdatedAt)}`,
|
|
256
|
+
'---',
|
|
257
|
+
'',
|
|
258
|
+
`# ${item.title ?? 'Untitled archived chat'}`,
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
262
|
+
if (!isAppendSurfaceEvent(event)) continue;
|
|
263
|
+
const message = deriveEventMessage(event);
|
|
264
|
+
if (message === null) continue;
|
|
265
|
+
const timestamp = Number.isFinite(event.time)
|
|
266
|
+
? ` - ${new Date(event.time).toISOString()}`
|
|
267
|
+
: '';
|
|
268
|
+
const content = Array.isArray(message.content)
|
|
269
|
+
? message.content.map(renderContentBlock).filter(Boolean).join('\n\n')
|
|
270
|
+
: fenced('json', jsonText(message.content));
|
|
271
|
+
lines.push('', `## ${messageLabel(message)}${timestamp}`, '', content);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return `${lines.join('\n').replace(/\n{4,}/g, '\n\n\n')}\n`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function formattedJson(value) {
|
|
278
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function addZipEntry(archive, source, name, date) {
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
archive.entry(source, { name, date }, (error) => {
|
|
284
|
+
if (error) reject(error);
|
|
285
|
+
else resolve();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Create a sequential ZIP stream. The first inspection happens before the
|
|
292
|
+
* stream is returned so HTTP callers can still send an ordinary error status.
|
|
293
|
+
*/
|
|
294
|
+
export async function createExportZip({ plan, inspect, generatorVersion }) {
|
|
295
|
+
if (!Array.isArray(plan?.items) || plan.items.length === 0) {
|
|
296
|
+
throw new TypeError('export plan must contain at least one session');
|
|
297
|
+
}
|
|
298
|
+
if (typeof inspect !== 'function') throw new TypeError('inspect must be a function');
|
|
299
|
+
|
|
300
|
+
let firstInspection = await inspect(plan.items[0].id);
|
|
301
|
+
const archive = new ZipStream({ level: 9 });
|
|
302
|
+
const entryDate = new Date(plan.exportedAt);
|
|
303
|
+
|
|
304
|
+
const completion = new Promise((resolve, reject) => {
|
|
305
|
+
archive.once('end', resolve);
|
|
306
|
+
archive.once('error', reject);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const write = async () => {
|
|
310
|
+
await addZipEntry(
|
|
311
|
+
archive,
|
|
312
|
+
formattedJson(createManifest(plan, generatorVersion)),
|
|
313
|
+
'manifest.json',
|
|
314
|
+
entryDate,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
for (let index = 0; index < plan.items.length; index += 1) {
|
|
318
|
+
const item = plan.items[index];
|
|
319
|
+
let inspected = index === 0 ? firstInspection : await inspect(item.id);
|
|
320
|
+
await addZipEntry(
|
|
321
|
+
archive,
|
|
322
|
+
formattedJson(createSessionRecord(item, inspected, plan.exportedAt)),
|
|
323
|
+
item.files.json,
|
|
324
|
+
entryDate,
|
|
325
|
+
);
|
|
326
|
+
await addZipEntry(
|
|
327
|
+
archive,
|
|
328
|
+
renderTranscript(item, inspected?.events, plan.exportedAt),
|
|
329
|
+
item.files.markdown,
|
|
330
|
+
entryDate,
|
|
331
|
+
);
|
|
332
|
+
inspected = null;
|
|
333
|
+
if (index === 0) firstInspection = null;
|
|
334
|
+
}
|
|
335
|
+
archive.finalize();
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
void write().catch((error) => {
|
|
339
|
+
archive.destroy(error);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
stream: archive,
|
|
344
|
+
completion,
|
|
345
|
+
abort(error = new Error('export aborted')) {
|
|
346
|
+
archive.destroy(error);
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* store are excluded).
|
|
11
11
|
* GET /plugins/dsh-archived-chats/stats → storage totals for
|
|
12
12
|
* visible archived sessions.
|
|
13
|
+
* POST /plugins/dsh-archived-chats/export → streamed JSON + Markdown
|
|
14
|
+
* backup ZIP.
|
|
13
15
|
* POST /plugins/dsh-archived-chats/metadata → { sessionId, tags, note }
|
|
14
16
|
* POST /plugins/dsh-archived-chats/unarchive → { sessionId }
|
|
15
17
|
* POST /plugins/dsh-archived-chats/unarchive-all → { sessionIds: [...] }
|
|
@@ -69,6 +71,7 @@ import { dirname, join } from 'node:path';
|
|
|
69
71
|
import { homedir } from 'node:os';
|
|
70
72
|
import { createMetadataStore, MetadataStoreError } from './metadata.js';
|
|
71
73
|
import { createStatsService } from './stats.js';
|
|
74
|
+
import { createExportZip, planExport } from './export.js';
|
|
72
75
|
|
|
73
76
|
/** Cordis plugin name. */
|
|
74
77
|
export const name = 'archived-chats';
|
|
@@ -83,6 +86,7 @@ const PERSISTENCE_KEYS = ['sessionPersistence'];
|
|
|
83
86
|
const ROUTE_PREFIX = '/plugins/dsh-archived-chats';
|
|
84
87
|
/** Custom header required on POSTs: cheap CSRF hardening for a loopback UI. */
|
|
85
88
|
const GUARD_HEADER = 'x-dsh-archived-chats';
|
|
89
|
+
const PLUGIN_VERSION = '0.7.0';
|
|
86
90
|
|
|
87
91
|
//#region wire helpers
|
|
88
92
|
/** Read and JSON-parse a request body (empty body → {}). */
|
|
@@ -100,6 +104,61 @@ function readBody(req) {
|
|
|
100
104
|
});
|
|
101
105
|
}
|
|
102
106
|
|
|
107
|
+
const EXPORT_BODY_LIMIT = 512 * 1024;
|
|
108
|
+
const EXPORT_SESSION_LIMIT = 2000;
|
|
109
|
+
|
|
110
|
+
/** Parse a bounded native-form export selection and preserve first-seen order. */
|
|
111
|
+
function readExportSelection(req) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
let bytes = 0;
|
|
115
|
+
let settled = false;
|
|
116
|
+
const fail = (message, status = 400) => {
|
|
117
|
+
if (settled) return;
|
|
118
|
+
settled = true;
|
|
119
|
+
reject(Object.assign(new Error(message), { status }));
|
|
120
|
+
};
|
|
121
|
+
req.on('data', (chunk) => {
|
|
122
|
+
if (settled) return;
|
|
123
|
+
const bytesChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
124
|
+
bytes += bytesChunk.length;
|
|
125
|
+
if (bytes > EXPORT_BODY_LIMIT) {
|
|
126
|
+
fail('export request body is too large', 413);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
chunks.push(bytesChunk);
|
|
130
|
+
});
|
|
131
|
+
req.on('end', () => {
|
|
132
|
+
if (settled) return;
|
|
133
|
+
try {
|
|
134
|
+
const params = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
|
|
135
|
+
const encoded = params.get('sessionIds');
|
|
136
|
+
if (encoded === null) throw new Error('sessionIds is required');
|
|
137
|
+
const submitted = JSON.parse(encoded);
|
|
138
|
+
if (!Array.isArray(submitted) || submitted.length === 0) {
|
|
139
|
+
throw new Error('sessionIds must be a non-empty array');
|
|
140
|
+
}
|
|
141
|
+
if (submitted.some((id) => typeof id !== 'string' || id === '')) {
|
|
142
|
+
throw new Error('sessionIds must contain non-empty strings');
|
|
143
|
+
}
|
|
144
|
+
const ids = [...new Set(submitted)];
|
|
145
|
+
if (ids.length > EXPORT_SESSION_LIMIT) {
|
|
146
|
+
throw new Error(`sessionIds cannot contain more than ${EXPORT_SESSION_LIMIT} unique ids`);
|
|
147
|
+
}
|
|
148
|
+
settled = true;
|
|
149
|
+
resolve(ids);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
fail(String(error?.message ?? error));
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
req.on('error', (error) => {
|
|
155
|
+
if (settled) return;
|
|
156
|
+
settled = true;
|
|
157
|
+
reject(error);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
103
162
|
/** Send one JSON response. */
|
|
104
163
|
function send(res, status, value) {
|
|
105
164
|
res.writeHead(status, {
|
|
@@ -109,6 +168,24 @@ function send(res, status, value) {
|
|
|
109
168
|
res.end(JSON.stringify(value));
|
|
110
169
|
}
|
|
111
170
|
|
|
171
|
+
function sendText(res, status, text) {
|
|
172
|
+
res.writeHead(status, {
|
|
173
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
174
|
+
'cache-control': 'no-store',
|
|
175
|
+
'x-content-type-options': 'nosniff',
|
|
176
|
+
});
|
|
177
|
+
res.end(text);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function contentDisposition(filename) {
|
|
181
|
+
const ascii = filename
|
|
182
|
+
.replace(/[^\x20-\x7e]/g, '_')
|
|
183
|
+
.replace(/["\\]/g, '_');
|
|
184
|
+
const encoded = encodeURIComponent(filename)
|
|
185
|
+
.replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
186
|
+
return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
112
189
|
/** Guard mutating routes behind a custom header (cross-site forms cannot set one). */
|
|
113
190
|
function guard(req, res) {
|
|
114
191
|
if (req.method !== 'POST') {
|
|
@@ -512,6 +589,84 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
|
|
|
512
589
|
},
|
|
513
590
|
}), 'archived-chats: stats route');
|
|
514
591
|
|
|
592
|
+
ctx.effect(() => webServer.register({
|
|
593
|
+
kind: 'exact',
|
|
594
|
+
path: `${ROUTE_PREFIX}/export`,
|
|
595
|
+
handler: async (req, res) => {
|
|
596
|
+
if (req.method !== 'POST') {
|
|
597
|
+
sendText(res, 405, 'method-not-allowed');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
const ids = await readExportSelection(req);
|
|
602
|
+
const pending = await pendingStore.load();
|
|
603
|
+
const visible = new Set(registry.archivedSessionIds
|
|
604
|
+
.map(String)
|
|
605
|
+
.filter((id) => !pending.has(id)));
|
|
606
|
+
if (ids.some((id) => !visible.has(id))) {
|
|
607
|
+
sendText(res, 404, 'session-not-archived');
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const state = await listArchived(ctx, registry, persistence, titleCache, metadataStore, pendingStore);
|
|
611
|
+
const rows = new Map(state.sessions.map((row) => [row.id, row]));
|
|
612
|
+
if (ids.some((id) => !rows.has(id))) {
|
|
613
|
+
sendText(res, 404, 'session-not-archived');
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
let stats;
|
|
617
|
+
try {
|
|
618
|
+
stats = await statsService.measure(ids);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
ctx.logger.warn(`archived-chats: export storage measurement unavailable: ${String(error?.code ?? error?.name ?? 'Error')}`);
|
|
621
|
+
stats = { sessions: {} };
|
|
622
|
+
}
|
|
623
|
+
const descriptors = ids.map((id) => ({
|
|
624
|
+
...rows.get(id),
|
|
625
|
+
storage: stats.sessions?.[id] ?? {
|
|
626
|
+
status: 'unavailable',
|
|
627
|
+
sizeBytes: null,
|
|
628
|
+
fileCount: null,
|
|
629
|
+
},
|
|
630
|
+
}));
|
|
631
|
+
const plan = planExport(descriptors, new Date());
|
|
632
|
+
const zip = await createExportZip({
|
|
633
|
+
plan,
|
|
634
|
+
inspect: (id) => persistence.inspect(id),
|
|
635
|
+
generatorVersion: PLUGIN_VERSION,
|
|
636
|
+
});
|
|
637
|
+
let aborted = false;
|
|
638
|
+
const abort = () => {
|
|
639
|
+
aborted = true;
|
|
640
|
+
zip.abort(Object.assign(new Error('export client disconnected'), { code: 'export-aborted' }));
|
|
641
|
+
};
|
|
642
|
+
req.once?.('aborted', abort);
|
|
643
|
+
res.writeHead(200, {
|
|
644
|
+
'content-type': 'application/zip',
|
|
645
|
+
'content-disposition': contentDisposition(plan.filename),
|
|
646
|
+
'cache-control': 'no-store',
|
|
647
|
+
'x-content-type-options': 'nosniff',
|
|
648
|
+
});
|
|
649
|
+
zip.stream.pipe(res);
|
|
650
|
+
try {
|
|
651
|
+
await zip.completion;
|
|
652
|
+
} finally {
|
|
653
|
+
req.off?.('aborted', abort);
|
|
654
|
+
}
|
|
655
|
+
if (aborted) return;
|
|
656
|
+
} catch (error) {
|
|
657
|
+
if (res.headersSent || res.status === 200) {
|
|
658
|
+
ctx.logger.warn(`archived-chats: export stream failed: ${String(error?.code ?? error?.name ?? 'Error')}`);
|
|
659
|
+
res.destroy?.();
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const status = error.status ?? (error instanceof SyntaxError || error instanceof TypeError ? 400 : 500);
|
|
663
|
+
sendText(res, status, status === 413
|
|
664
|
+
? 'request-too-large'
|
|
665
|
+
: (status === 400 ? 'invalid-export-request' : 'export-failed'));
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
}), 'archived-chats: export route');
|
|
669
|
+
|
|
515
670
|
ctx.effect(() => webServer.register({
|
|
516
671
|
kind: 'exact',
|
|
517
672
|
path: `${ROUTE_PREFIX}/metadata`,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser client entry — the Archived Chats settings section. Renders the
|
|
3
3
|
* summary strip, tag/type/project filters, per-row tag chips and storage
|
|
4
|
-
* sizes, and an accessible tags/note editor,
|
|
5
|
-
* 中文.
|
|
4
|
+
* sizes, single/batch backup actions, and an accessible tags/note editor,
|
|
5
|
+
* all localized in English and 中文.
|
|
6
6
|
*/
|
|
7
7
|
export declare const SETTINGS_NS: string;
|
|
8
8
|
export declare function apply(ctx: unknown): void;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host loader entry — registers the
|
|
3
|
-
* routes (state, stats, metadata, unarchive, unarchive-all, delete,
|
|
4
|
-
* delete-all) and wires archive insights:
|
|
5
|
-
* `/state`, storage statistics from
|
|
6
|
-
* through `/metadata`.
|
|
2
|
+
* Host loader entry — registers the eight `/plugins/dsh-archived-chats/*`
|
|
3
|
+
* routes (state, stats, export, metadata, unarchive, unarchive-all, delete,
|
|
4
|
+
* delete-all), streams JSON/Markdown backup ZIPs, and wires archive insights:
|
|
5
|
+
* per-session tags/notes joined into `/state`, storage statistics from
|
|
6
|
+
* `/stats`, and guarded metadata mutation through `/metadata`.
|
|
7
7
|
*/
|
|
8
8
|
export declare function apply(ctx: unknown): void;
|
|
9
9
|
export declare const name: string;
|
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.7.0",
|
|
4
|
+
"description": "DeepSeek Harness 已归档会话管理页:JSON/Markdown ZIP 备份、标签、备注、存储统计、搜索、批量操作、取消归档和删除。Archived Chats management with JSON/Markdown ZIP backups, metadata, storage insights, search, bulk actions, unarchive, and delete.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ultronen",
|
|
7
7
|
"repository": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"files": [
|
|
40
40
|
"lib/index.js",
|
|
41
41
|
"lib/client.js",
|
|
42
|
+
"lib/export.js",
|
|
42
43
|
"lib/metadata.js",
|
|
43
44
|
"lib/stats.js",
|
|
44
45
|
"lib/types",
|
|
@@ -58,7 +59,14 @@
|
|
|
58
59
|
}
|
|
59
60
|
},
|
|
60
61
|
"peerDependencies": {
|
|
61
|
-
"
|
|
62
|
-
"
|
|
62
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
63
|
+
"react": "^18.2.0"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.7",
|
|
67
|
+
"zip-stream": "^7.0.5"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"fflate": "^0.8.3"
|
|
63
71
|
}
|
|
64
72
|
}
|