Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8e15a48
FEAT: Add self-contained HTML conversation export to the GUI
varunj-msft Aug 18, 2026
b3a3553
FIX: Tighten media embedding in the HTML conversation export
varunj-msft Aug 19, 2026
a3effea
FIX: Say what the export left out, and keep links out of it
varunj-msft Aug 19, 2026
20670e7
FIX: Make the export budget bound the file it actually writes
varunj-msft Aug 19, 2026
8f33415
TEST: Make the escaped media budget test prove the accounting
varunj-msft Aug 19, 2026
8ba78db
TEST: Pin the escape on media the export fetched
varunj-msft Aug 19, 2026
821b925
TEST: Pin that media stays with its own message
varunj-msft Aug 19, 2026
dd98d07
DOC: Say what the size limit is for and what it does not stop
varunj-msft Aug 20, 2026
b029ce0
FIX: Cancel media responses the export decides not to read
varunj-msft Aug 26, 2026
f0b8b9c
Merge origin/main into the HTML conversation export branch
varunj-msft Aug 26, 2026
4a8d9fa
FIX: Strip the signed url from every copy of an attachment
varunj-msft Aug 26, 2026
74f7857
FIX: Say media is held elsewhere instead of blaming the reader
varunj-msft Aug 26, 2026
dfb22d6
TEST: Pin the display piece sanitizer when there is no flat list
varunj-msft Aug 26, 2026
9471d15
DOC: Say what the transcript leaves out and where the bytes come from
varunj-msft Aug 26, 2026
61a832a
FIX: Keep exported media where the conversation put it
varunj-msft Aug 26, 2026
cb3d1fb
FIX: Measure a data uri by the bytes it really carries
varunj-msft Aug 26, 2026
13f6bf8
Merge branch 'main' into varunj-msft/10246-Export-Convos/Print-Friend…
varunj-msft Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,19 @@ Click the panel toggle in the ribbon to open the conversations sidebar. This pan

#### Exporting a Conversation

Click the **Export** button in the ribbon to download the conversation that is currently displayed. Two formats are offered from the button's menu:
Click the **Export** button in the ribbon to download the conversation that is currently displayed. Three formats are offered from the button's menu:

- **Markdown (`.md`):** A human-readable transcript with each message labeled by role. Best for reading, sharing, or pasting into reports.
- **JSON (`.json`):** A structured record of the conversation for tooling and further processing.
- **HTML (`.html`):** A single self-contained page with the images, audio, and video inside the file itself. Best for sharing a conversation as evidence, and for printing — open it and use your browser's **Print → Save as PDF**.

The export runs entirely in your browser and captures exactly what is shown in the chat, including the system prompt shown in the banner — no data is sent to the server. Export stays available for read-only historical conversations, and is disabled while a conversation is empty, still loading, or sending. The button is disabled until there is at least one user or model message to export.
Every format includes the whole conversation as shown in the chat, including the system prompt shown in the banner. Scores are the exception: they are kept in the JSON export but are not written into the Markdown or HTML transcript.

Markdown records the names of attachments but never the media itself. JSON keeps media that is already inline, drops the source link for everything else, and so cannot be relied on to carry pictures either. HTML is the format to pick when the media matters. It puts each attachment it can read into the page, and lists the rest by name with the reason it was left out — media that sits on another host, which is where a deployment backed by cloud storage keeps it, cannot be read by the page and is listed as kept in remote storage; an attachment that is too large on its own is skipped; and one that no longer fits in the page is marked as having no room left. The page keeps filling after that, so an attachment later in the conversation that still fits can make it in. Files that are not images, audio, or video are never embedded. The count of what was and was not included is printed at the top of the exported page, so an incomplete export is never mistaken for a complete one. Attachment source links are deliberately left out of every export.

Exporting runs in your browser and sends nothing to the server. HTML is the one exception: it reads locally stored media back from the server so it can embed it.

Export stays available for read-only historical conversations, and is disabled while a conversation is empty, still loading, or sending. The button is disabled until there is at least one user or model message to export.

> **Note:** Exported files can contain adversarial prompts, model responses, and other sensitive material. Store and share them responsibly.

Expand Down
108 changes: 108 additions & 0 deletions frontend/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,4 +956,112 @@ test.describe("Conversation export", () => {
expect(content).toContain("Export me please");
expect(content).toContain("Mock response for: Export me please");
});

test("downloads the displayed conversation as a self-contained HTML transcript", async ({ page }) => {
const { filename, content } = await triggerExport(page, "export-html-item");

expect(filename).toMatch(/^copyrit-conversation-e2e-conv-001-.*\.html$/);
expect(content).toContain("<h1>CoPyRIT conversation export</h1>");
expect(content).toContain("Export me please");
expect(content).toContain("Mock response for: Export me please");
// Print rules travel with the file so it can be saved as PDF as-is.
expect(content).toContain("@media print");
});
});

