From 8e15a4813855c9fcc49b35c5761a24e09e8d7b6a Mon Sep 17 00:00:00 2001 From: varunj-msft Date: Tue, 18 Aug 2026 23:19:20 +0000 Subject: [PATCH 1/8] FEAT: Add self-contained HTML conversation export to the GUI 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. --- doc/gui/0_gui.md | 5 +- frontend/e2e/chat.spec.ts | 53 ++++ .../src/components/Chat/ChatWindow.test.tsx | 17 ++ frontend/src/components/Chat/ChatWindow.tsx | 21 +- frontend/src/utils/conversationExport.test.ts | 284 +++++++++++++++++- frontend/src/utils/conversationExport.ts | 241 ++++++++++++++- frontend/src/utils/messageMapper.ts | 4 +- 7 files changed, 608 insertions(+), 17 deletions(-) diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 60d96adc2f..856adfb10b 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -74,12 +74,13 @@ 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 embedded in 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. +Markdown and JSON are built entirely in your browser with no network requests. The HTML export additionally re-reads each attachment from the backend — a read-only request for media that is already shown on screen — so it can embed it in the file; no conversation content is uploaded. Attachments the browser cannot read, such as media held in Azure Blob storage or files larger than 10 MB, are listed by name instead of embedded, and their source links are deliberately left out of the file. All formats capture exactly what is shown in the chat, including the system prompt shown in the banner. 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. diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index 87879faeb4..ec4358f471 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -956,4 +956,57 @@ 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("

CoPyRIT conversation export

"); + 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(" { 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("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 diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 1bcd7cf34d..0beb6645ea 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -9,6 +9,7 @@ import { MenuPopover, MenuTrigger, mergeClasses, + Spinner, Switch, Text, Tooltip, @@ -126,6 +127,7 @@ export default function ChatWindow({ const [loadedConversationId, setLoadedConversationId] = useState(null) const isSending = activeConversationId ? sendingConversations.has(activeConversationId) : Boolean(sendingConversations.size) const [isPanelOpen, setIsPanelOpen] = useState(false) + const [isExporting, setIsExporting] = useState(false) const [isNarrowScreen, setIsNarrowScreen] = useState(matchesNarrowScreen) const [isConverterPanelOpen, setIsConverterPanelOpen] = useState(false) // Conversation-wide preference for rendering message text as Markdown. @@ -715,8 +717,18 @@ export default function ChatWindow({ !isLoadingMessages && !awaitingConversationLoad - const handleExport = (format: ExportFormat) => { - exportConversation({ messages, conversationId: activeConversationId ?? conversationId, format }) + const handleExport = async (format: ExportFormat) => { + if (isExporting) { + return + } + setIsExporting(true) + try { + await exportConversation({ messages, conversationId: activeConversationId ?? conversationId, format }) + } catch (err) { + console.error('Failed to export conversation:', err) + } finally { + setIsExporting(false) + } } return ( @@ -762,7 +774,7 @@ export default function ChatWindow({