FEAT: Add HTML conversation export to the GUI - #2437
Conversation
The Markdown and JSON exports carry only attachment names, so a conversation whose answer is an image exports as an empty block and the evidence is lost. Operators were screenshotting the screen instead. Adds a third Export format that writes a single HTML transcript with the media embedded as data URIs, so the file opens offline and prints to PDF as-is. Attachments the browser cannot read are named rather than embedded, and their source URLs are deliberately left out so no signed storage link is written into a shared file. No new dependencies, and no backend or CSP changes.
Review found four ways the exported file could hold something other than the media it claims: - Any same-origin URL was fetched, but the single-page app answers unmatched paths with its own index.html, so that HTML could be embedded as if it were the image. Only the media endpoint is read now. - Already-inline data URIs skipped the size limit the docs promise, so an oversized attachment was still embedded. - File attachments became clickable data links, which put active content one click away in a file meant to be shared. Only images, audio, and video are embedded; everything else is named. - The export guard read React state, so two quick clicks could both start. Also closes the test holes behind these: the escape guarding the media source attribute, the export gate, and the progress indicator all had passing tests that survived deleting the code they covered.
The HTML export could hand over a file full of named placeholders without ever saying that anything was missing, so the page now states how many attachments it embedded and why the rest were left out, and each placeholder gives its own reason. Reading the media is now sequential and shares repeated sources, a whole document has a size budget on top of the per-attachment cap, and an oversized response is abandoned on its declared length instead of being downloaded and thrown away. The cap now measures every form a data URI can take, so a percent-encoded payload can no longer slip past it. The JSON export no longer writes attachment source URLs, which were either a signed storage link or an absolute path on the operator's machine.
The document budget charged the decoded size of each attachment while the page carries the encoded, escaped text, and a repeated attachment was charged once no matter how many times it was written. Both let the file grow well past the limit; a measured case produced 80 MB against a 50 MB budget. Media is now resolved per place in the document rather than per object, and each place pays for the text it adds. An attachment that no longer fits now says there is no room left in the file rather than claiming to be too large on its own, so each reason is the true one. The summary counts places too, so it can no longer disagree with the page it describes. The uppercase data URI test was sized so it passed either way; it now fails if the marker stops being matched case-insensitively.
The test held six data URIs that were far under the limit either way, so it passed whether the budget counted the text written or the text before escaping. It now sizes them so the two readings disagree: as raw text they all fit, but escaped they do not, and it asserts that some are left out for want of room.
The refactor moved escaping from the one place that rendered a source to the two places that build one, and only the inline one was covered. The fetched path is the only one that puts a mime type into the src attribute, so a hostile mime type there could break out of it. Deleting that escape now fails a test.
Resolution and rendering both walk the settled messages, so dropping a loading placeholder has to shift both or neither. Nothing held that in place, and getting it wrong would print a count that disagreed with the page beneath it. The guide also now describes the case where the page has taken as much media as it can hold.
|
Open question Does this match what was needed for sharing a conversation as operation "evidence"? The ticket Heads up that #2352 also touches |
| /** | ||
| * State, in the document itself, how much of the conversation made it in. An | ||
| * incomplete export must never look complete to whoever it is handed to, so | ||
| * every attachment left out is counted and explained here as well as in place. | ||
| */ |
There was a problem hiding this comment.
Should there be a threshold for which exporting fails, e.g., if XX% >= threshold% of the document fails to render?
| const perMessage: ResolvedMedia['perMessage'] = [] | ||
| const occurrences: ResolvedAttachment[] = [] | ||
| const seen = new Map<string, ResolvedAttachment>() | ||
| let remaining = MAX_TOTAL_INLINE_CHARACTERS |
There was a problem hiding this comment.
Can we document why the limit is 50 MiB for the total media budget (or change it if that's not enough)
| ? { source: null, reason: 'no-room' } | ||
| : outcome | ||
| if (charged.source !== null) { | ||
| remaining -= charged.source.length |
There was a problem hiding this comment.
Since this depletes sequentially the order in which attachments are processed determines which make into the report and which don't. Is that intentional

Description
The Export button offers Markdown and JSON today. Both list attachment names but not the
attachments themselves, so exporting a conversation that contains images or audio quietly
loses them. Operators work around this by screenshotting the screen one frame at a time.
This adds a third option to the same menu: Export as HTML (.html). It is a single
self-contained page with the media embedded in it, so it opens offline on any machine and
prints straight to PDF with Ctrl+P. That covers both halves of the request: something you
can attach to a report or an email, and something you can print.
Follow-up to #2259, which added the Markdown/JSON export.
Why HTML and not the alternatives
#root { overflow: hidden }truncates the transcript, and there is nothing to send anyoneMemoryExporterwas deliberately removed in #1856Frontend only. No new npm dependencies, no backend changes, no CSP changes.
What the file contains
Everything the chat shows, including the system prompt from the banner, plus any attachment
the browser can read, inlined as a
data:URI.Anything it can't embed is listed by name with the reason, and the page states the count at
the top (for example
Attachments: 2 of 3 embedded,1 not embedded (1 could not be read)).An incomplete export should never look like a complete one when someone else opens it.
Two things worth calling out
for
img-srcandmedia-srcbut notconnect-src, so the browser can display that mediabut can't read its bytes. Those attachments become named placeholders and the SAS URL is
deliberately not written into the file. Happy to file a follow-up for a same-origin media
proxy if we want prod parity.
SAS-signed storage link or an absolute path on the operator's machine, and neither belongs
in a file people email around. Same reasoning as BUG: Stop leaking absolute media paths and SAS tokens in Attack History 'Last Message' #1865. Inline
data:values stay, sincethere the URL is the payload rather than a pointer to it.
Tests and Documentation
Tests
reasons, the completeness summary, HTML escaping, the size limits, and that Markdown and
JSON output is unchanged.
one stubs
/api/mediaand asserts the fetched bytes actually land in the file as base64.tsc --noEmitandnpm run lintare clean.same-origin fetch guard, or the size limits each fails a test rather than passing quietly.
Documentation
doc/gui/0_gui.mdnow describes all three formats, when to pick each, and what HTML leavesout. No notebooks or JupyText-paired files changed, so no JupyText run was needed.