test.describe("Conversation export with media", () => {
const setupImageMock = buildModalityMock(
[
{
id: "img-export-1",
original_value_data_type: "text",
converted_value_data_type: "image_path",
original_value: "generated image",
converted_value: WIDE_IMAGE_DATA_URI,
converted_value_mime_type: "image/svg+xml",
scores: [],
response_error: "none",
},
],
"e2e-export-media-conv",
);

test("embeds the image in the HTML export so the file stands alone", async ({ page }) => {
await setupImageMock(page);
await page.goto("/");
await activateMockTarget(page);

await page.getByRole("textbox").fill("Generate an image");
await page.getByRole("button", { name: /send/i }).click();
await expect(page.locator('img:not([alt="Co-PyRIT Logo"])')).toBeVisible({ timeout: 10000 });

const exportButton = page.getByTestId("export-conversation-btn");
await expect(exportButton).toBeEnabled();
const downloadPromise = page.waitForEvent("download");
await exportButton.click();
await page.getByTestId("export-html-item").click();

const download = await downloadPromise;
const filePath = await download.path();
expect(filePath).not.toBeNull();
const content = readFileSync(filePath, "utf-8");

expect(download.suggestedFilename()).toMatch(/\.html$/);
expect(content).toContain("<img src=\"data:image/svg+xml");
});

test("embeds media it has to fetch back from the media endpoint", async ({ page }) => {
// A 1x1 PNG, served by a stubbed /api/media route so the export exercises
// the fetch → blob → base64 path rather than an already-inline data URI.
const pngBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGIAAgAABQABDQotsgAAAABJRU5ErkJggg==";
const mediaPath = "/api/media?path=/dbdata/prompt-memory-entries/images/e2e.png";

await buildModalityMock(
[
{
id: "img-fetch-1",
original_value_data_type: "text",
converted_value_data_type: "image_path",
original_value: "generated image",
converted_value: mediaPath,
converted_value_url: mediaPath,
converted_value_mime_type: "image/png",
scores: [],
response_error: "none",
},
],
"e2e-export-fetch-conv",
)(page);
await page.route("**/api/media**", async (route) => {
await route.fulfill({
status: 200,
headers: { "content-type": "image/png" },
body: Buffer.from(pngBase64, "base64"),
});
});

await page.goto("/");
await activateMockTarget(page);

await page.getByRole("textbox").fill("Generate an image");
await page.getByRole("button", { name: /send/i }).click();
await expect(page.locator('img:not([alt="Co-PyRIT Logo"])')).toBeVisible({ timeout: 10000 });

const exportButton = page.getByTestId("export-conversation-btn");
await expect(exportButton).toBeEnabled();
const downloadPromise = page.waitForEvent("download");
await exportButton.click();
await page.getByTestId("export-html-item").click();

const download = await downloadPromise;
const filePath = await download.path();
expect(filePath).not.toBeNull();
const content = readFileSync(filePath, "utf-8");

expect(content).toContain(`data:image/png;base64,${pngBase64}`);
expect(content).toContain("Attachments: 1 of 1 embedded");
// The path the bytes came from must not travel with the file.
expect(content).not.toContain("/api/media");
});
});
80 changes: 79 additions & 1 deletion frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import ChatWindow from "./ChatWindow";
Expand Down Expand Up @@ -49,6 +49,7 @@ jest.mock("../../services/api", () => ({
jest.mock("../../utils/messageMapper", () => ({
buildMessagePieces: jest.fn(),
backendMessagesToFrontend: jest.fn(),
fileToBase64: jest.fn(),
}));

const mockedAttacksApi = attacksApi as jest.Mocked<typeof attacksApi>;
Expand Down Expand Up @@ -3633,6 +3634,83 @@ describe("ChatWindow Integration", () => {
expect(mockedAttacksApi.getMessages.mock.calls.length).toBe(callsBefore);
});

it("exports the displayed conversation as a self-contained HTML transcript", async () => {
const user = userEvent.setup();
await renderWithLoadedConversation();
const callsBefore = mockedAttacksApi.getMessages.mock.calls.length;
const { getDownloadAnchor } = spyOnDownloadAnchor();

await user.click(screen.getByRole("button", { name: /export conversation/i }));
await user.click(screen.getByRole("menuitem", { name: /export as html/i }));

await waitFor(() => expect(URL.createObjectURL as jest.Mock).toHaveBeenCalled());
const blob = (URL.createObjectURL as jest.Mock).mock.calls[0][0] as Blob;
expect(blob.type).toBe("text/html;charset=utf-8");
expect(getDownloadAnchor().download).toMatch(/^copyrit-conversation-conv-1-.*\.html$/);
// WYSIWYG: export serializes in-state messages and makes no extra API call.
expect(mockedAttacksApi.getMessages.mock.calls.length).toBe(callsBefore);
});

it("shows progress and ignores a second request while an export is in flight", async () => {
const user = userEvent.setup();
const messagesWithMedia: Message[] = [
...mockMessages,
{
role: "assistant",
content: "",
timestamp: new Date().toISOString(),
attachments: [
{
type: "image",
name: "r.png",
url: "blob:http://localhost/pending",
mimeType: "image/png",
file: new File(["x"], "r.png", { type: "image/png" }),
},
],
},
];
mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
mockedMapper.backendMessagesToFrontend.mockReturnValue(messagesWithMedia);
// Hold the media read open so the export stays in flight across clicks.
let releaseMedia: (value: string) => void = () => {};
mockedMapper.fileToBase64.mockImplementation(
() => new Promise<string>((resolve) => { releaseMedia = resolve; })
);
render(
<TestWrapper>
<ChatWindow
{...defaultProps}
attackResultId="ar-1"
conversationId="conv-1"
activeConversationId="conv-1"
/>
</TestWrapper>
);
await waitFor(() =>
expect(screen.getByRole("button", { name: /export conversation/i })).toBeEnabled()
);
const { clickSpy } = spyOnDownloadAnchor();

await user.click(screen.getByRole("button", { name: /export conversation/i }));
await user.click(screen.getByTestId("export-html-item"));
const exportButton = screen.getByRole("button", { name: /export conversation/i });
await waitFor(() => expect(within(exportButton).getByRole("progressbar")).toBeInTheDocument());

await user.click(screen.getByRole("button", { name: /export conversation/i }));
await user.click(screen.getByTestId("export-html-item"));

// The menu shows the export is already running, and the guard stops a
// second one from starting even if the click lands anyway.
expect(screen.getByTestId("export-html-item")).toHaveAttribute("aria-disabled", "true");
expect(screen.getByTestId("export-markdown-item")).toHaveAttribute("aria-disabled", "true");
expect(mockedMapper.fileToBase64).toHaveBeenCalledTimes(1);

releaseMedia("eA==");
await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1));
await waitFor(() => expect(within(exportButton).queryByRole("progressbar")).not.toBeInTheDocument());
});

it("exports the displayed conversation id when it differs from the attack's main conversation", async () => {
const user = userEvent.setup();
// Viewing a branch: activeConversationId (displayed) differs from the
Expand Down
34 changes: 29 additions & 5 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
MenuPopover,
MenuTrigger,
mergeClasses,
Spinner,
Switch,
Text,
Tooltip,
Expand Down Expand Up @@ -130,6 +131,8 @@ export default function ChatWindow({
const [loadedConversationId, setLoadedConversationId] = useState<string | null>(null)
const isSending = activeConversationId ? sendingConversations.has(activeConversationId) : Boolean(sendingConversations.size)
const [isPanelOpen, setIsPanelOpen] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const isExportingRef = useRef(false)
const [isNarrowScreen, setIsNarrowScreen] = useState(matchesNarrowScreen)
const [isConverterPanelOpen, setIsConverterPanelOpen] = useState(false)
// Conversation-wide preference for rendering message text as Markdown.
Expand Down Expand Up @@ -724,8 +727,22 @@ export default function ChatWindow({
!isLoadingMessages &&
!awaitingConversationLoad

const handleExport = (format: ExportFormat) => {
exportConversation({ messages, conversationId: activeConversationId ?? conversationId, format })
const handleExport = async (format: ExportFormat) => {
// A ref, not the state flag: two clicks in the same tick would both read
// the pre-render value and start duplicate exports.
if (isExportingRef.current) {
return
}
isExportingRef.current = true
setIsExporting(true)
try {
await exportConversation({ messages, conversationId: activeConversationId ?? conversationId, format })
} catch (err) {
console.error('Failed to export conversation:', err)
} finally {
isExportingRef.current = false
setIsExporting(false)
}
}

return (
Expand Down Expand Up @@ -771,7 +788,7 @@ export default function ChatWindow({
<Button
appearance="subtle"
className={styles.ribbonAction}
icon={<ArrowDownloadRegular />}
icon={isExporting ? <Spinner size="tiny" /> : <ArrowDownloadRegular />}
disabled={!canExportConversation}
aria-label="Export conversation"
data-testid="export-conversation-btn"
Expand All @@ -780,12 +797,19 @@ export default function ChatWindow({
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem onClick={() => handleExport('markdown')} data-testid="export-markdown-item">
<MenuItem
onClick={() => handleExport('markdown')}
disabled={isExporting}
data-testid="export-markdown-item"
>
Export as Markdown (.md)
</MenuItem>
<MenuItem onClick={() => handleExport('json')} data-testid="export-json-item">
<MenuItem onClick={() => handleExport('json')} disabled={isExporting} data-testid="export-json-item">
Export as JSON (.json)
</MenuItem>
<MenuItem onClick={() => handleExport('html')} disabled={isExporting} data-testid="export-html-item">
Export as HTML (.html)
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
Expand Down
Loading
Loading