diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index e26f39c078..ceace0a5ad 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 user or model 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/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index 5c3dd881fb..ed4f096fc3 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -413,6 +413,7 @@ test.describe("Mobile touch targets", () => { page.locator( [ '[data-testid="labels-icon-btn"]', + '[data-testid="export-conversation-btn"]', '[data-testid="toggle-panel-btn"]', '[data-testid="new-attack-btn"]', '[aria-label="Attach files"]', diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index a0b9e7e9fc..7eafd78ef6 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -3185,4 +3185,206 @@ 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("keeps export disabled when the only loaded message is a system prompt", async () => { + mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] }); + mockedMapper.backendMessagesToFrontend.mockReturnValue([ + { role: "system", content: "You are a pirate.", timestamp: "2026-07-22T02:30:07.000Z" }, + ]); + render( + + + + ); + await waitFor(() => { + expect(mockedMapper.backendMessagesToFrontend).toHaveBeenCalled(); + }); + // A lone system prompt renders only in the banner, so export stays 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..f7428b4af5 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,21 @@ 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. A lone system prompt (rendered only + // in the banner, not the chat body) does not count as an exportable message. + // Read-only / operator-lock / cross-target states do not block export. + const canExportConversation = + messages.some((message) => !message.isLoading && message.role !== 'system') && + !isSending && + !isLoadingAttack && + !isLoadingMessages && + !awaitingConversationLoad + + const handleExport = (format: ExportFormat) => { + exportConversation({ messages, conversationId: activeConversationId ?? conversationId, format }) + } + return (

Chat

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