From 1ae09347c412464229d17c64a8cd1a3a3dc8329d Mon Sep 17 00:00:00 2001 From: varunj-msft Date: Wed, 22 Jul 2026 21:14:12 +0000 Subject: [PATCH 1/3] FEAT: Add conversation export button to GUI chat view Add an Export control to the CoPyRIT chat ribbon that downloads the currently displayed conversation as Markdown or JSON. The file is serialized client-side from the in-view messages (WYSIWYG), including the system prompt shown in the banner; no conversation data is sent to the server. - conversationExport.ts: pure Markdown/JSON serializers, filename builder, and Blob-based download helper - ChatWindow.tsx: Export menu button (Markdown / JSON) enabled only for a viewable, settled conversation - Unit tests plus a real-browser Playwright E2E; GUI docs updated --- doc/gui/0_gui.md | 11 + frontend/e2e/chat.spec.ts | 63 +++ .../src/components/Chat/ChatWindow.test.tsx | 180 +++++++ frontend/src/components/Chat/ChatWindow.tsx | 47 +- frontend/src/utils/conversationExport.test.ts | 444 ++++++++++++++++++ frontend/src/utils/conversationExport.ts | 201 ++++++++ 6 files changed, 945 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/conversationExport.test.ts create mode 100644 frontend/src/utils/conversationExport.ts diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index e26f39c078..e2b6c0ae12 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -72,6 +72,17 @@ Each assistant message has four action buttons: Click the panel toggle in the ribbon to open the conversations sidebar. This panel shows all conversations within the current attack, including message counts and last-message previews. You can switch between conversations, create new ones, and promote a conversation to be the "main" conversation. +#### 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: + +- **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. + +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 message to export. + +> **Note:** Exported files can contain adversarial prompts, model responses, and other sensitive material. Store and share them responsibly. + #### Labels The labels bar in the ribbon displays the current attack's labels (e.g., `operator`, `operation`). Labels are key-value pairs that help organize and filter attacks. You can add, edit, and remove labels inline. The `operator` and `operation` labels are required and cannot be removed. diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index 3a13fdb8a0..87879faeb4 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { test, expect, type Page } from "@playwright/test"; import { makeTarget } from "./_targets"; @@ -894,3 +895,65 @@ test.describe("Target type scenarios", () => { await expect(badge).toContainText(/dall-e-3/); }); }); + +test.describe("Conversation export", () => { + test.beforeEach(async ({ page }) => { + await mockBackendAPIs(page); + await page.goto("/"); + await activateMockTarget(page); + + // A viewable conversation must be on screen before export is enabled. + await page.getByRole("textbox").fill("Export me please"); + await page.getByRole("button", { name: /send/i }).click(); + await expect( + page.getByText("Mock response for: Export me please"), + ).toBeVisible({ timeout: 10000 }); + }); + + // Trigger the export menu, pick a format, and return the real downloaded file. + // Exercises the browser download path (Blob -> object URL -> anchor click) + // that jsdom mocks out in the unit tests. + async function triggerExport( + page: Page, + itemTestId: string, + ): Promise<{ filename: string; content: string }> { + const exportButton = page.getByTestId("export-conversation-btn"); + await expect(exportButton).toBeEnabled(); + + const downloadPromise = page.waitForEvent("download"); + await exportButton.click(); + await page.getByTestId(itemTestId).click(); + + const download = await downloadPromise; + const filePath = await download.path(); + expect(filePath).not.toBeNull(); + return { + filename: download.suggestedFilename(), + content: readFileSync(filePath, "utf-8"), + }; + } + + test("downloads the displayed conversation as Markdown", async ({ page }) => { + const { filename, content } = await triggerExport(page, "export-markdown-item"); + + expect(filename).toMatch(/^copyrit-conversation-e2e-conv-001-.*\.md$/); + expect(content).toContain("# CoPyRIT conversation export"); + expect(content).toContain("Export me please"); + expect(content).toContain("Mock response for: Export me please"); + }); + + test("downloads the displayed conversation as JSON", async ({ page }) => { + const { filename, content } = await triggerExport(page, "export-json-item"); + + expect(filename).toMatch(/^copyrit-conversation-e2e-conv-001-.*\.json$/); + + const parsed = JSON.parse(content) as { + conversation_id: string; + messages: unknown[]; + }; + expect(parsed.conversation_id).toBe("e2e-conv-001"); + expect(parsed.messages.length).toBeGreaterThanOrEqual(2); + expect(content).toContain("Export me please"); + expect(content).toContain("Mock response for: Export me please"); + }); +}); diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index a0b9e7e9fc..6e80cddb1d 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -3185,4 +3185,184 @@ describe("ChatWindow Integration", () => { expect(screen.queryByTestId("converted-value-input")).not.toBeInTheDocument(); }); }); + + // ----------------------------------------------------------------------- + // Conversation export + // ----------------------------------------------------------------------- + + describe("conversation export", () => { + function spyOnDownloadAnchor(): { clickSpy: jest.Mock; getDownloadAnchor: () => HTMLAnchorElement } { + const anchors: HTMLAnchorElement[] = []; + const clickSpy = jest.fn(); + const origCreateElement = document.createElement.bind(document); + jest.spyOn(document, "createElement").mockImplementation((tag: string) => { + const el = origCreateElement(tag); + if (tag === "a") { + anchors.push(el as HTMLAnchorElement); + jest.spyOn(el as HTMLAnchorElement, "click").mockImplementation(clickSpy); + } + return el; + }); + return { clickSpy, getDownloadAnchor: () => anchors.find((a) => a.download) as HTMLAnchorElement }; + } + + async function renderWithLoadedConversation( + props: Record = {} + ): Promise { + mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] }); + mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages); + render( + + + + ); + await waitFor(() => + expect(screen.getByRole("button", { name: /export conversation/i })).toBeEnabled() + ); + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("shows an export button in the ribbon", () => { + render( + + + + ); + expect(screen.getByRole("button", { name: /export conversation/i })).toBeInTheDocument(); + }); + + it("disables export when the conversation is empty", () => { + render( + + + + ); + expect(screen.getByRole("button", { name: /export conversation/i })).toBeDisabled(); + }); + + it("enables export once a conversation with messages loads", async () => { + await renderWithLoadedConversation(); + expect(screen.getByRole("button", { name: /export conversation/i })).toBeEnabled(); + }); + + it("keeps export disabled when every loaded message is a loading placeholder", async () => { + mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] }); + mockedMapper.backendMessagesToFrontend.mockReturnValue([ + { role: "assistant", content: "", timestamp: "2026-07-22T02:30:07.000Z", isLoading: true }, + ]); + render( + + + + ); + await waitFor(() => { + expect(mockedMapper.backendMessagesToFrontend).toHaveBeenCalled(); + }); + // length > 0 but no non-loading message => export must stay disabled. + expect(screen.getByRole("button", { name: /export conversation/i })).toBeDisabled(); + }); + + it("opens a menu with Markdown and JSON options", async () => { + const user = userEvent.setup(); + await renderWithLoadedConversation(); + + await user.click(screen.getByRole("button", { name: /export conversation/i })); + + expect(screen.getByRole("menuitem", { name: /export as markdown/i })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { name: /export as json/i })).toBeInTheDocument(); + }); + + it("downloads Markdown when the Markdown option is clicked", async () => { + const user = userEvent.setup(); + await renderWithLoadedConversation(); + const { clickSpy, getDownloadAnchor } = spyOnDownloadAnchor(); + + await user.click(screen.getByRole("button", { name: /export conversation/i })); + await user.click(screen.getByRole("menuitem", { name: /export as markdown/i })); + + const blob = (URL.createObjectURL as jest.Mock).mock.calls[0][0] as Blob; + expect(blob.type).toBe("text/markdown;charset=utf-8"); + expect(getDownloadAnchor().download).toMatch(/^copyrit-conversation-conv-1-.*\.md$/); + expect(clickSpy).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url"); + }); + + it("downloads JSON without re-fetching the conversation", 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 json/i })); + + const blob = (URL.createObjectURL as jest.Mock).mock.calls[0][0] as Blob; + expect(blob.type).toBe("application/json;charset=utf-8"); + expect(getDownloadAnchor().download).toMatch(/^copyrit-conversation-conv-1-.*\.json$/); + // 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 + // attack's main conversationId. handleExport uses activeConversationId. + await renderWithLoadedConversation({ + conversationId: "conv-main", + activeConversationId: "conv-branch", + }); + const { getDownloadAnchor } = spyOnDownloadAnchor(); + + await user.click(screen.getByRole("button", { name: /export conversation/i })); + await user.click(screen.getByRole("menuitem", { name: /export as markdown/i })); + + expect(getDownloadAnchor().download).toMatch(/^copyrit-conversation-conv-branch-.*\.md$/); + }); + + it("allows exporting a read-only historical conversation", async () => { + const user = userEvent.setup(); + // Operator lock: the loaded attack belongs to a different operator. + await renderWithLoadedConversation({ attackLabels: { operator: "someone-else" } }); + const { clickSpy } = spyOnDownloadAnchor(); + + const exportButton = screen.getByRole("button", { name: /export conversation/i }); + expect(exportButton).toBeEnabled(); + + await user.click(exportButton); + await user.click(screen.getByRole("menuitem", { name: /export as markdown/i })); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it("disables export while a message is being sent", async () => { + const user = userEvent.setup(); + mockedMapper.buildMessagePieces.mockResolvedValue([ + { data_type: "text", original_value: "hi" }, + ]); + // addMessage never resolves, so the conversation stays in the sending state. + mockedAttacksApi.addMessage.mockImplementation(() => new Promise(() => {})); + await renderWithLoadedConversation(); + + await user.type(screen.getByRole("textbox"), "hi"); + await user.click(screen.getByRole("button", { name: /send/i })); + + await waitFor(() => + expect(screen.getByRole("button", { name: /export conversation/i })).toBeDisabled() + ); + }); + }); }); diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 844c01d0b9..3f4b6101fd 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -2,6 +2,11 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { Button, Drawer, + Menu, + MenuItem, + MenuList, + MenuPopover, + MenuTrigger, mergeClasses, Switch, Text, @@ -9,7 +14,7 @@ import { useRestoreFocusSource, useRestoreFocusTarget, } from '@fluentui/react-components' -import { AddRegular, PanelRightRegular } from '@fluentui/react-icons' +import { AddRegular, ArrowDownloadRegular, PanelRightRegular } from '@fluentui/react-icons' import MessageList from './MessageList' import SystemPromptBanner from './SystemPromptBanner' import ChatInputArea from './ChatInputArea' @@ -23,6 +28,8 @@ import type { ChatInputAreaHandle } from './ChatInputArea' import { attacksApi } from '../../services/api' import { toApiError } from '../../services/errors' import { buildMessagePieces, backendMessagesToFrontend } from '../../utils/messageMapper' +import { exportConversation } from '../../utils/conversationExport' +import type { ExportFormat } from '../../utils/conversationExport' import type { Message, MessageAttachment, TargetInstance, TargetInfo } from '../../types' import { targetInfoMatchesTarget } from '../../utils/targetIdentity' import type { ViewName } from '../Sidebar/Navigation' @@ -617,6 +624,20 @@ export default function ChatWindow({ const systemMessage = messages.find(message => message.role === 'system') + // Export is available whenever there is a stable, viewable conversation: + // not while empty, loading, or mid-send. Read-only / operator-lock / + // cross-target states do not block export. + const canExportConversation = + messages.some((message) => !message.isLoading) && + !isSending && + !isLoadingAttack && + !isLoadingMessages && + !awaitingConversationLoad + + const handleExport = (format: ExportFormat) => { + exportConversation({ messages, conversationId: activeConversationId ?? conversationId, format }) + } + return (

Chat

@@ -654,6 +675,30 @@ export default function ChatWindow({ data-testid="global-markdown-toggle" /> + + + +