@deepseek-ai/dsh-session-log-export 0.1.1-rc.2 → 0.1.2-alpha.2
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.i18n.yaml +2 -2
- package/README.md +105 -14
- package/README.zh.md +107 -16
- package/lib/client.js +6 -4
- package/lib/index.js +416 -5
- package/lib/invariant.js +4 -1
- package/lib/types/archive.d.ts +106 -0
- package/lib/types/client/Dialog.d.ts +2 -1
- package/lib/types/client/controller.d.ts +2 -1
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/index.d.ts +17 -3
- package/package.json +28 -22
package/README.i18n.yaml
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
3
|
# after editing either side, bring the other along and re-record with:
|
|
4
4
|
# pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md
|
|
5
|
-
README.md:
|
|
6
|
-
README.zh.md:
|
|
5
|
+
README.md: a46bcdcd6fa6dda8997b3ed07f0ae789d8e3471d
|
|
6
|
+
README.zh.md: 348f6c09c8c5de4e8fe7f779b6d0fd4f662e8e34
|
package/README.md
CHANGED
|
@@ -1,31 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Web Session-log ZIP export: Host streaming, the authenticated download route, the Session Header action, and the /export command."
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# @deepseek-ai/dsh-session-log-export
|
|
2
7
|
|
|
3
8
|
English | [中文](README.zh.md)
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
## Summary
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
`dsh-session-log-export` lets the Web interface download a session's full history: a `Session log` button in the Session Header and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. The package owns the Host archive stream, its authenticated Fetch route, and the browser controls and feedback. The browser chooses the download destination. Setup and usage come first; implementation details follow.
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
## Table of Contents
|
|
15
|
+
|
|
16
|
+
- [Use this package](#use-this-package)
|
|
17
|
+
- [Understand the implementation](#understand-the-implementation)
|
|
18
|
+
- [Further Exploration](#further-exploration)
|
|
19
|
+
- [Model Experience](#model-experience)
|
|
20
|
+
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
|
21
|
+
- [Dev Note](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
13
24
|
|
|
14
|
-
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## Use this package
|
|
15
27
|
|
|
16
|
-
|
|
28
|
+
Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then click `Session log` in the Session Header or type `/export`; the browser downloads `dsh-session-<id>.zip`.
|
|
17
29
|
|
|
18
|
-
|
|
30
|
+
### When to choose it
|
|
19
31
|
|
|
20
|
-
|
|
32
|
+
Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write, and it requires a persistence backend that stores a per-session raw artifact (the shipped JSONL backend supports plaintext and zstd; SQLite export is not supported).
|
|
33
|
+
|
|
34
|
+
### Composition
|
|
21
35
|
|
|
22
36
|
```yaml
|
|
23
37
|
- id: session-log-download
|
|
24
38
|
name: '@deepseek-ai/dsh-session-log-export'
|
|
25
39
|
```
|
|
26
40
|
|
|
27
|
-
The Web bundle mounts the package
|
|
41
|
+
The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`.
|
|
42
|
+
|
|
43
|
+
### Configuration
|
|
44
|
+
|
|
45
|
+
| Field | Default | Meaning |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `compressionLevel` | `6` | DEFLATE level from 0 through 9 for each ZIP entry. |
|
|
48
|
+
|
|
49
|
+
### Command contract
|
|
50
|
+
|
|
51
|
+
| Input | Result |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `/export` | Records a human-command lifecycle; the submitting browser downloads `GET /api/session.export?sessionId=<id>&includeDescendants=true` |
|
|
54
|
+
| `/export <path>` | An error; browser downloads choose their destination through the browser's ordinary download behavior |
|
|
55
|
+
|
|
56
|
+
### What to expect
|
|
57
|
+
|
|
58
|
+
The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush.
|
|
59
|
+
|
|
60
|
+
### Failures
|
|
61
|
+
|
|
62
|
+
The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog.
|
|
63
|
+
|
|
64
|
+
-----
|
|
65
|
+
|
|
66
|
+
<a id="understand-the-implementation"></a>
|
|
67
|
+
## Understand the implementation
|
|
68
|
+
|
|
69
|
+
<details>
|
|
70
|
+
<summary>Implementation internals — click to expand</summary>
|
|
71
|
+
|
|
72
|
+
This section explains how the package wires the export control and points at the code that realizes it; the observable behavior is fully covered in [Use this package](#use-this-package).
|
|
28
73
|
|
|
74
|
+
### Design split
|
|
75
|
+
|
|
76
|
+
The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` Fetch route to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download controller and UI, and observes `command/executed` so only the submitting browser starts a download.
|
|
77
|
+
|
|
78
|
+
### Download flow
|
|
79
|
+
|
|
80
|
+
Both entry paths issue a `HEAD` preflight to `GET /api/session.export?...`, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session.
|
|
81
|
+
|
|
82
|
+
The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, raw artifact and attachment reads, ZIP generation, and HTTP status semantics.
|
|
83
|
+
|
|
84
|
+
</details>
|
|
85
|
+
|
|
86
|
+
-----
|
|
87
|
+
|
|
88
|
+
<a id="further-exploration"></a>
|
|
89
|
+
## Further Exploration
|
|
90
|
+
|
|
91
|
+
Read these pages when the package-level contract is not enough. They move from the Web control to the host endpoint and the surrounding command and session surfaces.
|
|
92
|
+
|
|
93
|
+
- [dsh-client-connection](../../client/connection/README.md) — the authenticated Fetch-route carrier used by the Host endpoint.
|
|
94
|
+
- [Commands subsystem reference](../../../docs/subsystems/commands.md) — the human-command registry the `/export` command registers on.
|
|
95
|
+
- [dsh-client-ui-commands](../../client/ui-commands/README.md) — the browser command surface that renders and acknowledges `/export`.
|
|
96
|
+
- [Session Query package map](../README.md) — the retrieval family this package belongs to.
|
|
97
|
+
|
|
98
|
+
-----
|
|
99
|
+
|
|
100
|
+
<a id="model-experience"></a>
|
|
29
101
|
## Model Experience
|
|
30
102
|
|
|
31
103
|
### Human `/export` control
|
|
@@ -44,6 +116,25 @@ None. The log-only command lifecycle and browser download do not change the deri
|
|
|
44
116
|
|
|
45
117
|
## Known Limitations and Deferred Work
|
|
46
118
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
119
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
These limits define when this package is a poor fit or needs special operational care. They are current package constraints, not a task backlog.
|
|
123
|
+
|
|
124
|
+
- **Requires a per-session raw artifact backend** — the download endpoint needs a persistence backend with a per-session raw artifact; the shipped JSONL backend supports plaintext and zstd, and SQLite export is not supported.
|
|
125
|
+
- **Browser download, not a Host-path writer** — the browser chooses the local destination; no Host path or native folder action is returned.
|
|
126
|
+
- **Preflight reports only pre-stream failures** — a descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the dialog.
|
|
127
|
+
|
|
128
|
+
<a id="dev-note"></a>
|
|
129
|
+
### Dev Note
|
|
130
|
+
|
|
131
|
+
<details>
|
|
132
|
+
<summary>Working context for maintainers — click to expand</summary>
|
|
133
|
+
|
|
134
|
+
This Dev Note is working context for maintainers: open design questions and directions that are not decided. It is explicitly non-authoritative — shipped behavior, limits, and accepted rationale live in the sections above, the package code, and the linked pages.
|
|
135
|
+
|
|
136
|
+
#### Future: export destinations beyond the browser
|
|
137
|
+
|
|
138
|
+
The download is deliberately browser-scoped; a Host-path or native folder export would need a new endpoint contract and a decision on where the ZIP lands.
|
|
139
|
+
|
|
140
|
+
</details>
|
package/README.zh.md
CHANGED
|
@@ -1,31 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Web 会话日志 ZIP 导出:Host 流式传输、认证下载路由、Session Header 操作与 /export 命令。"
|
|
3
|
+
kind: "package-reference"
|
|
4
|
+
---
|
|
5
|
+
|
|
1
6
|
# @deepseek-ai/dsh-session-log-export
|
|
2
7
|
|
|
3
8
|
[English](README.md) | 中文
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
## 概述
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 中的 `Session log` 按钮与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。本包拥有 Host 归档流、经过认证的 Fetch 路由以及浏览器控制和反馈。下载目标位置由浏览器选择。设置与用法在前,随后说明实现细节。
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
## 目录
|
|
15
|
+
|
|
16
|
+
- [使用本包](#use-this-package)
|
|
17
|
+
- [理解实现](#understand-the-implementation)
|
|
18
|
+
- [进一步探索](#further-exploration)
|
|
19
|
+
- [模型体验](#model-experience)
|
|
20
|
+
- [已知限制与延期工作](#known-limitations-and-deferred-work)
|
|
21
|
+
- [开发备注](#dev-note)
|
|
22
|
+
|
|
23
|
+
-----
|
|
13
24
|
|
|
14
|
-
|
|
25
|
+
<a id="use-this-package"></a>
|
|
26
|
+
## 使用本包
|
|
15
27
|
|
|
16
|
-
|
|
28
|
+
当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`;浏览器会下载 `dsh-session-<id>.zip`。
|
|
17
29
|
|
|
18
|
-
|
|
30
|
+
### 何时选择
|
|
19
31
|
|
|
20
|
-
|
|
32
|
+
为需要带可见下载弹窗的用户级会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入,并且它要求持久化后端保存逐会话原始产物(随附 JSONL 后端支持明文与 zstd;不支持 SQLite 导出)。
|
|
33
|
+
|
|
34
|
+
### 组合
|
|
21
35
|
|
|
22
36
|
```yaml
|
|
23
37
|
- id: session-log-download
|
|
24
38
|
name: '@deepseek-ai/dsh-session-log-export'
|
|
25
39
|
```
|
|
26
40
|
|
|
27
|
-
Web bundle 将本包与
|
|
41
|
+
Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 和 `dsh-client-ui-conversation` 一起挂载。
|
|
42
|
+
|
|
43
|
+
### 配置
|
|
44
|
+
|
|
45
|
+
| 字段 | 默认值 | 含义 |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `compressionLevel` | `6` | 每个 ZIP 条目的 DEFLATE 级别,范围为 0 到 9。 |
|
|
48
|
+
|
|
49
|
+
### 命令约定
|
|
50
|
+
|
|
51
|
+
| 输入 | 结果 |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `/export` | 记录一组用户命令生命周期;提交命令的浏览器下载 `GET /api/session.export?sessionId=<id>&includeDescendants=true` |
|
|
54
|
+
| `/export <path>` | 错误;浏览器下载通过浏览器的普通下载行为选择目标位置 |
|
|
55
|
+
|
|
56
|
+
### 预期行为
|
|
57
|
+
|
|
58
|
+
弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush。
|
|
59
|
+
|
|
60
|
+
### 失败
|
|
61
|
+
|
|
62
|
+
当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。
|
|
63
|
+
|
|
64
|
+
-----
|
|
65
|
+
|
|
66
|
+
<a id="understand-the-implementation"></a>
|
|
67
|
+
## 理解实现
|
|
68
|
+
|
|
69
|
+
<details>
|
|
70
|
+
<summary>实现细节——点击展开</summary>
|
|
71
|
+
|
|
72
|
+
本节解释本包如何接线导出控制,并指出实现它的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。
|
|
28
73
|
|
|
74
|
+
### 设计拆分
|
|
75
|
+
|
|
76
|
+
本包有两个半包。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。
|
|
77
|
+
|
|
78
|
+
### 下载流程
|
|
79
|
+
|
|
80
|
+
两条入口都会对 `GET /api/session.export?...` 发出 `HEAD` 预检,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。
|
|
81
|
+
|
|
82
|
+
Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、原始产物与附件读取、ZIP 生成和 HTTP 状态语义。
|
|
83
|
+
|
|
84
|
+
</details>
|
|
85
|
+
|
|
86
|
+
-----
|
|
87
|
+
|
|
88
|
+
<a id="further-exploration"></a>
|
|
89
|
+
## 进一步探索
|
|
90
|
+
|
|
91
|
+
当包级约定不够用时阅读以下页面。它们从 Web 控制逐步进入 Host 端点与周围的命令和会话表面。
|
|
92
|
+
|
|
93
|
+
- [dsh-client-connection](../../client/connection/README.zh.md)——Host 端点使用的认证 Fetch 路由载体。
|
|
94
|
+
- [命令子系统参考](../../../docs/subsystems/commands.zh.md)——`/export` 命令注册的用户命令注册表。
|
|
95
|
+
- [dsh-client-ui-commands](../../client/ui-commands/README.zh.md)——渲染并确认 `/export` 的浏览器命令表面。
|
|
96
|
+
- [会话查询包映射](../README.zh.md)——本包所属的检索能力家族。
|
|
97
|
+
|
|
98
|
+
-----
|
|
99
|
+
|
|
100
|
+
<a id="model-experience"></a>
|
|
29
101
|
## 模型体验
|
|
30
102
|
|
|
31
103
|
### 用户 `/export` 控制
|
|
@@ -40,10 +112,29 @@ Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-co
|
|
|
40
112
|
|
|
41
113
|
#### KV Cache 影响
|
|
42
114
|
|
|
43
|
-
|
|
115
|
+
无。仅日志命令生命周期与浏览器下载不会改变派生请求前缀。
|
|
116
|
+
|
|
117
|
+
## 已知限制与延期工作
|
|
118
|
+
|
|
119
|
+
<a id="known-limitations-and-deferred-work"></a>
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
这些限制说明本包何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是任务积压。
|
|
123
|
+
|
|
124
|
+
- **要求逐会话原始产物后端**——下载端点需要带逐会话原始产物的持久化后端;随附 JSONL 后端支持明文与 zstd,不支持 SQLite 导出。
|
|
125
|
+
- **浏览器下载,而非 Host 路径写入**——目标位置由浏览器选择;不会返回 Host 路径或原生文件夹操作。
|
|
126
|
+
- **预检只报告流式传输前的失败**——浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。
|
|
127
|
+
|
|
128
|
+
<a id="dev-note"></a>
|
|
129
|
+
### 开发备注
|
|
130
|
+
|
|
131
|
+
<details>
|
|
132
|
+
<summary>维护者的工作上下文——点击展开</summary>
|
|
133
|
+
|
|
134
|
+
本开发备注是维护者的工作上下文:开放设计问题与尚未决定的探索方向。它明确不具权威性——已交付的行为、限制与既定理由以上文、包代码和相关页面为准。
|
|
135
|
+
|
|
136
|
+
#### 未来:浏览器之外的导出目标
|
|
44
137
|
|
|
45
|
-
|
|
138
|
+
下载刻意限定在浏览器范围;Host 路径或原生文件夹导出需要新的端点约定,并决定 ZIP 的落盘位置。
|
|
46
139
|
|
|
47
|
-
|
|
48
|
-
- 这是浏览器下载,不是 Host 路径写入。目标位置由浏览器选择,不会返回 Host 路径或原生文件夹操作。
|
|
49
|
-
- 预检只报告 ZIP 开始流式传输前发现的失败。浏览器接受 GET 后发生的子 Session 或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。
|
|
140
|
+
</details>
|
package/lib/client.js
CHANGED
|
@@ -4,7 +4,7 @@ window.__ModuleLoader__.load({
|
|
|
4
4
|
var module = { exports: {} };
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
-
let
|
|
7
|
+
let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
|
|
8
8
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
9
|
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
10
10
|
//#region lib/types/client/controller.js
|
|
@@ -42,7 +42,7 @@ window.__ModuleLoader__.load({
|
|
|
42
42
|
fetcher;
|
|
43
43
|
save;
|
|
44
44
|
/** uSES-safe state source shared by every Session-scoped modal contribution. */
|
|
45
|
-
store = (0,
|
|
45
|
+
store = (0, _deepseek_ai_dsh_client_store.createSnapshotStore)(INITIAL);
|
|
46
46
|
active = /* @__PURE__ */ new Map();
|
|
47
47
|
disposed = false;
|
|
48
48
|
/**
|
|
@@ -187,7 +187,7 @@ window.__ModuleLoader__.load({
|
|
|
187
187
|
* @returns the persistent Header action and Session-scoped dialog.
|
|
188
188
|
*/
|
|
189
189
|
function SessionLogDownloadHeaderAction(props) {
|
|
190
|
-
const { sessionId, useSessionLogDownload, request } = props;
|
|
190
|
+
const { sessionId, useSessionLogDownload, request, t } = props;
|
|
191
191
|
const busy = useSessionLogDownload((state) => state.bySession[String(sessionId)])?.status === "downloading";
|
|
192
192
|
return (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [(0, react_jsx_runtime.jsxs)("button", {
|
|
193
193
|
type: "button",
|
|
@@ -197,7 +197,7 @@ window.__ModuleLoader__.load({
|
|
|
197
197
|
onClick: () => {
|
|
198
198
|
request(sessionId);
|
|
199
199
|
},
|
|
200
|
-
children: [(0, react_jsx_runtime.jsx)("span", { children: "
|
|
200
|
+
children: [(0, react_jsx_runtime.jsx)("span", { children: t("header.action") }), (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconDownloadOutline16, { size: 12 })]
|
|
201
201
|
}), (0, react_jsx_runtime.jsx)(SessionLogDownloadDialog, { ...props })] });
|
|
202
202
|
}
|
|
203
203
|
//#endregion
|
|
@@ -206,6 +206,7 @@ window.__ModuleLoader__.load({
|
|
|
206
206
|
const NS = "session-log-download";
|
|
207
207
|
/** Simplified-Chinese Session export strings. */
|
|
208
208
|
const zh = {
|
|
209
|
+
"header.action": "Session 日志",
|
|
209
210
|
"dialog.preparingTitle": "正在导出 Session",
|
|
210
211
|
"dialog.preparingDescription": "正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。",
|
|
211
212
|
"dialog.successTitle": "Session 导出已开始下载",
|
|
@@ -216,6 +217,7 @@ window.__ModuleLoader__.load({
|
|
|
216
217
|
};
|
|
217
218
|
/** English Session export strings. */
|
|
218
219
|
const en = {
|
|
220
|
+
"header.action": "Session log",
|
|
219
221
|
"dialog.preparingTitle": "Exporting Session",
|
|
220
222
|
"dialog.preparingDescription": "Preparing a ZIP containing this Session, its sub-Sessions, and attachments.",
|
|
221
223
|
"dialog.successTitle": "Session download started",
|
package/lib/index.js
CHANGED
|
@@ -1,16 +1,380 @@
|
|
|
1
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
2
|
+
import { brandString } from "@deepseek-ai/dsh-brand";
|
|
3
|
+
import { Zip, ZipDeflate } from "fflate";
|
|
4
|
+
//#region lib/types/archive.js
|
|
5
|
+
/**
|
|
6
|
+
* Host-side session-log download: streams one ZIP archive whose files are the
|
|
7
|
+
* sessions' stored artifact text verbatim plus every referenced media object.
|
|
8
|
+
* The root artifact sits under its original base name (`session.jsonl`); each
|
|
9
|
+
* subagent descendant under `subagents/<id>/<filename>`; each image referenced
|
|
10
|
+
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
|
|
11
|
+
* so one archive never duplicates a shared image). No manifest is written —
|
|
12
|
+
* every file is byte-identical to the backend's durable artifact or attachment
|
|
13
|
+
* store and self-describing through its own header line or media type. Before
|
|
14
|
+
* each live session's artifact read, the SessionStore flush barrier makes the
|
|
15
|
+
* current in-memory log durable; cold sessions need no barrier. Request abort
|
|
16
|
+
* and response-consumer cancellation share one producer signal and terminate
|
|
17
|
+
* the active compressor.
|
|
18
|
+
* Compression runs on the host with fflate's streaming Zip API, so the archive
|
|
19
|
+
* bytes are produced incrementally and the host never holds the whole archive
|
|
20
|
+
* in one buffer; production waits for consumer pull whenever the response queue
|
|
21
|
+
* reaches its byte high-water mark, so a slow consumer bounds accumulation to
|
|
22
|
+
* the fixed 64 KiB response queue plus one synchronous fflate push.
|
|
23
|
+
* @module
|
|
24
|
+
*/
|
|
25
|
+
/** Balanced default used when Session export configuration omits a compression level. */
|
|
26
|
+
const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL = 6;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the persistence, session-query, and attachment services a log export needs.
|
|
29
|
+
* @param ctx - the composed host context.
|
|
30
|
+
* @returns the export services (absent when the deployment does not mount them).
|
|
31
|
+
*/
|
|
32
|
+
function sessionLogExportDeps(ctx) {
|
|
33
|
+
return {
|
|
34
|
+
sessionQuery: ctx.get("sessionQuery"),
|
|
35
|
+
sessionPersistence: ctx.get("sessionPersistence"),
|
|
36
|
+
attachments: ctx.get("attachments"),
|
|
37
|
+
sessions: ctx.get("sessions")
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Flush one currently live session through the store's authoritative durability
|
|
42
|
+
* barrier immediately before its raw artifact is read. A cold or absent id has
|
|
43
|
+
* no in-memory work to flush.
|
|
44
|
+
* @param deps - export services, including the optional live-session store.
|
|
45
|
+
* @param id - the session whose artifact is about to be read.
|
|
46
|
+
* @param signal - optional cancellation observed around the flush barrier.
|
|
47
|
+
*/
|
|
48
|
+
async function flushLiveSessionLog(deps, id, signal) {
|
|
49
|
+
signal?.throwIfAborted();
|
|
50
|
+
const sessions = deps.sessions;
|
|
51
|
+
if (sessions === void 0) return;
|
|
52
|
+
const session = sessions.get(id);
|
|
53
|
+
if (session === void 0) return;
|
|
54
|
+
await sessions.flush(session);
|
|
55
|
+
signal?.throwIfAborted();
|
|
56
|
+
}
|
|
57
|
+
/** Zip extension for each accepted raster media type. */
|
|
58
|
+
const MEDIA_TYPE_EXTENSIONS = {
|
|
59
|
+
"image/png": "png",
|
|
60
|
+
"image/jpeg": "jpg",
|
|
61
|
+
"image/webp": "webp",
|
|
62
|
+
"image/gif": "gif"
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* The zip path for one media object: content-addressed by the opaque
|
|
66
|
+
* attachment id so shared images land once and the id in the log maps back to
|
|
67
|
+
* the archive entry without a manifest.
|
|
68
|
+
* @param ref - the durable reference from a session log.
|
|
69
|
+
* @returns the archive path.
|
|
70
|
+
*/
|
|
71
|
+
function mediaEntryPath(ref) {
|
|
72
|
+
return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Collect every image reference inside one content array, descending into
|
|
76
|
+
* nested tool results the way the live attachment route does.
|
|
77
|
+
* @param content - an event content array (or nested tool-result content).
|
|
78
|
+
* @param refs - the dedupe map being filled (keyed by attachment id).
|
|
79
|
+
*/
|
|
80
|
+
function collectImageRefs(content, refs) {
|
|
81
|
+
if (!Array.isArray(content)) return;
|
|
82
|
+
const pending = [];
|
|
83
|
+
for (const item of content) pending.push(item);
|
|
84
|
+
while (pending.length > 0) {
|
|
85
|
+
const value = pending.pop();
|
|
86
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
87
|
+
const block = value;
|
|
88
|
+
if (block.type === "image" && typeof block.attachment === "object" && block.attachment !== null) {
|
|
89
|
+
const ref = block.attachment;
|
|
90
|
+
refs.set(String(ref.attachmentId), ref);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(block.content)) for (const item of block.content) pending.push(item);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Collect every image reference one session event carries, across the same
|
|
97
|
+
* carriers the live attachment route scans (direct content, message content,
|
|
98
|
+
* inserted messages, and completed assistant chunk blocks).
|
|
99
|
+
* @param event - one parsed JSONL event object.
|
|
100
|
+
* @param refs - the dedupe map being filled (keyed by attachment id).
|
|
101
|
+
*/
|
|
102
|
+
function collectEventImageRefs(event, refs) {
|
|
103
|
+
const data = event.data;
|
|
104
|
+
if (typeof data !== "object" || data === null) return;
|
|
105
|
+
const carrier = data;
|
|
106
|
+
collectImageRefs(carrier.content, refs);
|
|
107
|
+
if (carrier.message !== void 0) collectImageRefs(carrier.message.content, refs);
|
|
108
|
+
if (carrier.inserted !== void 0) for (const message of carrier.inserted) collectImageRefs(message.content, refs);
|
|
109
|
+
if (carrier.chunk?.type === "block-end") collectImageRefs([carrier.chunk.block], refs);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Collect the distinct media references one stored artifact text names.
|
|
113
|
+
* Lines that fail to parse cannot reference media and are skipped (the
|
|
114
|
+
* artifact text itself is exported verbatim regardless).
|
|
115
|
+
* @param content - the stored artifact text.
|
|
116
|
+
* @returns the dedupe map keyed by attachment id.
|
|
117
|
+
*/
|
|
118
|
+
function imageRefsInArtifact(content) {
|
|
119
|
+
const refs = /* @__PURE__ */ new Map();
|
|
120
|
+
for (const line of content.split("\n")) {
|
|
121
|
+
if (line === "") continue;
|
|
122
|
+
let event;
|
|
123
|
+
try {
|
|
124
|
+
event = JSON.parse(line);
|
|
125
|
+
} catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
collectEventImageRefs(event, refs);
|
|
129
|
+
}
|
|
130
|
+
return refs;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* One safe zip path segment from an untrusted session id. Session ids are
|
|
134
|
+
* host-controlled, but the brand allows any non-empty string, so `../`, dot
|
|
135
|
+
* segments, and separator characters are neutralized before they can shape
|
|
136
|
+
* archive entries. Distinct ids may collapse onto one segment (id collision
|
|
137
|
+
* is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
|
|
138
|
+
* @param id - the raw session id.
|
|
139
|
+
* @returns a filesystem-safe single path segment.
|
|
140
|
+
*/
|
|
141
|
+
function safeSessionIdSegment(id) {
|
|
142
|
+
return id.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The export archive filename for one root session.
|
|
146
|
+
* @param sessionId - the root session id (sanitized to one safe path segment).
|
|
147
|
+
* @returns the attachment filename for the session's export archive.
|
|
148
|
+
*/
|
|
149
|
+
function sessionLogZipFilename(sessionId) {
|
|
150
|
+
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Yield the export entries in zip order: the preloaded root artifact first,
|
|
154
|
+
* then every subagent descendant in lineage order (each flushed when live,
|
|
155
|
+
* read from the persistence backend right before it is yielded, and dropped
|
|
156
|
+
* after the consumer moves on), then every distinct media object referenced by any of
|
|
157
|
+
* the included logs (read and verified from the attachment store, one archive
|
|
158
|
+
* entry per attachment id). The host holds at most one descendant's artifact
|
|
159
|
+
* text and one media object at a time beyond the root.
|
|
160
|
+
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
|
161
|
+
* @param root - the already-read root artifact (read by the caller so the
|
|
162
|
+
* missing-session path can answer cleanly before streaming starts).
|
|
163
|
+
* @param sessionId - the root session id.
|
|
164
|
+
* @param includeDescendants - whether to include every subagent descendant.
|
|
165
|
+
* @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
|
|
166
|
+
* @returns the export entries in zip order.
|
|
167
|
+
*/
|
|
168
|
+
async function* sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal) {
|
|
169
|
+
const media = /* @__PURE__ */ new Map();
|
|
170
|
+
const rememberMedia = (content) => {
|
|
171
|
+
for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref);
|
|
172
|
+
};
|
|
173
|
+
rememberMedia(root.content);
|
|
174
|
+
yield {
|
|
175
|
+
path: root.filename,
|
|
176
|
+
content: root.content
|
|
177
|
+
};
|
|
178
|
+
if (includeDescendants) {
|
|
179
|
+
const seen = new Set([sessionId]);
|
|
180
|
+
const collect = async function* (nodes) {
|
|
181
|
+
for (const node of nodes) {
|
|
182
|
+
signal?.throwIfAborted();
|
|
183
|
+
const id = node.session.header.id;
|
|
184
|
+
if (seen.has(id)) continue;
|
|
185
|
+
seen.add(id);
|
|
186
|
+
await flushLiveSessionLog(deps, id, signal);
|
|
187
|
+
const raw = await deps.sessionPersistence.readRaw(id, signal);
|
|
188
|
+
signal?.throwIfAborted();
|
|
189
|
+
if (raw === void 0) throw new Error(`subagent "${id}" has no stored log artifact`);
|
|
190
|
+
rememberMedia(raw.content);
|
|
191
|
+
yield {
|
|
192
|
+
path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
|
|
193
|
+
content: raw.content
|
|
194
|
+
};
|
|
195
|
+
yield* collect(node.descendants);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const lineage = await deps.sessionQuery.traceSession(sessionId, signal);
|
|
199
|
+
signal?.throwIfAborted();
|
|
200
|
+
yield* collect(lineage.descendants);
|
|
201
|
+
}
|
|
202
|
+
for (const ref of media.values()) {
|
|
203
|
+
signal?.throwIfAborted();
|
|
204
|
+
const stored = await deps.attachments.readImage(ref, signal);
|
|
205
|
+
signal?.throwIfAborted();
|
|
206
|
+
yield {
|
|
207
|
+
path: mediaEntryPath(ref),
|
|
208
|
+
data: stored.data
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** How many code units of artifact text one zip push carries (bounded encode memory). */
|
|
213
|
+
const PUSH_CHUNK_CODE_UNITS = 65536;
|
|
214
|
+
/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
|
|
215
|
+
const PUSH_CHUNK_BYTES = 65536;
|
|
216
|
+
/** Byte capacity retained by the response stream before ZIP production waits for pull. */
|
|
217
|
+
const RESPONSE_HIGH_WATER_MARK_BYTES = 65536;
|
|
218
|
+
/** One producer waiter released only when ReadableStream pull restores capacity. */
|
|
219
|
+
var ResponseCapacityGate = class {
|
|
220
|
+
releasePending;
|
|
221
|
+
/**
|
|
222
|
+
* Wait until the response queue has positive byte capacity or cancellation wins.
|
|
223
|
+
* @param controller - response controller whose desired size owns capacity.
|
|
224
|
+
* @param signal - combined request/consumer cancellation.
|
|
225
|
+
*/
|
|
226
|
+
async wait(controller, signal) {
|
|
227
|
+
signal.throwIfAborted();
|
|
228
|
+
if (controller.desiredSize === null || controller.desiredSize > 0) return;
|
|
229
|
+
await new Promise((resolve) => {
|
|
230
|
+
const release = () => {
|
|
231
|
+
this.releasePending = void 0;
|
|
232
|
+
signal.removeEventListener("abort", release);
|
|
233
|
+
resolve();
|
|
234
|
+
};
|
|
235
|
+
this.releasePending = release;
|
|
236
|
+
signal.addEventListener("abort", release, { once: true });
|
|
237
|
+
});
|
|
238
|
+
signal.throwIfAborted();
|
|
239
|
+
}
|
|
240
|
+
/** Release the current producer waiter after a consumer pull. */
|
|
241
|
+
pulled() {
|
|
242
|
+
this.releasePending?.();
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* Push one media object's bytes into a deflate stream in bounded chunks,
|
|
247
|
+
* waiting for consumer capacity between chunks like the artifact path does.
|
|
248
|
+
* @param deflate - the zip entry's deflate stream.
|
|
249
|
+
* @param data - the stored image bytes.
|
|
250
|
+
* @param controller - response queue controller.
|
|
251
|
+
* @param capacity - pull-driven response-capacity gate.
|
|
252
|
+
* @param signal - cancellation; throws when aborted.
|
|
253
|
+
*/
|
|
254
|
+
async function pushBinaryChunks(deflate, data, controller, capacity, signal) {
|
|
255
|
+
let offset = 0;
|
|
256
|
+
do {
|
|
257
|
+
signal.throwIfAborted();
|
|
258
|
+
const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength);
|
|
259
|
+
const finalChunk = end >= data.byteLength;
|
|
260
|
+
deflate.push(data.subarray(offset, end), finalChunk);
|
|
261
|
+
offset = end;
|
|
262
|
+
await capacity.wait(controller, signal);
|
|
263
|
+
} while (offset < data.byteLength);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Push one artifact's text into a deflate stream in bounded chunks, never
|
|
267
|
+
* splitting a surrogate pair across a chunk boundary (a lone high surrogate
|
|
268
|
+
* re-encodes as U+FFFD and would silently corrupt the exported artifact).
|
|
269
|
+
* @param deflate - the zip entry's deflate stream.
|
|
270
|
+
* @param content - the artifact text verbatim.
|
|
271
|
+
* @param controller - response queue controller.
|
|
272
|
+
* @param capacity - pull-driven response-capacity gate.
|
|
273
|
+
* @param signal - cancellation; throws when aborted.
|
|
274
|
+
*/
|
|
275
|
+
async function pushArtifactChunks(deflate, content, controller, capacity, signal) {
|
|
276
|
+
const encoder = new TextEncoder();
|
|
277
|
+
let offset = 0;
|
|
278
|
+
let finalChunk;
|
|
279
|
+
do {
|
|
280
|
+
signal.throwIfAborted();
|
|
281
|
+
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length);
|
|
282
|
+
if (end < content.length && end - offset > 1) {
|
|
283
|
+
const last = content.charCodeAt(end - 1);
|
|
284
|
+
if (last >= 55296 && last <= 56319) end -= 1;
|
|
285
|
+
}
|
|
286
|
+
finalChunk = end >= content.length;
|
|
287
|
+
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk);
|
|
288
|
+
offset = end;
|
|
289
|
+
await capacity.wait(controller, signal);
|
|
290
|
+
} while (!finalChunk);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
|
|
294
|
+
* read and validated by the caller before this is called (missing root or
|
|
295
|
+
* missing services answer cleanly before any byte is produced); each entry is
|
|
296
|
+
* then encoded and deflated in bounded chunks as it is produced, so the
|
|
297
|
+
* archive bytes arrive incrementally. A descendant that fails to read errors
|
|
298
|
+
* the stream (fail-loud, never silent under-export).
|
|
299
|
+
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
|
300
|
+
* @param root - the already-read root artifact (first zip entry).
|
|
301
|
+
* @param sessionId - the root session id.
|
|
302
|
+
* @param includeDescendants - whether to include every subagent descendant.
|
|
303
|
+
* @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
|
|
304
|
+
* @param signal - request cancellation combined with response-consumer cancellation.
|
|
305
|
+
* @returns the zip byte stream.
|
|
306
|
+
*/
|
|
307
|
+
function streamSessionLogZip(deps, root, sessionId, includeDescendants, compressionLevel, signal) {
|
|
308
|
+
const consumerAbort = new AbortController();
|
|
309
|
+
const producerSignal = AbortSignal.any([signal, consumerAbort.signal]);
|
|
310
|
+
let zip;
|
|
311
|
+
let zipTerminated = false;
|
|
312
|
+
const capacity = new ResponseCapacityGate();
|
|
313
|
+
const terminateZip = () => {
|
|
314
|
+
if (zip === void 0 || zipTerminated) return;
|
|
315
|
+
zipTerminated = true;
|
|
316
|
+
zip.terminate();
|
|
317
|
+
};
|
|
318
|
+
return new ReadableStream({
|
|
319
|
+
start(controller) {
|
|
320
|
+
const archive = new Zip((error, data, final) => {
|
|
321
|
+
/* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
|
|
322
|
+
if (error) {
|
|
323
|
+
controller.error(error);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
/* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
|
|
327
|
+
if (data.byteLength > 0) controller.enqueue(data);
|
|
328
|
+
if (final) controller.close();
|
|
329
|
+
});
|
|
330
|
+
zip = archive;
|
|
331
|
+
(async () => {
|
|
332
|
+
try {
|
|
333
|
+
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
|
|
334
|
+
const deflate = new ZipDeflate(entry.path, { level: compressionLevel });
|
|
335
|
+
archive.add(deflate);
|
|
336
|
+
if ("content" in entry) await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal);
|
|
337
|
+
else await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal);
|
|
338
|
+
}
|
|
339
|
+
archive.end();
|
|
340
|
+
} catch (error) {
|
|
341
|
+
/* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
|
|
342
|
+
terminateZip();
|
|
343
|
+
controller.error(error instanceof Error ? error : new Error(String(error)));
|
|
344
|
+
}
|
|
345
|
+
})();
|
|
346
|
+
},
|
|
347
|
+
pull() {
|
|
348
|
+
capacity.pulled();
|
|
349
|
+
},
|
|
350
|
+
cancel(reason) {
|
|
351
|
+
consumerAbort.abort(reason instanceof Error ? reason : /* @__PURE__ */ new Error("session log export stream cancelled"));
|
|
352
|
+
terminateZip();
|
|
353
|
+
}
|
|
354
|
+
}, {
|
|
355
|
+
highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
|
|
356
|
+
size: (chunk) => chunk.byteLength
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
//#endregion
|
|
1
360
|
//#region lib/types/index.js
|
|
2
|
-
/**
|
|
361
|
+
/** Session-log download command and Host-owned streaming route. */
|
|
3
362
|
const name = "session-log-download";
|
|
4
|
-
const inject = ["commands"];
|
|
363
|
+
const inject = ["commands", "connection"];
|
|
364
|
+
/** Stable browser download path retained across the transport migration. */
|
|
365
|
+
const SESSION_LOG_EXPORT_PATH = "/api/session.export";
|
|
366
|
+
/** Validate Session-log archive configuration. */
|
|
367
|
+
const Config = Schema.object({ compressionLevel: Schema.number().step(1).min(0).max(9).default(6) });
|
|
5
368
|
const REQUESTED = {
|
|
6
369
|
kind: "success",
|
|
7
370
|
text: "Session log download requested."
|
|
8
371
|
};
|
|
9
372
|
/**
|
|
10
|
-
* Register the Web-only `/export` command
|
|
373
|
+
* Register the Web-only `/export` command and authenticated ZIP download route.
|
|
11
374
|
* @param ctx - Host context carrying the human-command registry.
|
|
375
|
+
* @param config - resolved compression policy.
|
|
12
376
|
*/
|
|
13
|
-
function apply(ctx) {
|
|
377
|
+
function apply(ctx, config = {}) {
|
|
14
378
|
ctx.effect(() => ctx.commands.register({
|
|
15
379
|
name: "export",
|
|
16
380
|
description: "Download this Session log as a ZIP archive",
|
|
@@ -19,6 +383,53 @@ function apply(ctx) {
|
|
|
19
383
|
text: "The Web /export command does not accept a path."
|
|
20
384
|
})
|
|
21
385
|
}), "session-log-download: command");
|
|
386
|
+
connectionOf(ctx).fetch.register({
|
|
387
|
+
path: SESSION_LOG_EXPORT_PATH,
|
|
388
|
+
methods: ["GET", "HEAD"],
|
|
389
|
+
fetch: async (request) => {
|
|
390
|
+
const response = await sessionLogExportResponse(ctx, request, config.compressionLevel ?? 6);
|
|
391
|
+
if (request.method === "GET") return response;
|
|
392
|
+
await response.body?.cancel();
|
|
393
|
+
return new Response(null, {
|
|
394
|
+
status: response.status,
|
|
395
|
+
headers: response.headers
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function connectionOf(ctx) {
|
|
401
|
+
return Reflect.get(ctx, "connection");
|
|
402
|
+
}
|
|
403
|
+
async function sessionLogExportResponse(ctx, request, compressionLevel) {
|
|
404
|
+
const url = new URL(request.url);
|
|
405
|
+
const query = Object.fromEntries(url.searchParams);
|
|
406
|
+
const sessionIdValue = query["sessionId"];
|
|
407
|
+
const descendantsValue = query["includeDescendants"];
|
|
408
|
+
if (sessionIdValue === void 0 || sessionIdValue.length === 0 || descendantsValue !== void 0 && descendantsValue !== "true" && descendantsValue !== "false") return new Response("missing or invalid sessionId query parameter", { status: 400 });
|
|
409
|
+
const sessionId = brandString(sessionIdValue);
|
|
410
|
+
const deps = sessionLogExportDeps(ctx);
|
|
411
|
+
if (deps.sessionQuery === void 0 || deps.sessionPersistence === void 0 || deps.attachments === void 0) return new Response("session log export is unavailable: missing session-query, session-persistence, or attachments service", { status: 500 });
|
|
412
|
+
if (!deps.sessionPersistence.supportsRawArtifacts) return new Response("session log export is unavailable: the persistence backend does not expose per-session raw artifacts", { status: 501 });
|
|
413
|
+
const ready = {
|
|
414
|
+
sessionQuery: deps.sessionQuery,
|
|
415
|
+
sessionPersistence: deps.sessionPersistence,
|
|
416
|
+
attachments: deps.attachments,
|
|
417
|
+
sessions: deps.sessions
|
|
418
|
+
};
|
|
419
|
+
let root;
|
|
420
|
+
try {
|
|
421
|
+
await flushLiveSessionLog(deps, sessionId, request.signal);
|
|
422
|
+
root = await deps.sessionPersistence.readRaw(sessionId, request.signal);
|
|
423
|
+
request.signal.throwIfAborted();
|
|
424
|
+
} catch {
|
|
425
|
+
request.signal.throwIfAborted();
|
|
426
|
+
return new Response("session log export failed to prepare the stored artifact", { status: 500 });
|
|
427
|
+
}
|
|
428
|
+
if (root === void 0) return new Response("session not found", { status: 404 });
|
|
429
|
+
return new Response(streamSessionLogZip(ready, root, sessionId, descendantsValue === "true", compressionLevel, request.signal), { headers: {
|
|
430
|
+
"content-type": "application/zip",
|
|
431
|
+
"content-disposition": `attachment; filename="${sessionLogZipFilename(sessionId)}"`
|
|
432
|
+
} });
|
|
22
433
|
}
|
|
23
434
|
//#endregion
|
|
24
|
-
export { apply, inject, name };
|
|
435
|
+
export { Config, DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, SESSION_LOG_EXPORT_PATH, apply, flushLiveSessionLog, inject, name, sessionLogExportDeps, sessionLogZipEntries, sessionLogZipFilename, streamSessionLogZip };
|
package/lib/invariant.js
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
const PACKAGE_NAME = "@deepseek-ai/dsh-session-log-export";
|
|
4
4
|
const name = "session-export-invariant";
|
|
5
5
|
const inject = ["invariants"];
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* No runtime invariant: Connection and the command registry own both
|
|
8
|
+
* registrations, while each export reads authoritative Session services.
|
|
9
|
+
*/
|
|
7
10
|
const install = () => {};
|
|
8
11
|
/**
|
|
9
12
|
* Register this package's invariant companion.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side session-log download: streams one ZIP archive whose files are the
|
|
3
|
+
* sessions' stored artifact text verbatim plus every referenced media object.
|
|
4
|
+
* The root artifact sits under its original base name (`session.jsonl`); each
|
|
5
|
+
* subagent descendant under `subagents/<id>/<filename>`; each image referenced
|
|
6
|
+
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
|
|
7
|
+
* so one archive never duplicates a shared image). No manifest is written —
|
|
8
|
+
* every file is byte-identical to the backend's durable artifact or attachment
|
|
9
|
+
* store and self-describing through its own header line or media type. Before
|
|
10
|
+
* each live session's artifact read, the SessionStore flush barrier makes the
|
|
11
|
+
* current in-memory log durable; cold sessions need no barrier. Request abort
|
|
12
|
+
* and response-consumer cancellation share one producer signal and terminate
|
|
13
|
+
* the active compressor.
|
|
14
|
+
* Compression runs on the host with fflate's streaming Zip API, so the archive
|
|
15
|
+
* bytes are produced incrementally and the host never holds the whole archive
|
|
16
|
+
* in one buffer; production waits for consumer pull whenever the response queue
|
|
17
|
+
* reaches its byte high-water mark, so a slow consumer bounds accumulation to
|
|
18
|
+
* the fixed 64 KiB response queue plus one synchronous fflate push.
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
22
|
+
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
23
|
+
import type { SessionQueryEngine } from '@deepseek-ai/dsh-session-query';
|
|
24
|
+
import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session';
|
|
25
|
+
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence';
|
|
26
|
+
/** Valid fflate DEFLATE levels accepted by session-log export. */
|
|
27
|
+
export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
|
28
|
+
/** Balanced default used when Session export configuration omits a compression level. */
|
|
29
|
+
export declare const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel;
|
|
30
|
+
/** The services a session-log export needs (the live-session store is optional). */
|
|
31
|
+
export interface SessionLogExportDeps {
|
|
32
|
+
readonly sessionQuery: SessionQueryEngine | undefined;
|
|
33
|
+
readonly sessionPersistence: SessionPersistence | undefined;
|
|
34
|
+
readonly attachments: AttachmentStore | undefined;
|
|
35
|
+
readonly sessions: SessionStore | undefined;
|
|
36
|
+
}
|
|
37
|
+
/** The export services narrowed to the mounted ones streaming actually reads. */
|
|
38
|
+
export interface SessionLogExportReady {
|
|
39
|
+
readonly sessionQuery: SessionQueryEngine;
|
|
40
|
+
readonly sessionPersistence: SessionPersistence;
|
|
41
|
+
readonly attachments: AttachmentStore;
|
|
42
|
+
readonly sessions: SessionStore | undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the persistence, session-query, and attachment services a log export needs.
|
|
46
|
+
* @param ctx - the composed host context.
|
|
47
|
+
* @returns the export services (absent when the deployment does not mount them).
|
|
48
|
+
*/
|
|
49
|
+
export declare function sessionLogExportDeps(ctx: Context): SessionLogExportDeps;
|
|
50
|
+
/**
|
|
51
|
+
* Flush one currently live session through the store's authoritative durability
|
|
52
|
+
* barrier immediately before its raw artifact is read. A cold or absent id has
|
|
53
|
+
* no in-memory work to flush.
|
|
54
|
+
* @param deps - export services, including the optional live-session store.
|
|
55
|
+
* @param id - the session whose artifact is about to be read.
|
|
56
|
+
* @param signal - optional cancellation observed around the flush barrier.
|
|
57
|
+
*/
|
|
58
|
+
export declare function flushLiveSessionLog(deps: Pick<SessionLogExportDeps, 'sessions'>, id: SessionId, signal?: AbortSignal): Promise<void>;
|
|
59
|
+
/** One exported file: a stored artifact text or one referenced media object. */
|
|
60
|
+
export type SessionLogZipEntry = {
|
|
61
|
+
readonly path: string;
|
|
62
|
+
readonly content: string;
|
|
63
|
+
} | {
|
|
64
|
+
readonly path: string;
|
|
65
|
+
readonly data: Uint8Array;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The export archive filename for one root session.
|
|
69
|
+
* @param sessionId - the root session id (sanitized to one safe path segment).
|
|
70
|
+
* @returns the attachment filename for the session's export archive.
|
|
71
|
+
*/
|
|
72
|
+
export declare function sessionLogZipFilename(sessionId: string): string;
|
|
73
|
+
/**
|
|
74
|
+
* Yield the export entries in zip order: the preloaded root artifact first,
|
|
75
|
+
* then every subagent descendant in lineage order (each flushed when live,
|
|
76
|
+
* read from the persistence backend right before it is yielded, and dropped
|
|
77
|
+
* after the consumer moves on), then every distinct media object referenced by any of
|
|
78
|
+
* the included logs (read and verified from the attachment store, one archive
|
|
79
|
+
* entry per attachment id). The host holds at most one descendant's artifact
|
|
80
|
+
* text and one media object at a time beyond the root.
|
|
81
|
+
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
|
82
|
+
* @param root - the already-read root artifact (read by the caller so the
|
|
83
|
+
* missing-session path can answer cleanly before streaming starts).
|
|
84
|
+
* @param sessionId - the root session id.
|
|
85
|
+
* @param includeDescendants - whether to include every subagent descendant.
|
|
86
|
+
* @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
|
|
87
|
+
* @returns the export entries in zip order.
|
|
88
|
+
*/
|
|
89
|
+
export declare function sessionLogZipEntries(deps: SessionLogExportReady, root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, signal?: AbortSignal): AsyncGenerator<SessionLogZipEntry>;
|
|
90
|
+
/**
|
|
91
|
+
* Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
|
|
92
|
+
* read and validated by the caller before this is called (missing root or
|
|
93
|
+
* missing services answer cleanly before any byte is produced); each entry is
|
|
94
|
+
* then encoded and deflated in bounded chunks as it is produced, so the
|
|
95
|
+
* archive bytes arrive incrementally. A descendant that fails to read errors
|
|
96
|
+
* the stream (fail-loud, never silent under-export).
|
|
97
|
+
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
|
98
|
+
* @param root - the already-read root artifact (first zip entry).
|
|
99
|
+
* @param sessionId - the root session id.
|
|
100
|
+
* @param includeDescendants - whether to include every subagent descendant.
|
|
101
|
+
* @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
|
|
102
|
+
* @param signal - request cancellation combined with response-consumer cancellation.
|
|
103
|
+
* @returns the zip byte stream.
|
|
104
|
+
*/
|
|
105
|
+
export declare function streamSessionLogZip(deps: SessionLogExportReady, root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, compressionLevel: SessionLogCompressionLevel, signal: AbortSignal): ReadableStream<Uint8Array>;
|
|
106
|
+
//# sourceMappingURL=archive.d.ts.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ObservableSnapshot
|
|
1
|
+
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store';
|
|
2
|
+
import type { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
2
3
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
3
4
|
import type { SessionLogDownloadState } from './controller.ts';
|
|
4
5
|
import { NS } from './locales.ts';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Browser download state shared by the Session Header button and `/export`. */
|
|
2
|
-
import { type
|
|
2
|
+
import { type SnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
3
|
+
import type { SessionId } from '@deepseek-ai/dsh-session/types';
|
|
3
4
|
/** Download phases presented by the shared modal. */
|
|
4
5
|
export type SessionLogDownloadStatus = 'downloading' | 'success' | 'error';
|
|
5
6
|
/** One Session's current download-dialog state. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Browser plugin owning Session export download state and its shared modal. */
|
|
2
|
-
import type { ClientContext } from '@deepseek-ai/
|
|
2
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis';
|
|
3
3
|
import { SessionLogDownloadController } from './controller.ts';
|
|
4
4
|
import { type SessionLogDownloadKey } from './locales.ts';
|
|
5
5
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export declare const NS = "session-log-download";
|
|
3
3
|
/** Simplified-Chinese Session export strings. */
|
|
4
4
|
export declare const zh: {
|
|
5
|
+
readonly 'header.action': "Session 日志";
|
|
5
6
|
readonly 'dialog.preparingTitle': "正在导出 Session";
|
|
6
7
|
readonly 'dialog.preparingDescription': "正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。";
|
|
7
8
|
readonly 'dialog.successTitle': "Session 导出已开始下载";
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Session-log download command and Host-owned streaming route. */
|
|
2
2
|
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
4
|
+
import { type SessionLogCompressionLevel } from './archive.ts';
|
|
5
|
+
export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, sessionLogExportDeps, sessionLogZipEntries, sessionLogZipFilename, streamSessionLogZip, } from './archive.ts';
|
|
6
|
+
export type { SessionLogCompressionLevel, SessionLogExportDeps, SessionLogExportReady, SessionLogZipEntry, } from './archive.ts';
|
|
3
7
|
export declare const name = "session-log-download";
|
|
4
8
|
export declare const inject: string[];
|
|
9
|
+
/** Stable browser download path retained across the transport migration. */
|
|
10
|
+
export declare const SESSION_LOG_EXPORT_PATH = "/api/session.export";
|
|
11
|
+
/** Session-log archive policy. */
|
|
12
|
+
export interface Config {
|
|
13
|
+
/** DEFLATE level for each ZIP entry. @default 6 */
|
|
14
|
+
readonly compressionLevel?: SessionLogCompressionLevel;
|
|
15
|
+
}
|
|
16
|
+
/** Validate Session-log archive configuration. */
|
|
17
|
+
export declare const Config: Schema<Config>;
|
|
5
18
|
/**
|
|
6
|
-
* Register the Web-only `/export` command
|
|
19
|
+
* Register the Web-only `/export` command and authenticated ZIP download route.
|
|
7
20
|
* @param ctx - Host context carrying the human-command registry.
|
|
21
|
+
* @param config - resolved compression policy.
|
|
8
22
|
*/
|
|
9
|
-
export declare function apply(ctx: Context): void;
|
|
23
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
10
24
|
//# sourceMappingURL=index.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepseek-ai/dsh-session-log-export",
|
|
3
3
|
"description": "Web Session-log export command and shared download dialog",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2-alpha.2",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -36,38 +36,44 @@
|
|
|
36
36
|
"lib/types/**/*.d.ts"
|
|
37
37
|
],
|
|
38
38
|
"license": "MIT",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"fflate": "^0.8.2",
|
|
41
|
+
"@deepseek-ai/dsh-brand": "^0.1.2-alpha.2",
|
|
42
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
43
|
+
},
|
|
39
44
|
"peerDependencies": {
|
|
40
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
41
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
|
|
42
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
|
|
43
|
-
"@deepseek-ai/dsh-client-ui-commands": "^0.1.1-rc.2",
|
|
44
|
-
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
|
|
45
|
-
"@deepseek-ai/dsh-commands": "^0.1.1-rc.2",
|
|
46
|
-
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2"
|
|
45
|
+
"@deepseek-ai/cordis": "^4.0.2"
|
|
47
46
|
},
|
|
48
47
|
"devDependencies": {
|
|
49
48
|
"@types/react": "~18.3.1",
|
|
50
49
|
"react": "^18.2.0",
|
|
51
|
-
"@deepseek-ai/cordis": "^4.0.
|
|
52
|
-
"@deepseek-ai/cordis-plugin-loader": "^1.0.
|
|
53
|
-
"@deepseek-ai/dsh-
|
|
54
|
-
"@deepseek-ai/dsh-
|
|
55
|
-
"@deepseek-ai/dsh-client-
|
|
56
|
-
"@deepseek-ai/dsh-client-ui-commands": "^0.1.
|
|
57
|
-
"@deepseek-ai/dsh-client-
|
|
58
|
-
"@deepseek-ai/dsh-client-ui-
|
|
59
|
-
"@deepseek-ai/dsh-client-ui-
|
|
60
|
-
"@deepseek-ai/dsh-
|
|
61
|
-
"@deepseek-ai/dsh-
|
|
62
|
-
"@deepseek-ai/dsh-
|
|
50
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
51
|
+
"@deepseek-ai/cordis-plugin-loader": "^1.0.3",
|
|
52
|
+
"@deepseek-ai/dsh-attachment": "^0.1.2-alpha.2",
|
|
53
|
+
"@deepseek-ai/dsh-agent": "^0.1.2-alpha.2",
|
|
54
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.2-alpha.2",
|
|
55
|
+
"@deepseek-ai/dsh-client-ui-commands": "^0.1.2-alpha.2",
|
|
56
|
+
"@deepseek-ai/dsh-client-store": "^0.1.2-alpha.2",
|
|
57
|
+
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.2-alpha.2",
|
|
58
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-alpha.2",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-session": "^0.1.2-alpha.2",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-alpha.2",
|
|
61
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.2-alpha.2",
|
|
62
|
+
"@deepseek-ai/dsh-commands": "^0.1.2-alpha.2",
|
|
63
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-alpha.2",
|
|
64
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-alpha.2",
|
|
65
|
+
"@deepseek-ai/dsh-session-persistence": "^0.1.2-alpha.2",
|
|
66
|
+
"@deepseek-ai/dsh-session-query": "^0.1.2-alpha.2",
|
|
67
|
+
"@deepseek-ai/dsh-session": "^0.1.2-alpha.2"
|
|
63
68
|
},
|
|
64
69
|
"dsh": {
|
|
65
70
|
"client": {
|
|
66
71
|
"inject": [
|
|
67
72
|
"@deepseek-ai/dsh-client-locale",
|
|
68
|
-
"@deepseek-ai/dsh-client-runtime",
|
|
69
73
|
"@deepseek-ai/dsh-client-ui-commands",
|
|
70
|
-
"@deepseek-ai/dsh-client-ui-conversation"
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-session"
|
|
71
77
|
],
|
|
72
78
|
"platform": "web"
|
|
73
79
|
}
|