@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/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
+ }