dsh-save-session 0.1.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/LICENSE +21 -0
- package/README.md +41 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +92 -0
- package/lib/index.js +377 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-save-session contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# dsh-save-session
|
|
2
|
+
|
|
3
|
+
一个 DeepSeek Harness (DSH) 插件:在会话头部提供两个一键下载按钮,把当前会话导出为
|
|
4
|
+
Markdown 或渲染后的 HTML。
|
|
5
|
+
|
|
6
|
+
## 功能
|
|
7
|
+
|
|
8
|
+
- **⬇ 保存 MD** — 下载 `session-<时间戳>.md`
|
|
9
|
+
- **⬇ 保存 HTML** — 下载 `session-<时间戳>.html`
|
|
10
|
+
- Markdown 已渲染(标题 / 代码块 / 列表 / 引用 / 链接 / GFM 表格 / 行内代码 / 粗斜体)
|
|
11
|
+
- 左侧目录面板:每条消息一行(角色 + 单行预览),点击跳转;窄屏自动隐藏
|
|
12
|
+
- 同时注册模型工具 `save_session_md`:对话中说"保存当前会话为 md"即可,可选 `path` 参数,
|
|
13
|
+
默认写入会话工作目录
|
|
14
|
+
|
|
15
|
+
## 内容规则
|
|
16
|
+
|
|
17
|
+
导出**只包含用户消息与助手正文**。以下内容一律过滤:
|
|
18
|
+
|
|
19
|
+
- 模型思考过程(reasoning)
|
|
20
|
+
- 工具调用与工具结果
|
|
21
|
+
- 注入的上下文 / 子代理报告
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
dsh plugin --profile web add dsh-save-session
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
然后重启 profile(`dsh web`),按钮出现在会话标题栏右侧操作行。
|
|
30
|
+
|
|
31
|
+
## 工作原理
|
|
32
|
+
|
|
33
|
+
- Host:`sessionQuery.readSurface` 读取会话消息 → 过滤 → 生成 md / html;
|
|
34
|
+
`webServer` 注册 `GET /plugins/dsh-save-session/download?session=<id>&format=md|html`
|
|
35
|
+
按需生成并作为附件下发;`tools` 注册 `save_session_md`。
|
|
36
|
+
- Client:在 `conversation.session.header.actions` 槽位注册两个纯 `<a download>` 链接,
|
|
37
|
+
无需 RPC。
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-save-session — Client half.
|
|
3
|
+
*
|
|
4
|
+
* Two one-click download buttons in the session header action row:
|
|
5
|
+
* ⬇ 保存 MD / ⬇ 保存 HTML
|
|
6
|
+
* Both are plain anchors to the host-side download route, so no RPC is needed.
|
|
7
|
+
*/
|
|
8
|
+
window.__ModuleLoader__.load({
|
|
9
|
+
id: "dsh-save-session",
|
|
10
|
+
factory: (require) => {
|
|
11
|
+
const module = { exports: {} }
|
|
12
|
+
const exports = module.exports
|
|
13
|
+
const React = require("react")
|
|
14
|
+
|
|
15
|
+
exports.inject = []
|
|
16
|
+
|
|
17
|
+
function SaveLink(props) {
|
|
18
|
+
const sessionId = props.sessionId
|
|
19
|
+
const format = props.format
|
|
20
|
+
const label = props.label
|
|
21
|
+
const href =
|
|
22
|
+
"/plugins/dsh-save-session/download?format=" +
|
|
23
|
+
format +
|
|
24
|
+
"&session=" +
|
|
25
|
+
encodeURIComponent(String(sessionId ?? ""))
|
|
26
|
+
return React.createElement(
|
|
27
|
+
"a",
|
|
28
|
+
{
|
|
29
|
+
className: "dsh-save-session-btn",
|
|
30
|
+
href: href,
|
|
31
|
+
download: true,
|
|
32
|
+
title:
|
|
33
|
+
"导出当前会话为 " +
|
|
34
|
+
(format === "html" ? "HTML(带目录)" : "Markdown") +
|
|
35
|
+
",自动过滤思考过程与工具调用",
|
|
36
|
+
},
|
|
37
|
+
label,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function SaveSessionActions(props) {
|
|
42
|
+
const sessionId = props && props.sessionId
|
|
43
|
+
return React.createElement(
|
|
44
|
+
"span",
|
|
45
|
+
{ className: "dsh-save-session-row" },
|
|
46
|
+
React.createElement(SaveLink, {
|
|
47
|
+
sessionId: sessionId,
|
|
48
|
+
format: "md",
|
|
49
|
+
label: "⬇ 保存 MD",
|
|
50
|
+
}),
|
|
51
|
+
React.createElement(SaveLink, {
|
|
52
|
+
sessionId: sessionId,
|
|
53
|
+
format: "html",
|
|
54
|
+
label: "⬇ 保存 HTML",
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ensureStyles() {
|
|
60
|
+
if (typeof document === "undefined") return
|
|
61
|
+
if (document.querySelector("style[data-plugin-css=\"dsh-save-session/buttons.css\"]") !== null) return
|
|
62
|
+
const tag = document.createElement("style")
|
|
63
|
+
tag.dataset.plugin = "dsh-save-session"
|
|
64
|
+
tag.dataset.pluginCss = "dsh-save-session/buttons.css"
|
|
65
|
+
tag.textContent = [
|
|
66
|
+
".dsh-save-session-row { display:inline-flex; gap:6px; align-items:center; }",
|
|
67
|
+
".dsh-save-session-btn { display:inline-flex; align-items:center; gap:4px; height:26px; padding:0 10px; border:1px solid var(--dsh-border, rgba(128,128,128,0.35)); border-radius:6px; background:transparent; color:inherit; font-size:12px; line-height:1; cursor:pointer; white-space:nowrap; text-decoration:none; }",
|
|
68
|
+
".dsh-save-session-btn:hover { background: var(--dsh-hover, rgba(128,128,128,0.12)); }",
|
|
69
|
+
].join("\n")
|
|
70
|
+
document.head.appendChild(tag)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
exports.apply = function apply(ctx) {
|
|
74
|
+
const slots = ctx.get("slots")
|
|
75
|
+
if (slots === undefined) return
|
|
76
|
+
ensureStyles()
|
|
77
|
+
slots.inject("conversation.session.header.actions", () =>
|
|
78
|
+
slots.register(
|
|
79
|
+
{
|
|
80
|
+
name: "conversation.session.header.actions",
|
|
81
|
+
id: "save-session",
|
|
82
|
+
order: 30,
|
|
83
|
+
label: "导出会话",
|
|
84
|
+
},
|
|
85
|
+
SaveSessionActions,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return module.exports
|
|
91
|
+
},
|
|
92
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-save-session — Host half.
|
|
3
|
+
*
|
|
4
|
+
* - Registers the `save_session_md` model tool (writes a .md file, default
|
|
5
|
+
* into the session workspace).
|
|
6
|
+
* - Serves GET /plugins/dsh-save-session/download?session=<id>&format=md|html
|
|
7
|
+
* as a browser download, generated on demand.
|
|
8
|
+
*
|
|
9
|
+
* Both paths export only user prompts and assistant prose: reasoning blocks,
|
|
10
|
+
* tool calls, tool results, and injected contexts are always filtered out.
|
|
11
|
+
*/
|
|
12
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
13
|
+
|
|
14
|
+
export const name = 'dsh-save-session'
|
|
15
|
+
export const inject = ['sessionQuery', 'fs', 'webServer', 'tools']
|
|
16
|
+
|
|
17
|
+
function fmtTime(ms) {
|
|
18
|
+
const d = new Date(ms)
|
|
19
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
20
|
+
return (
|
|
21
|
+
d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
|
|
22
|
+
' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stamp(ms) {
|
|
27
|
+
const d = new Date(ms)
|
|
28
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
29
|
+
return (
|
|
30
|
+
'' + d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) +
|
|
31
|
+
'-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds())
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function shortIdOf(id) {
|
|
36
|
+
const s = String(id)
|
|
37
|
+
return (s.startsWith('session-') ? s.slice('session-'.length) : s).slice(0, 8)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function oneLine(text, max) {
|
|
41
|
+
const s = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
42
|
+
return s.length > max ? s.slice(0, max) + '…' : s
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function escapeHtml(s) {
|
|
46
|
+
return String(s)
|
|
47
|
+
.replaceAll('&', '&')
|
|
48
|
+
.replaceAll('<', '<')
|
|
49
|
+
.replaceAll('>', '>')
|
|
50
|
+
.replaceAll('"', '"')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Keep only user prompts and assistant prose (drop reasoning / tool / injected context). */
|
|
54
|
+
function collectMessages(events) {
|
|
55
|
+
const messages = []
|
|
56
|
+
for (const ev of events) {
|
|
57
|
+
if (ev.type === 'user/message') {
|
|
58
|
+
const src = ev.data.source && ev.data.source.kind
|
|
59
|
+
if (src !== 'user') continue
|
|
60
|
+
const text = (ev.data.content ?? [])
|
|
61
|
+
.filter((b) => b.type === 'text')
|
|
62
|
+
.map((b) => b.text)
|
|
63
|
+
.join('\n\n')
|
|
64
|
+
if (text.trim().length === 0) continue
|
|
65
|
+
messages.push({ seq: ev.seq, role: 'user', time: ev.time, text })
|
|
66
|
+
} else if (ev.type === 'assistant/message') {
|
|
67
|
+
const text = (ev.data.message.content ?? [])
|
|
68
|
+
.filter((b) => b.type === 'text')
|
|
69
|
+
.map((b) => b.text)
|
|
70
|
+
.join('\n\n')
|
|
71
|
+
if (text.trim().length === 0) continue
|
|
72
|
+
messages.push({ seq: ev.seq, role: 'assistant', time: ev.time, text })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return messages
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildMarkdown(header, messages) {
|
|
79
|
+
const title = header.title ?? '会话 ' + shortIdOf(header.id)
|
|
80
|
+
const lines = []
|
|
81
|
+
lines.push('# ' + title, '')
|
|
82
|
+
lines.push('- 会话 ID: `' + header.id + '`')
|
|
83
|
+
if (header.cwd) lines.push('- 工作目录: `' + header.cwd + '`')
|
|
84
|
+
lines.push('- 创建时间: ' + fmtTime(header.createdAt))
|
|
85
|
+
lines.push('- 导出时间: ' + fmtTime(messages.length ? messages.at(-1).time : header.createdAt))
|
|
86
|
+
lines.push('- 消息数: ' + messages.length + '(已过滤思考过程与工具调用)', '')
|
|
87
|
+
lines.push('---', '')
|
|
88
|
+
for (const m of messages) {
|
|
89
|
+
lines.push('## ' + (m.role === 'user' ? '🧑 用户' : '🤖 助手') + ' · ' + fmtTime(m.time), '')
|
|
90
|
+
lines.push(m.text, '')
|
|
91
|
+
}
|
|
92
|
+
lines.push('---', '', '*由 dsh-save-session 导出*', '')
|
|
93
|
+
return lines.join('\n')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* ---------- lightweight Markdown → HTML (headings, code, lists, quotes, links, GFM tables) ---------- */
|
|
97
|
+
|
|
98
|
+
function mdInline(s) {
|
|
99
|
+
s = escapeHtml(s)
|
|
100
|
+
s = s.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
101
|
+
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
102
|
+
s = s.replace(/(^|[^*])\*([^*\s][^*]*)\*/g, '$1<em>$2</em>')
|
|
103
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
|
|
104
|
+
return s
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function splitTableRow(line) {
|
|
108
|
+
let s = String(line).trim()
|
|
109
|
+
if (s.startsWith('|')) s = s.slice(1)
|
|
110
|
+
if (s.endsWith('|')) s = s.slice(0, -1)
|
|
111
|
+
return s.split('|').map((c) => c.trim())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isTableSeparator(line) {
|
|
115
|
+
const s = String(line).trim()
|
|
116
|
+
if (!/^\|?[\s:|-]+\|?$/.test(s)) return false
|
|
117
|
+
const cells = splitTableRow(s)
|
|
118
|
+
return cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mdToHtml(src) {
|
|
122
|
+
const out = []
|
|
123
|
+
const lines = String(src).split('\n')
|
|
124
|
+
let i = 0
|
|
125
|
+
let para = []
|
|
126
|
+
const flushPara = () => {
|
|
127
|
+
if (para.length > 0) {
|
|
128
|
+
out.push('<p>' + para.map(mdInline).join('<br>') + '</p>')
|
|
129
|
+
para = []
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
while (i < lines.length) {
|
|
133
|
+
const line = lines[i]
|
|
134
|
+
if (line.includes('|') && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
|
135
|
+
flushPara()
|
|
136
|
+
const headerCells = splitTableRow(line)
|
|
137
|
+
const aligns = splitTableRow(lines[i + 1]).map((c) => {
|
|
138
|
+
const left = c.startsWith(':')
|
|
139
|
+
const right = c.endsWith(':')
|
|
140
|
+
return left && right ? 'center' : right ? 'right' : 'left'
|
|
141
|
+
})
|
|
142
|
+
i += 2
|
|
143
|
+
const rows = []
|
|
144
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim().length > 0) {
|
|
145
|
+
rows.push(splitTableRow(lines[i]))
|
|
146
|
+
i++
|
|
147
|
+
}
|
|
148
|
+
let tbl = '<div class="tbl"><table><thead><tr>'
|
|
149
|
+
for (let c = 0; c < headerCells.length; c++) {
|
|
150
|
+
tbl += '<th style="text-align:' + (aligns[c] ?? 'left') + '">' + mdInline(headerCells[c]) + '</th>'
|
|
151
|
+
}
|
|
152
|
+
tbl += '</tr></thead><tbody>'
|
|
153
|
+
for (const row of rows) {
|
|
154
|
+
tbl += '<tr>'
|
|
155
|
+
for (let c = 0; c < headerCells.length; c++) {
|
|
156
|
+
tbl += '<td style="text-align:' + (aligns[c] ?? 'left') + '">' + mdInline(row[c] ?? '') + '</td>'
|
|
157
|
+
}
|
|
158
|
+
tbl += '</tr>'
|
|
159
|
+
}
|
|
160
|
+
out.push(tbl + '</tbody></table></div>')
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
if (/^```/.test(line)) {
|
|
164
|
+
flushPara()
|
|
165
|
+
const lang = line.slice(3).trim()
|
|
166
|
+
const buf = []
|
|
167
|
+
i++
|
|
168
|
+
while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++])
|
|
169
|
+
i++
|
|
170
|
+
out.push(
|
|
171
|
+
'<pre><code' + (lang ? ' class="language-' + escapeHtml(lang) + '"' : '') + '>' +
|
|
172
|
+
escapeHtml(buf.join('\n')) + '</code></pre>',
|
|
173
|
+
)
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (/^#{1,6}\s/.test(line)) {
|
|
177
|
+
flushPara()
|
|
178
|
+
const level = line.match(/^#+/)[0].length
|
|
179
|
+
out.push('<h' + level + '>' + mdInline(line.replace(/^#+\s*/, '')) + '</h' + level + '>')
|
|
180
|
+
i++
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (/^(---+|\*\*\*+)\s*$/.test(line)) {
|
|
184
|
+
flushPara()
|
|
185
|
+
out.push('<hr>')
|
|
186
|
+
i++
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
if (/^>\s?/.test(line)) {
|
|
190
|
+
flushPara()
|
|
191
|
+
const buf = []
|
|
192
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) buf.push(lines[i++].replace(/^>\s?/, ''))
|
|
193
|
+
out.push('<blockquote>' + buf.map(mdInline).join('<br>') + '</blockquote>')
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
if (/^\s*[-*+]\s+/.test(line)) {
|
|
197
|
+
flushPara()
|
|
198
|
+
const items = []
|
|
199
|
+
while (i < lines.length && /^\s*[-*+]\s+/.test(lines[i])) {
|
|
200
|
+
items.push('<li>' + mdInline(lines[i++].replace(/^\s*[-*+]\s+/, '')) + '</li>')
|
|
201
|
+
}
|
|
202
|
+
out.push('<ul>' + items.join('') + '</ul>')
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
if (/^\s*\d+[.)]\s+/.test(line)) {
|
|
206
|
+
flushPara()
|
|
207
|
+
const items = []
|
|
208
|
+
while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) {
|
|
209
|
+
items.push('<li>' + mdInline(lines[i++].replace(/^\s*\d+[.)]\s+/, '')) + '</li>')
|
|
210
|
+
}
|
|
211
|
+
out.push('<ol>' + items.join('') + '</ol>')
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
if (line.trim() === '') {
|
|
215
|
+
flushPara()
|
|
216
|
+
i++
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
para.push(line)
|
|
220
|
+
i++
|
|
221
|
+
}
|
|
222
|
+
flushPara()
|
|
223
|
+
return out.join('\n')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function buildHtml(header, messages) {
|
|
227
|
+
const title = header.title ?? '会话 ' + shortIdOf(header.id)
|
|
228
|
+
const p = []
|
|
229
|
+
p.push('<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">')
|
|
230
|
+
p.push('<meta name="viewport" content="width=device-width, initial-scale=1">')
|
|
231
|
+
p.push('<title>' + escapeHtml(title) + '</title><style>')
|
|
232
|
+
p.push('*{box-sizing:border-box}')
|
|
233
|
+
p.push('body{margin:0;font-family:-apple-system,"Segoe UI",Roboto,"PingFang SC",sans-serif;color:#1f2328;background:#fff;line-height:1.65}')
|
|
234
|
+
p.push('.layout{display:flex;min-height:100vh}')
|
|
235
|
+
p.push('.toc{width:280px;flex:none;border-right:1px solid #d8dee4;background:#f6f8fa;padding:20px 14px;position:sticky;top:0;height:100vh;overflow-y:auto}')
|
|
236
|
+
p.push('.toc h1{font-size:16px;margin:0 0 4px}')
|
|
237
|
+
p.push('.toc .meta{font-size:12px;color:#656d76;margin-bottom:14px}')
|
|
238
|
+
p.push('.toc a{display:block;padding:6px 8px;margin:2px 0;border-radius:6px;font-size:12px;color:#1f2328;text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}')
|
|
239
|
+
p.push('.toc a:hover{background:#eaeef2}')
|
|
240
|
+
p.push('.toc a .r{display:inline-block;width:30px;opacity:0.6}')
|
|
241
|
+
p.push('.main{flex:1;max-width:860px;margin:0 auto;padding:28px 24px 60px;min-width:0}')
|
|
242
|
+
p.push('.msg{border:1px solid #d8dee4;border-radius:10px;padding:14px 18px;margin:18px 0;scroll-margin-top:16px}')
|
|
243
|
+
p.push('.msg.user{background:#f6f8fa}')
|
|
244
|
+
p.push('.msg .role{font-size:12px;font-weight:600;color:#656d76;margin-bottom:8px}')
|
|
245
|
+
p.push('.msg .time{font-weight:400;margin-left:8px}')
|
|
246
|
+
p.push('.msg .body>:first-child{margin-top:0}.msg .body>:last-child{margin-bottom:0}')
|
|
247
|
+
p.push('.msg .body pre{background:#f6f8fa;border:1px solid #eaeef2;border-radius:8px;padding:10px 12px;overflow-x:auto;font-size:12.5px}')
|
|
248
|
+
p.push('.msg .body code{background:rgba(175,184,193,.25);border-radius:4px;padding:1px 5px;font-size:.92em}')
|
|
249
|
+
p.push('.msg .body pre code{background:none;padding:0}')
|
|
250
|
+
p.push('.msg .body blockquote{margin:8px 0;padding:4px 12px;border-left:3px solid #d8dee4;color:#656d76}')
|
|
251
|
+
p.push('.tbl{overflow-x:auto;margin:10px 0}.tbl table{border-collapse:collapse;width:100%;font-size:13px}')
|
|
252
|
+
p.push('.tbl th,.tbl td{border:1px solid #d8dee4;padding:6px 10px}.tbl th{background:#f6f8fa;font-weight:600}')
|
|
253
|
+
p.push('@media (max-width:860px){.toc{display:none}}')
|
|
254
|
+
p.push('</style></head><body><div class="layout">')
|
|
255
|
+
// left TOC
|
|
256
|
+
p.push('<nav class="toc"><h1>' + escapeHtml(title) + '</h1>')
|
|
257
|
+
p.push('<div class="meta">' + messages.length + ' 条消息 · 已过滤思考与工具调用<br>' + escapeHtml(fmtTime(header.createdAt)) + '</div>')
|
|
258
|
+
for (const m of messages) {
|
|
259
|
+
p.push('<a href="#m' + m.seq + '"><span class="r">' + (m.role === 'user' ? '🧑' : '🤖') + '</span>' + escapeHtml(oneLine(m.text, 60)) + '</a>')
|
|
260
|
+
}
|
|
261
|
+
p.push('</nav>')
|
|
262
|
+
// right body
|
|
263
|
+
p.push('<main class="main"><h2 style="margin-top:0">' + escapeHtml(title) + '</h2>')
|
|
264
|
+
p.push('<div style="font-size:13px;color:#656d76">')
|
|
265
|
+
p.push('<div>会话 ID: <code>' + escapeHtml(header.id) + '</code></div>')
|
|
266
|
+
if (header.cwd) p.push('<div>工作目录: <code>' + escapeHtml(header.cwd) + '</code></div>')
|
|
267
|
+
p.push('<div>导出时间: ' + escapeHtml(fmtTime(messages.length ? messages.at(-1).time : header.createdAt)) + '</div>')
|
|
268
|
+
p.push('</div><hr>')
|
|
269
|
+
for (const m of messages) {
|
|
270
|
+
p.push('<div class="msg ' + m.role + '" id="m' + m.seq + '">')
|
|
271
|
+
p.push('<div class="role">' + (m.role === 'user' ? '🧑 用户' : '🤖 助手') + '<span class="time">' + escapeHtml(fmtTime(m.time)) + '</span></div>')
|
|
272
|
+
p.push('<div class="body">' + mdToHtml(m.text) + '</div></div>')
|
|
273
|
+
}
|
|
274
|
+
p.push('<hr><div style="font-size:12px;color:#656d76">由 dsh-save-session 导出</div>')
|
|
275
|
+
p.push('</main></div></body></html>')
|
|
276
|
+
return p.join('\n')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/* ---------- plugin ---------- */
|
|
280
|
+
|
|
281
|
+
export function apply(ctx) {
|
|
282
|
+
async function generate(sessionId, format) {
|
|
283
|
+
const snapshot = await ctx.sessionQuery.readSurface(sessionId)
|
|
284
|
+
const messages = collectMessages(snapshot.events)
|
|
285
|
+
if (messages.length === 0) throw new Error('会话没有可导出的对话内容')
|
|
286
|
+
const lastTime = messages.at(-1).time
|
|
287
|
+
const filename = 'session-' + stamp(lastTime) + '.' + (format === 'html' ? 'html' : 'md')
|
|
288
|
+
const content = format === 'html'
|
|
289
|
+
? buildHtml(snapshot.session, messages)
|
|
290
|
+
: buildMarkdown(snapshot.session, messages)
|
|
291
|
+
return { filename, content, messages: messages.length }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// browser download, generated on demand: no client RPC needed
|
|
295
|
+
ctx.effect(() =>
|
|
296
|
+
ctx.webServer.register({
|
|
297
|
+
kind: 'exact',
|
|
298
|
+
path: '/plugins/dsh-save-session/download',
|
|
299
|
+
handler: async (req, res) => {
|
|
300
|
+
try {
|
|
301
|
+
const url = String(req.url ?? '')
|
|
302
|
+
const query = url.slice(url.indexOf('?') + 1)
|
|
303
|
+
let sessionId = ''
|
|
304
|
+
let format = 'md'
|
|
305
|
+
for (const pair of query.split('&')) {
|
|
306
|
+
const eq = pair.indexOf('=')
|
|
307
|
+
if (eq < 0) continue
|
|
308
|
+
const key = pair.slice(0, eq)
|
|
309
|
+
const value = decodeURIComponent(pair.slice(eq + 1).replace(/\+/g, ' '))
|
|
310
|
+
if (key === 'session') sessionId = value
|
|
311
|
+
if (key === 'format') format = value === 'html' ? 'html' : 'md'
|
|
312
|
+
}
|
|
313
|
+
if (sessionId.length === 0) {
|
|
314
|
+
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' })
|
|
315
|
+
res.end('missing session parameter')
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
const { filename, content } = await generate(sessionId, format)
|
|
319
|
+
res.writeHead(200, {
|
|
320
|
+
'content-type': (format === 'html' ? 'text/html' : 'text/markdown') + '; charset=utf-8',
|
|
321
|
+
'content-disposition': 'attachment; filename="' + filename + '"',
|
|
322
|
+
'cache-control': 'no-store',
|
|
323
|
+
})
|
|
324
|
+
res.end(content)
|
|
325
|
+
} catch (error) {
|
|
326
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
|
|
327
|
+
res.end('export failed: ' + String(error?.message ?? error))
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
}),
|
|
331
|
+
'dsh-save-session: download route')
|
|
332
|
+
|
|
333
|
+
// model tool: write a .md file (default into the session workspace)
|
|
334
|
+
ctx.tools.register(defineTool({
|
|
335
|
+
name: 'save_session_md',
|
|
336
|
+
description:
|
|
337
|
+
'将当前会话导出为一个 Markdown 文件(.md),自动过滤模型的思考(reasoning)过程与工具调用记录,只保留用户与助手的正文。' +
|
|
338
|
+
'用户要求"保存/导出当前会话为 md"时调用。可选指定输出路径,默认保存到会话工作目录。',
|
|
339
|
+
parameters: {
|
|
340
|
+
type: 'object',
|
|
341
|
+
properties: {
|
|
342
|
+
path: {
|
|
343
|
+
type: 'string',
|
|
344
|
+
description:
|
|
345
|
+
'输出 .md 文件的路径(绝对路径,或相对于会话工作目录的相对路径)。默认为工作目录下的 session-<时间戳>.md',
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
output: {
|
|
350
|
+
schema: {
|
|
351
|
+
type: 'object',
|
|
352
|
+
additionalProperties: false,
|
|
353
|
+
properties: {
|
|
354
|
+
path: { type: 'string' },
|
|
355
|
+
messages: { type: 'number' },
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
render: (_args, value) => [
|
|
359
|
+
{ type: 'text', text: '已保存会话到 ' + value.path + '(' + value.messages + ' 条消息,已过滤思考过程与工具调用)' },
|
|
360
|
+
],
|
|
361
|
+
},
|
|
362
|
+
async execute(args, exec) {
|
|
363
|
+
const agent = exec.agent
|
|
364
|
+
if (agent === undefined) throw new Error('save_session_md: no calling agent context')
|
|
365
|
+
const snapshot = await ctx.sessionQuery.readSurface(agent.id)
|
|
366
|
+
const messages = collectMessages(snapshot.events)
|
|
367
|
+
if (messages.length === 0) throw new Error('会话没有可导出的对话内容')
|
|
368
|
+
const lastTime = messages.at(-1).time
|
|
369
|
+
const target = args.path && args.path.trim().length > 0
|
|
370
|
+
? args.path.trim()
|
|
371
|
+
: 'session-' + stamp(lastTime) + '.md'
|
|
372
|
+
const resolved = await ctx.fs.resolve(target, { cwd: snapshot.session.cwd })
|
|
373
|
+
await ctx.fs.writeText(resolved, buildMarkdown(snapshot.session, messages))
|
|
374
|
+
return { path: ctx.fs.processPath(resolved), messages: messages.length }
|
|
375
|
+
},
|
|
376
|
+
}))
|
|
377
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-save-session",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH plugin: export the current session as Markdown or rendered HTML (with a left TOC panel) via two one-click buttons in the session header. Thinking (reasoning) and tool-call records are filtered out; only user prompts and assistant prose are kept.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jh1016248/dsh-save-session.git"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./lib/index.js",
|
|
17
|
+
"./client": "./lib/client.js",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"deepseek",
|
|
25
|
+
"deepseek-harness",
|
|
26
|
+
"dsh",
|
|
27
|
+
"dsh-plugin",
|
|
28
|
+
"session",
|
|
29
|
+
"export",
|
|
30
|
+
"markdown",
|
|
31
|
+
"html"
|
|
32
|
+
],
|
|
33
|
+
"dsh": {
|
|
34
|
+
"bundle": {
|
|
35
|
+
"patch": "./cordis.patch.yml"
|
|
36
|
+
},
|
|
37
|
+
"client": {
|
|
38
|
+
"inject": [
|
|
39
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
41
|
+
],
|
|
42
|
+
"platform": "web"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"lib/index.js",
|
|
47
|
+
"lib/client.js",
|
|
48
|
+
"cordis.patch.yml",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.8"
|
|
54
|
+
}
|
|
55
|
+
}
|