diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 60d96adc2f..81a24d1039 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -74,12 +74,17 @@ 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. + +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 stored in Azure Blob storage cannot be read by the browser, 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. Source links are deliberately left out of every export. + +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..107927320e 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -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("

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(" { + // 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"); + }); }); diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index ad3da9d594..d6cd0ab1ec 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -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"; @@ -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; @@ -3507,6 +3508,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((resolve) => { releaseMedia = resolve; }) + ); + render( + + + + ); + 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 diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 1bcd7cf34d..65bd48a224 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,8 @@ 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 isExportingRef = useRef(false) const [isNarrowScreen, setIsNarrowScreen] = useState(matchesNarrowScreen) const [isConverterPanelOpen, setIsConverterPanelOpen] = useState(false) // Conversation-wide preference for rendering message text as Markdown. @@ -715,8 +718,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 ( @@ -762,7 +779,7 @@ export default function ChatWindow({