Skip to content

FEAT: Add HTML conversation export to the GUI - #2437

Open
varunj-msft wants to merge 7 commits into
microsoft:mainfrom
varunj-msft:varunj-msft/10246-Export-Convos/Print-Friendly-Version-GUI
Open

FEAT: Add HTML conversation export to the GUI#2437
varunj-msft wants to merge 7 commits into
microsoft:mainfrom
varunj-msft:varunj-msft/10246-Export-Convos/Print-Friendly-Version-GUI

Conversation

@varunj-msft

Copy link
Copy Markdown
Contributor

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

Option Keeps media Print to PDF One shareable file Verdict
Self-contained HTML Images, plus playable audio and video Yes, Ctrl+P Yes Chosen
Client-side PDF library Images only, no audio or video Native Yes New dependency for worse fidelity, and you get a PDF from HTML for free
Print the live app Yes Best fidelity No file at all #root { overflow: hidden } truncates the transcript, and there is nothing to send anyone
ZIP of transcript plus files Everything, any size No Yes Needs a new dependency or a backend endpoint, and isn't readable without unpacking
Server-side exporter Everything Depends Yes MemoryExporter was deliberately removed in #1856

Frontend 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

  1. Media stored in Azure Blob Storage can't be embedded. The app's own CSP allows blob URLs
    for img-src and media-src but not connect-src, so the browser can display that media
    but 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.
  2. I also stopped the JSON export from writing attachment source URLs. That value is either a
    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, since
    there the URL is the payload rather than a pointer to it.

Tests and Documentation

Tests

  • 89 unit tests for the export module covering embedding, the placeholder path and its
    reasons, the completeness summary, HTML escaping, the size limits, and that Markdown and
    JSON output is unchanged.
  • ChatWindow tests for the new menu item, the in-flight guard, and the spinner.
  • Two new Playwright tests: one downloads the HTML and checks the print rules travel with it,
    one stubs /api/media and asserts the fetched bytes actually land in the file as base64.
  • Full frontend suite passes (1098 tests, 51 suites), tsc --noEmit and npm run lint are clean.
  • I mutation-tested the security-relevant assertions, so removing the escaping, the
    same-origin fetch guard, or the size limits each fails a test rather than passing quietly.

Documentation

doc/gui/0_gui.md now describes all three formats, when to pick each, and what HTML leaves
out. No notebooks or JupyText-paired files changed, so no JupyText run was needed.

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.
@varunj-msft varunj-msft changed the title Varunj msft/10246 export convos/print friendly version g UI FEAT: Add HTML conversation export to the GUI Aug 19, 2026
@varunj-msft

Copy link
Copy Markdown
Contributor Author

Screenshot:
pr-export

@varunj-msft

Copy link
Copy Markdown
Contributor Author

Open question

Does this match what was needed for sharing a conversation as operation "evidence"? The ticket
asks what operators prefer, so I built the readable artifact. If we want a ZIP of the raw files alongside it, I can open that as well.

Heads up that #2352 also touches ChatWindow.tsx and messageMapper.ts, so whichever merges second will need a rebase.

Comment on lines +218 to +222
/**
* 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.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this depletes sequentially the order in which attachments are processed determines which make into the report and which don't. Is that intentional

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants