@heixui/dsh-session-suite 0.0.0-stage → 0.3.1
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 +381 -2
- package/README.zh.md +178 -0
- package/THIRD-PARTY.md +64 -0
- package/client.js +2974 -0
- package/cordis.patch.yml +77 -0
- package/icon.svg +19 -0
- package/index.js +211 -0
- package/lib/api.js +387 -0
- package/lib/checkpoints.js +774 -0
- package/lib/data/jieba-dict.txt +109750 -0
- package/lib/factkeys.js +212 -0
- package/lib/markdown.js +252 -0
- package/lib/memory-engine.js +1323 -0
- package/lib/memory-host.js +683 -0
- package/lib/memory.js +224 -0
- package/lib/retrieval.js +217 -0
- package/lib/service.js +1273 -0
- package/lib/share.js +641 -0
- package/lib/step-tag-filter.js +199 -0
- package/lib/tags.js +140 -0
- package/lib/util.js +261 -0
- package/lib/wordlist.js +183 -0
- package/locale/en.json +87 -0
- package/locale/zh.json +87 -0
- package/package.json +76 -4
package/lib/share.js
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session sharing: turn a session's durable event log into portable text.
|
|
3
|
+
*
|
|
4
|
+
* The log is the source of truth (a plugin must not invent history), so every
|
|
5
|
+
* renderer here is a pure projection of `SessionEvent[]`:
|
|
6
|
+
*
|
|
7
|
+
* markdown — human readable transcript for a chat/issue/paste target
|
|
8
|
+
* text — the same transcript without markup, for clipboard use
|
|
9
|
+
* json — structured messages, for round-tripping into another tool
|
|
10
|
+
* html — one self-contained, printable page (the portable "share" artifact)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { blocksToHtml, escapeHtml, parseMarkdown, previewText } from './markdown.js'
|
|
14
|
+
import { stripStepTags } from './step-tag-filter.js'
|
|
15
|
+
|
|
16
|
+
const RUNTIME_SOURCES = new Set(['runtime-context', 'system-prompt', 'developer'])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Collapse a log to the two things a reader wants: the prompts and the closing
|
|
20
|
+
* reply of each turn. Tool traffic, request headers, and the narration a model
|
|
21
|
+
* emits between tool calls never reach an export in `final` mode.
|
|
22
|
+
*
|
|
23
|
+
* @param {readonly any[]} events durable events
|
|
24
|
+
* @returns {any[]} curated events, in log order
|
|
25
|
+
*/
|
|
26
|
+
export function curateEvents(events) {
|
|
27
|
+
const out = []
|
|
28
|
+
let lastAssistant = -1
|
|
29
|
+
for (const event of events) {
|
|
30
|
+
if (!event || typeof event.type !== 'string') continue
|
|
31
|
+
if (event.type === 'assistant/message') {
|
|
32
|
+
const previous = lastAssistant >= 0 ? out[lastAssistant] : undefined
|
|
33
|
+
const sameTurn = previous && previous.data?.turn === event.data?.turn
|
|
34
|
+
if (sameTurn) out[lastAssistant] = event
|
|
35
|
+
else {
|
|
36
|
+
out.push(event)
|
|
37
|
+
lastAssistant = out.length - 1
|
|
38
|
+
}
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (event.type === 'user/message') {
|
|
42
|
+
out.push(event)
|
|
43
|
+
lastAssistant = -1
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (event.type === 'tool/call' || event.type === 'tool/result' || event.type === 'assistant/attempt') continue
|
|
47
|
+
out.push(event)
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Keep the events that fall inside the selected turn ranges.
|
|
54
|
+
*
|
|
55
|
+
* @param {readonly any[]} events durable events
|
|
56
|
+
* @param {readonly { startSeq: number, endSeq: number }[]} [ranges] selected ranges; omitted keeps everything
|
|
57
|
+
* @returns {any[]} filtered events
|
|
58
|
+
*/
|
|
59
|
+
export function selectEvents(events, ranges) {
|
|
60
|
+
if (!Array.isArray(ranges) || ranges.length === 0) return [...events]
|
|
61
|
+
return events.filter(event => {
|
|
62
|
+
const seq = event?.seq
|
|
63
|
+
if (typeof seq !== 'number') return false
|
|
64
|
+
return ranges.some(range => seq >= range.startSeq && seq <= range.endSeq)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {object} input render input
|
|
70
|
+
* @param {string} input.sessionId session identity
|
|
71
|
+
* @param {string} [input.title] session title
|
|
72
|
+
* @param {number} [input.exportedAt] timestamp
|
|
73
|
+
* @param {readonly any[]} input.events durable events in sequence order
|
|
74
|
+
* @param {{ reasoning?: boolean, toolDetails?: boolean, toolResults?: boolean }} [input.options] render switches
|
|
75
|
+
* @returns {string} markdown document
|
|
76
|
+
*/
|
|
77
|
+
export function renderMarkdown({ sessionId, title, exportedAt = Date.now(), events, options = {} }) {
|
|
78
|
+
const lines = []
|
|
79
|
+
lines.push(`# ${title?.trim() || 'Session'}`)
|
|
80
|
+
lines.push('')
|
|
81
|
+
lines.push(`- Session: \`${sessionId}\``)
|
|
82
|
+
lines.push(`- Exported: ${new Date(exportedAt).toISOString()}`)
|
|
83
|
+
const stats = summarize(events)
|
|
84
|
+
lines.push(`- Turns: ${stats.turns} · Messages: ${stats.messages} · Tool calls: ${stats.toolCalls}`)
|
|
85
|
+
lines.push('')
|
|
86
|
+
lines.push('---')
|
|
87
|
+
lines.push('')
|
|
88
|
+
|
|
89
|
+
for (const entry of projectMessages(events, options)) {
|
|
90
|
+
if (entry.role === 'user') {
|
|
91
|
+
lines.push('### User')
|
|
92
|
+
lines.push('')
|
|
93
|
+
lines.push(entry.text.trim() || '_(no text)_')
|
|
94
|
+
lines.push('')
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (entry.role === 'assistant') {
|
|
98
|
+
lines.push(`### Assistant · turn ${entry.turn ?? '?'} · step ${entry.step ?? '?'}`)
|
|
99
|
+
lines.push('')
|
|
100
|
+
if (options.reasoning && entry.reasoning?.trim()) {
|
|
101
|
+
lines.push('<details><summary>Reasoning</summary>')
|
|
102
|
+
lines.push('')
|
|
103
|
+
lines.push(entry.reasoning.trim())
|
|
104
|
+
lines.push('')
|
|
105
|
+
lines.push('</details>')
|
|
106
|
+
lines.push('')
|
|
107
|
+
}
|
|
108
|
+
for (const part of entry.parts) {
|
|
109
|
+
if (part.kind === 'text') {
|
|
110
|
+
lines.push(part.text.trim())
|
|
111
|
+
lines.push('')
|
|
112
|
+
} else if (part.kind === 'tool-call' && options.toolDetails !== false) {
|
|
113
|
+
lines.push(`> **Tool call** \`${part.name}\``)
|
|
114
|
+
lines.push('')
|
|
115
|
+
lines.push('```json')
|
|
116
|
+
lines.push(prettyJson(part.arguments))
|
|
117
|
+
lines.push('```')
|
|
118
|
+
lines.push('')
|
|
119
|
+
if (options.toolResults !== false && part.result !== undefined) {
|
|
120
|
+
lines.push('```text')
|
|
121
|
+
lines.push(clip(part.result, 4000))
|
|
122
|
+
lines.push('```')
|
|
123
|
+
lines.push('')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
if (entry.role === 'error') {
|
|
130
|
+
lines.push('> **Turn error**')
|
|
131
|
+
lines.push('')
|
|
132
|
+
lines.push('```text')
|
|
133
|
+
lines.push(clip(entry.text, 2000))
|
|
134
|
+
lines.push('```')
|
|
135
|
+
lines.push('')
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {object} input render input
|
|
143
|
+
* @param {string} input.sessionId session identity
|
|
144
|
+
* @param {string} [input.title] session title
|
|
145
|
+
* @param {readonly any[]} input.events durable events
|
|
146
|
+
* @param {{ reasoning?: boolean, toolDetails?: boolean, toolResults?: boolean }} [input.options] switches
|
|
147
|
+
* @returns {string} plain-text transcript
|
|
148
|
+
*/
|
|
149
|
+
export function renderText({ sessionId, title, events, options = {} }) {
|
|
150
|
+
const out = []
|
|
151
|
+
const header = title?.trim() || sessionId
|
|
152
|
+
out.push(header)
|
|
153
|
+
out.push('='.repeat(Math.min(72, Math.max(8, header.length))))
|
|
154
|
+
out.push('')
|
|
155
|
+
for (const entry of projectMessages(events, options)) {
|
|
156
|
+
if (entry.role === 'user') {
|
|
157
|
+
out.push('User:')
|
|
158
|
+
out.push(entry.text.trim())
|
|
159
|
+
out.push('')
|
|
160
|
+
} else if (entry.role === 'assistant') {
|
|
161
|
+
out.push(`Assistant (turn ${entry.turn ?? '?'}, step ${entry.step ?? '?'}):`)
|
|
162
|
+
for (const part of entry.parts) {
|
|
163
|
+
if (part.kind === 'text') out.push(part.text.trim())
|
|
164
|
+
else if (part.kind === 'tool-call' && options.toolDetails !== false) {
|
|
165
|
+
out.push(` [tool:${part.name}] ${clip(prettyJson(part.arguments), 400)}`)
|
|
166
|
+
if (options.toolResults !== false && part.result !== undefined) out.push(` [result] ${clip(part.result, 800)}`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
out.push('')
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {object} input render input
|
|
177
|
+
* @param {string} input.sessionId session identity
|
|
178
|
+
* @param {string} [input.title] session title
|
|
179
|
+
* @param {readonly any[]} input.events durable events
|
|
180
|
+
* @returns {string} JSON document
|
|
181
|
+
*/
|
|
182
|
+
export function renderJson({ sessionId, title, events }) {
|
|
183
|
+
const messages = []
|
|
184
|
+
for (const entry of projectMessages(events, { toolDetails: true, toolResults: true })) {
|
|
185
|
+
if (entry.role === 'user') messages.push({ role: 'user', text: entry.text, turn: entry.turn ?? null })
|
|
186
|
+
else if (entry.role === 'assistant') {
|
|
187
|
+
messages.push({
|
|
188
|
+
role: 'assistant',
|
|
189
|
+
turn: entry.turn ?? null,
|
|
190
|
+
step: entry.step ?? null,
|
|
191
|
+
text: entry.parts.filter(part => part.kind === 'text').map(part => part.text).join('\n'),
|
|
192
|
+
reasoning: entry.reasoning ?? null,
|
|
193
|
+
toolCalls: entry.parts
|
|
194
|
+
.filter(part => part.kind === 'tool-call')
|
|
195
|
+
.map(part => ({ name: part.name, arguments: safeParse(part.arguments), result: part.result ?? null })),
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return `${JSON.stringify({ sessionId, title: title ?? null, exportedAt: Date.now(), messages }, null, 2)}\n`
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {readonly any[]} events durable events
|
|
204
|
+
* @param {{ reasoning?: boolean, toolDetails?: boolean, toolResults?: boolean }} options switches
|
|
205
|
+
* @returns {any[]} projected entries
|
|
206
|
+
*/
|
|
207
|
+
export function projectMessages(events, options = {}) {
|
|
208
|
+
const out = []
|
|
209
|
+
/** @type {Map<string, string>} */
|
|
210
|
+
const results = new Map()
|
|
211
|
+
for (const event of events) {
|
|
212
|
+
if (event?.type !== 'tool/result') continue
|
|
213
|
+
const callId = event.data?.message?.toolCallId ?? event.data?.message?.source?.callId
|
|
214
|
+
const text = blocksToText(event.data?.message?.content)
|
|
215
|
+
if (typeof callId === 'string') results.set(callId, text)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const event of events) {
|
|
219
|
+
if (!event || typeof event.type !== 'string') continue
|
|
220
|
+
if (event.type === 'user/message') {
|
|
221
|
+
const source = event.data?.source
|
|
222
|
+
const kind = typeof source === 'object' && source ? source.kind : undefined
|
|
223
|
+
// Only what the person actually typed is conversation. Everything else in
|
|
224
|
+
// a user-role event is harness plumbing: runtime context, the skill
|
|
225
|
+
// catalog, time samples, subagent relays, and settlement notices.
|
|
226
|
+
if (kind !== undefined && kind !== 'user') continue
|
|
227
|
+
const content = Array.isArray(event.data?.content) ? event.data.content : []
|
|
228
|
+
const text = content
|
|
229
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
230
|
+
.map(block => block.text)
|
|
231
|
+
.join('\n')
|
|
232
|
+
const images = content
|
|
233
|
+
.filter(block => block?.type === 'image' && block.attachment)
|
|
234
|
+
.map(block => ({
|
|
235
|
+
attachmentId: block.attachment.attachmentId,
|
|
236
|
+
mediaType: block.attachment.mediaType,
|
|
237
|
+
width: block.attachment.width,
|
|
238
|
+
height: block.attachment.height,
|
|
239
|
+
name: block.attachment.name,
|
|
240
|
+
}))
|
|
241
|
+
if (!text.trim() && images.length === 0) continue
|
|
242
|
+
out.push({ role: 'user', text, images, turn: event.data?.turn ?? null })
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
if (event.type === 'assistant/message') {
|
|
246
|
+
const message = event.data?.message
|
|
247
|
+
if (!message || !Array.isArray(message.content)) continue
|
|
248
|
+
const parts = []
|
|
249
|
+
let reasoning = ''
|
|
250
|
+
for (const block of message.content) {
|
|
251
|
+
if (block?.type === 'text' && typeof block.text === 'string') parts.push({ kind: 'text', text: stripStepTags(block.text) })
|
|
252
|
+
else if (block?.type === 'reasoning' && typeof block.text === 'string') reasoning += `${stripStepTags(block.text)}\n`
|
|
253
|
+
else if (block?.type === 'tool-call') {
|
|
254
|
+
const result = typeof block.id === 'string' ? results.get(block.id) : undefined
|
|
255
|
+
parts.push({ kind: 'tool-call', name: block.name, arguments: block.arguments, result })
|
|
256
|
+
} else if (block?.type === 'image') parts.push({ kind: 'image', name: block.attachment?.name ?? 'image' })
|
|
257
|
+
else if (block?.type === 'file') parts.push({ kind: 'file', name: block.attachment?.name ?? 'file' })
|
|
258
|
+
}
|
|
259
|
+
if (parts.length === 0 && !reasoning) continue
|
|
260
|
+
out.push({ role: 'assistant', turn: event.data?.turn ?? null, step: event.data?.step ?? null, parts, reasoning })
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
if (event.type === 'turn-error') {
|
|
264
|
+
out.push({ role: 'error', text: blocksToText(event.data?.message?.content) || JSON.stringify(event.data ?? {}) })
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return out
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* @param {readonly any[]} events durable events
|
|
272
|
+
* @returns {{ turns: number, messages: number, toolCalls: number }} counts
|
|
273
|
+
*/
|
|
274
|
+
export function summarize(events) {
|
|
275
|
+
let turns = 0
|
|
276
|
+
let messages = 0
|
|
277
|
+
let toolCalls = 0
|
|
278
|
+
for (const event of events) {
|
|
279
|
+
if (event?.type === 'turn/start') turns += 1
|
|
280
|
+
else if (event?.type === 'user/message') messages += 1
|
|
281
|
+
else if (event?.type === 'tool/call') toolCalls += 1
|
|
282
|
+
}
|
|
283
|
+
return { turns, messages, toolCalls }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {any} content message content blocks
|
|
288
|
+
* @returns {string} concatenated text
|
|
289
|
+
*/
|
|
290
|
+
export function blocksToText(content) {
|
|
291
|
+
if (typeof content === 'string') return stripStepTags(content)
|
|
292
|
+
if (!Array.isArray(content)) return ''
|
|
293
|
+
const out = []
|
|
294
|
+
for (const block of content) {
|
|
295
|
+
if (!block || typeof block !== 'object') continue
|
|
296
|
+
if (block.type === 'text' && typeof block.text === 'string') out.push(block.text)
|
|
297
|
+
else if (block.type === 'image') out.push('[图片]')
|
|
298
|
+
else if (block.type === 'file') out.push(`[文件:${block.attachment?.name ?? 'file'}]`)
|
|
299
|
+
}
|
|
300
|
+
return stripStepTags(out.join('\n'))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** @param {string} text value */
|
|
304
|
+
function clip(text, limit) {
|
|
305
|
+
const value = String(text ?? '')
|
|
306
|
+
return value.length > limit ? `${value.slice(0, limit)}\n… (${value.length - limit} more characters)` : value
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Turn-level outline used by the share and segment dialogs: one row per turn
|
|
311
|
+
* with the user prompt, the assistant answer, and the durable sequence range.
|
|
312
|
+
*
|
|
313
|
+
* @param {readonly any[]} events durable events
|
|
314
|
+
* @returns {any[]} turns in log order
|
|
315
|
+
*/
|
|
316
|
+
export function buildOutline(events) {
|
|
317
|
+
/** @type {Map<number, any>} */
|
|
318
|
+
const turns = new Map()
|
|
319
|
+
const entryFor = turn => {
|
|
320
|
+
if (!turns.has(turn)) {
|
|
321
|
+
turns.set(turn, { turn, startSeq: Number.POSITIVE_INFINITY, endSeq: 0, userText: '', assistantText: '', time: 0, messageCount: 0 })
|
|
322
|
+
}
|
|
323
|
+
return turns.get(turn)
|
|
324
|
+
}
|
|
325
|
+
let currentTurn
|
|
326
|
+
for (const event of events) {
|
|
327
|
+
if (event?.type === 'turn/start' && typeof event.data?.turn === 'number') currentTurn = event.data.turn
|
|
328
|
+
// `user/message` events carry no turn of their own; they belong to the turn
|
|
329
|
+
// opened before them, which is what the dialog lists.
|
|
330
|
+
const turn = typeof event?.data?.turn === 'number' ? event.data.turn : currentTurn
|
|
331
|
+
if (turn === undefined || typeof event.seq !== 'number') continue
|
|
332
|
+
const entry = entryFor(turn)
|
|
333
|
+
entry.startSeq = Math.min(entry.startSeq, event.seq)
|
|
334
|
+
entry.endSeq = Math.max(entry.endSeq, event.seq)
|
|
335
|
+
entry.time = entry.time || (typeof event.time === 'number' ? event.time : 0)
|
|
336
|
+
if (event.type === 'user/message') {
|
|
337
|
+
const kind = typeof event.data?.source === 'object' && event.data.source ? event.data.source.kind : undefined
|
|
338
|
+
if (kind !== undefined && kind !== 'user') continue
|
|
339
|
+
const content = Array.isArray(event.data?.content) ? event.data.content : []
|
|
340
|
+
const text = content
|
|
341
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
342
|
+
.map(block => block.text)
|
|
343
|
+
.join('\n')
|
|
344
|
+
.trim()
|
|
345
|
+
const imageCount = content.filter(block => block?.type === 'image').length
|
|
346
|
+
if (!text && imageCount === 0) continue
|
|
347
|
+
entry.userText = entry.userText || text || `[${imageCount} 张图片]`
|
|
348
|
+
entry.messageCount += 1
|
|
349
|
+
} else if (event.type === 'assistant/message') {
|
|
350
|
+
const text = blocksToText(event.data?.message?.content).trim()
|
|
351
|
+
if (!text) continue
|
|
352
|
+
entry.assistantText = entry.assistantText || text
|
|
353
|
+
entry.messageCount += 1
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return [...turns.values()]
|
|
357
|
+
.filter(entry => entry.messageCount > 0)
|
|
358
|
+
.map(entry => ({
|
|
359
|
+
turn: entry.turn,
|
|
360
|
+
startSeq: entry.startSeq,
|
|
361
|
+
endSeq: entry.endSeq,
|
|
362
|
+
time: entry.time,
|
|
363
|
+
preview: previewText(entry.userText || entry.assistantText, 110),
|
|
364
|
+
answerPreview: previewText(entry.assistantText, 110),
|
|
365
|
+
}))
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Build the structured document both exports consume: one entry per message,
|
|
370
|
+
* with Markdown already parsed into blocks and inline runs.
|
|
371
|
+
*
|
|
372
|
+
* @param {object} input document input
|
|
373
|
+
* @param {string} input.sessionId session identity
|
|
374
|
+
* @param {string} [input.title] session title
|
|
375
|
+
* @param {readonly any[]} input.events durable events
|
|
376
|
+
* @param {number} [input.exportedAt] timestamp
|
|
377
|
+
* @param {'final' | 'all'} [input.mode] curation: last reply per turn, or every step
|
|
378
|
+
* @returns {any} document
|
|
379
|
+
*/
|
|
380
|
+
export function renderBlockDoc({ sessionId, title, events, exportedAt = Date.now(), mode = 'final' }) {
|
|
381
|
+
const entries = projectMessages(events, { toolDetails: false, toolResults: false })
|
|
382
|
+
const raw = []
|
|
383
|
+
for (const entry of entries) {
|
|
384
|
+
if (entry.role === 'user') {
|
|
385
|
+
const text = entry.text.trim()
|
|
386
|
+
const images = entry.images ?? []
|
|
387
|
+
if (!text && images.length === 0) continue
|
|
388
|
+
raw.push({ role: 'user', turn: entry.turn ?? null, blocks: text ? parseMarkdown(text) : [], images })
|
|
389
|
+
continue
|
|
390
|
+
}
|
|
391
|
+
if (entry.role === 'assistant') {
|
|
392
|
+
const text = entry.parts
|
|
393
|
+
.filter(part => part.kind === 'text')
|
|
394
|
+
.map(part => part.text)
|
|
395
|
+
.join('\n\n')
|
|
396
|
+
.trim()
|
|
397
|
+
// A step made only of tool calls has no prose: it would render as an
|
|
398
|
+
// empty "Assistant" label, which is exactly the noise this export avoids.
|
|
399
|
+
if (!text) continue
|
|
400
|
+
raw.push({ role: 'assistant', turn: entry.turn ?? null, blocks: parseMarkdown(text), images: [] })
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
// Curation: a turn is a question and its answer. With `final`, a run of
|
|
404
|
+
// assistant steps collapses to the last one — the reply — instead of shipping
|
|
405
|
+
// every "let me check…" narration that happened between tool calls.
|
|
406
|
+
const selected = mode === 'all' ? raw : keepFinalReplies(raw)
|
|
407
|
+
// One card per reply: consecutive assistant steps of the same turn merge,
|
|
408
|
+
// so a multi-step answer reads as one block instead of three fragments.
|
|
409
|
+
const messages = []
|
|
410
|
+
for (const entry of selected) {
|
|
411
|
+
const last = messages[messages.length - 1]
|
|
412
|
+
if (entry.role === 'assistant' && last?.role === 'assistant' && last.turn === entry.turn) {
|
|
413
|
+
last.blocks.push(...entry.blocks)
|
|
414
|
+
last.parts = (last.parts ?? 1) + 1
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
messages.push({ role: entry.role, turn: entry.turn, blocks: [...entry.blocks], images: entry.images ?? [], parts: 1 })
|
|
418
|
+
}
|
|
419
|
+
return { sessionId, title: title ?? null, exportedAt, mode, messages, count: selected.length }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @param {any[]} raw projected messages
|
|
424
|
+
* @returns {any[]} only the last assistant message between two user messages
|
|
425
|
+
*/
|
|
426
|
+
function keepFinalReplies(raw) {
|
|
427
|
+
const out = []
|
|
428
|
+
let pending
|
|
429
|
+
for (const entry of raw) {
|
|
430
|
+
if (entry.role === 'user') {
|
|
431
|
+
if (pending) out.push(pending)
|
|
432
|
+
pending = undefined
|
|
433
|
+
out.push(entry)
|
|
434
|
+
continue
|
|
435
|
+
}
|
|
436
|
+
pending = entry
|
|
437
|
+
}
|
|
438
|
+
if (pending) out.push(pending)
|
|
439
|
+
return out
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* One self-contained HTML page: no external CSS, fonts, or scripts, so the file
|
|
444
|
+
* can be opened, mailed, or printed to PDF anywhere. Markdown is rendered, not
|
|
445
|
+
* echoed, so headings, lists, quotes and code keep their shape.
|
|
446
|
+
*
|
|
447
|
+
* @param {object} input render input
|
|
448
|
+
* @param {string} input.sessionId session identity
|
|
449
|
+
* @param {string} [input.title] session title
|
|
450
|
+
* @param {readonly any[]} input.events durable events
|
|
451
|
+
* @param {number} [input.exportedAt] timestamp
|
|
452
|
+
* @param {'final' | 'all'} [input.mode] curation mode
|
|
453
|
+
* @returns {string} HTML document
|
|
454
|
+
*/
|
|
455
|
+
export function renderHtml({ sessionId, title, events, exportedAt = Date.now(), mode = 'final' }) {
|
|
456
|
+
return renderHtmlFromDoc(renderBlockDoc({ sessionId, title, events, exportedAt, mode }))
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Render an already-built document (the Host resolves image attachments to data
|
|
461
|
+
* URLs before this runs, so the page stays a single file).
|
|
462
|
+
*
|
|
463
|
+
* @param {any} doc block document
|
|
464
|
+
* @returns {string} HTML document
|
|
465
|
+
*/
|
|
466
|
+
export function renderHtmlFromDoc(doc) {
|
|
467
|
+
const heading = escapeHtml(doc.title?.trim() || 'Session')
|
|
468
|
+
const turns = new Set(doc.messages.map(message => message.turn).filter(turn => typeof turn === 'number'))
|
|
469
|
+
const shots = images =>
|
|
470
|
+
(images ?? []).length === 0
|
|
471
|
+
? ''
|
|
472
|
+
: `<div class="shots">${images
|
|
473
|
+
.map(
|
|
474
|
+
image =>
|
|
475
|
+
`<button type="button" class="shot-btn" aria-label="Open ${escapeHtml(image.name ?? 'screenshot')} full size"><img class="shot" src="${escapeHtml(image.dataUrl ?? '')}" alt="${escapeHtml(image.name ?? 'screenshot')}" loading="lazy"></button>`,
|
|
476
|
+
)
|
|
477
|
+
.join('')}</div>`
|
|
478
|
+
const body = doc.messages.map(message => {
|
|
479
|
+
const content = blocksToHtml(message.blocks)
|
|
480
|
+
if (message.role === 'user') {
|
|
481
|
+
return `<div class="row user"><div class="stack">${shots(message.images)}<div class="bubble">${content}</div></div></div>`
|
|
482
|
+
}
|
|
483
|
+
return `<div class="row assistant"><div class="who">Assistant${typeof message.turn === 'number' ? ` · turn ${message.turn}` : ''}</div><div class="md">${content}${shots(message.images)}</div></div>`
|
|
484
|
+
})
|
|
485
|
+
return `<!doctype html>
|
|
486
|
+
<html lang="zh">
|
|
487
|
+
<head>
|
|
488
|
+
<meta charset="utf-8">
|
|
489
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
490
|
+
<title>${heading}</title>
|
|
491
|
+
<style>
|
|
492
|
+
:root {
|
|
493
|
+
color-scheme: light dark;
|
|
494
|
+
--page:#f4f5f7; --card:#ffffff; --fg:#1b1d21; --muted:#8a8f98; --line:#eceef1;
|
|
495
|
+
--bubble:#2563eb; --bubble-fg:#ffffff; --chip:#eef1f5; --code:#f6f7f9; --quote:#f7f8fa;
|
|
496
|
+
--link:#2563eb; --accent:#2563eb;
|
|
497
|
+
}
|
|
498
|
+
@media (prefers-color-scheme: dark) {
|
|
499
|
+
:root { --page:#0f1113; --card:#17191c; --fg:#e8eaed; --muted:#9aa0a6; --line:#26292d; --bubble:#3b6fe0; --bubble-fg:#ffffff; --chip:#22262b; --code:#1b1e22; --quote:#1c1f23; --link:#7aa2ff; --accent:#7aa2ff; }
|
|
500
|
+
}
|
|
501
|
+
* { box-sizing: border-box; }
|
|
502
|
+
body { margin:0; background:var(--page); color:var(--fg); font:15px/1.75 -apple-system, "Segoe UI", "Microsoft YaHei", Roboto, "Helvetica Neue", sans-serif; }
|
|
503
|
+
main { max-width: 760px; margin: 0 auto; padding: 40px 20px 64px; }
|
|
504
|
+
header { padding: 0 6px 20px; }
|
|
505
|
+
h1 { font-size: 27px; line-height:1.35; margin:0 0 8px; letter-spacing:-.01em; }
|
|
506
|
+
.meta { color: var(--muted); font-size: 12.5px; display:flex; gap:14px; flex-wrap:wrap; }
|
|
507
|
+
.row { margin: 0 0 22px; }
|
|
508
|
+
.row.user { display:flex; justify-content:flex-end; }
|
|
509
|
+
.row.user .stack { display:flex; flex-direction:column; align-items:flex-end; gap:8px; max-width: 88%; }
|
|
510
|
+
.row.user .bubble {
|
|
511
|
+
max-width: 100%; background: var(--bubble); color: var(--bubble-fg);
|
|
512
|
+
border-radius: 14px; padding: 10px 15px; box-shadow: 0 1px 2px rgba(16,18,20,.06);
|
|
513
|
+
}
|
|
514
|
+
.shots { display: flex; flex-direction: column; gap: 8px; margin: 10px 0 0; }
|
|
515
|
+
.shot { display:block; max-width: 100%; height: auto; border-radius: 12px; border: 1px solid var(--line); }
|
|
516
|
+
.row.user .bubble p { margin: 0 0 6px; }
|
|
517
|
+
.row.user .bubble p:last-child { margin-bottom: 0; }
|
|
518
|
+
.row.user .bubble a { color:#dce8ff; }
|
|
519
|
+
.who { color: var(--muted); font-size: 12px; margin: 0 0 8px 2px; }
|
|
520
|
+
.md { background: var(--card); border-radius: 14px; padding: 14px 18px; box-shadow: 0 1px 2px rgba(16,18,20,.05); }
|
|
521
|
+
.md > :first-child { margin-top: 0; }
|
|
522
|
+
.md > :last-child { margin-bottom: 0; }
|
|
523
|
+
.md p { margin: 0 0 10px; }
|
|
524
|
+
.md h2, .md h3, .md h4, .md h5, .md h6 { margin: 18px 0 8px; line-height: 1.4; }
|
|
525
|
+
.md h2 { font-size: 19px; }
|
|
526
|
+
.md h3 { font-size: 17px; }
|
|
527
|
+
.md h4, .md h5, .md h6 { font-size: 15px; }
|
|
528
|
+
.md ul, .md ol { margin: 0 0 10px; padding-left: 22px; }
|
|
529
|
+
.md li { margin: 3px 0; }
|
|
530
|
+
.md li.depth-1 { margin-left: 18px; }
|
|
531
|
+
.md li.depth-2 { margin-left: 36px; }
|
|
532
|
+
.md li.depth-3 { margin-left: 54px; }
|
|
533
|
+
.md blockquote {
|
|
534
|
+
margin: 12px 0; padding: 10px 14px; border-left: 3px solid var(--accent);
|
|
535
|
+
background: var(--quote); border-radius: 0 10px 10px 0; color: var(--fg);
|
|
536
|
+
}
|
|
537
|
+
.md blockquote p { margin: 0 0 6px; }
|
|
538
|
+
.md blockquote p:last-child { margin-bottom: 0; }
|
|
539
|
+
.md pre {
|
|
540
|
+
margin: 12px 0; padding: 12px 14px; background: var(--code); border-radius: 10px;
|
|
541
|
+
overflow-x: auto; font: 13px/1.6 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
|
542
|
+
}
|
|
543
|
+
.md code { background: var(--chip); border-radius: 4px; padding: 1px 5px; font: 13px ui-monospace, Consolas, monospace; }
|
|
544
|
+
.md pre code { background: transparent; padding: 0; }
|
|
545
|
+
.md a { color: var(--link); }
|
|
546
|
+
.md hr { border:0; border-top:1px solid var(--line); margin: 18px 0; }
|
|
547
|
+
.md .table-wrap { margin: 12px 0; overflow-x: auto; }
|
|
548
|
+
.md table { border-collapse: collapse; width: 100%; font-size: 14px; }
|
|
549
|
+
.md th, .md td { border: 1px solid var(--line); padding: 7px 10px; text-align: left; vertical-align: top; }
|
|
550
|
+
.md th { background: var(--quote); font-weight: 600; }
|
|
551
|
+
.md strong { font-weight: 650; }
|
|
552
|
+
footer { margin-top: 36px; color: var(--muted); font-size: 12px; text-align:center; }
|
|
553
|
+
.shot-btn {
|
|
554
|
+
display: block; padding: 0; border: 0; background: transparent; cursor: zoom-in;
|
|
555
|
+
border-radius: 12px; overflow: hidden; max-width: 100%;
|
|
556
|
+
}
|
|
557
|
+
.shot-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
558
|
+
dialog.viewer {
|
|
559
|
+
border: 0; padding: 0; max-width: 96vw; max-height: 96vh; background: transparent;
|
|
560
|
+
overflow: visible;
|
|
561
|
+
}
|
|
562
|
+
dialog.viewer::backdrop { background: rgba(10, 12, 14, .82); }
|
|
563
|
+
dialog.viewer img {
|
|
564
|
+
display: block; max-width: 96vw; max-height: 92vh; margin: 0 auto;
|
|
565
|
+
border-radius: 8px; background: #fff; cursor: zoom-out;
|
|
566
|
+
}
|
|
567
|
+
dialog.viewer .hint {
|
|
568
|
+
position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
|
|
569
|
+
color: #e8eaed; font-size: 12px; opacity: .8;
|
|
570
|
+
}
|
|
571
|
+
@media print { .shot-btn { cursor: auto; } dialog.viewer { display: none; } body { background:#fff; } main { padding: 0; max-width:none; } .row, .md { break-inside: avoid; } .md { box-shadow:none; } }
|
|
572
|
+
</style>
|
|
573
|
+
</head>
|
|
574
|
+
<body>
|
|
575
|
+
<main>
|
|
576
|
+
<header>
|
|
577
|
+
<h1>${heading}</h1>
|
|
578
|
+
<div class="meta">
|
|
579
|
+
<span>${turns.size} 轮 · ${doc.count} 条消息</span>
|
|
580
|
+
<span>导出于 ${new Date(doc.exportedAt ?? Date.now()).toISOString().slice(0, 16).replace('T', ' ')}</span>
|
|
581
|
+
<span>${escapeHtml(doc.sessionId ?? '')}</span>
|
|
582
|
+
</div>
|
|
583
|
+
</header>
|
|
584
|
+
${body.join('\n')}
|
|
585
|
+
<footer>Exported from DeepSeek Harness · dsh-session-suite</footer>
|
|
586
|
+
</main>
|
|
587
|
+
<dialog class="viewer" id="shot-viewer"><img alt=""><div class="hint">点击图片或按 Esc 关闭</div></dialog>
|
|
588
|
+
<script>
|
|
589
|
+
(() => {
|
|
590
|
+
const viewer = document.getElementById('shot-viewer')
|
|
591
|
+
const full = viewer.querySelector('img')
|
|
592
|
+
for (const button of document.querySelectorAll('.shot-btn')) {
|
|
593
|
+
button.addEventListener('click', () => {
|
|
594
|
+
const source = button.querySelector('img')
|
|
595
|
+
if (!source) return
|
|
596
|
+
full.src = source.src
|
|
597
|
+
viewer.showModal()
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
viewer.addEventListener('click', () => viewer.close())
|
|
601
|
+
})()
|
|
602
|
+
</script>
|
|
603
|
+
</body>
|
|
604
|
+
</html>
|
|
605
|
+
`
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
/** @param {string} text JSON text */
|
|
610
|
+
function prettyJson(text) {
|
|
611
|
+
const parsed = safeParse(text)
|
|
612
|
+
if (parsed === undefined) return String(text ?? '')
|
|
613
|
+
try {
|
|
614
|
+
return JSON.stringify(parsed, null, 2)
|
|
615
|
+
} catch {
|
|
616
|
+
return String(text ?? '')
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** @param {string} text JSON text */
|
|
621
|
+
function safeParse(text) {
|
|
622
|
+
if (typeof text !== 'string') return text
|
|
623
|
+
try {
|
|
624
|
+
return JSON.parse(text)
|
|
625
|
+
} catch {
|
|
626
|
+
return undefined
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Build a download-friendly filename for an export.
|
|
632
|
+
*
|
|
633
|
+
* @param {string} title session title
|
|
634
|
+
* @param {string} sessionId session identity
|
|
635
|
+
* @param {string} extension file extension
|
|
636
|
+
* @returns {string} safe file name
|
|
637
|
+
*/
|
|
638
|
+
export function exportFileName(title, sessionId, extension) {
|
|
639
|
+
const base = (title ?? '').trim().replace(/[\\/:*?"<>|\r\n]+/g, ' ').replace(/\s+/g, ' ').slice(0, 60)
|
|
640
|
+
return `${base || sessionId}.${extension}`
|
|
641
|
+
}
|