Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 11 additions & 0 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions frontend/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { test, expect, type Page } from "@playwright/test";
import { makeTarget } from "./_targets";

Expand Down Expand Up @@ -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");
});
});
1 change: 1 addition & 0 deletions frontend/e2e/touch-targets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]',
Expand Down
202 changes: 202 additions & 0 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}
): Promise<void> {
mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages);
render(
<TestWrapper>
<ChatWindow
{...defaultProps}
attackResultId="ar-1"
conversationId="conv-1"
activeConversationId="conv-1"
{...props}
/>
</TestWrapper>
);
await waitFor(() =>
expect(screen.getByRole("button", { name: /export conversation/i })).toBeEnabled()
);
}

afterEach(() => {
jest.restoreAllMocks();
});

it("shows an export button in the ribbon", () => {
render(
<TestWrapper>
<ChatWindow {...defaultProps} />
</TestWrapper>
);
expect(screen.getByRole("button", { name: /export conversation/i })).toBeInTheDocument();
});

it("disables export when the conversation is empty", () => {
render(
<TestWrapper>
<ChatWindow {...defaultProps} />
</TestWrapper>
);
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(
<TestWrapper>
<ChatWindow
{...defaultProps}
attackResultId="ar-1"
conversationId="conv-1"
activeConversationId="conv-1"
/>
</TestWrapper>
);
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(
<TestWrapper>
<ChatWindow
{...defaultProps}
attackResultId="ar-1"
conversationId="conv-1"
activeConversationId="conv-1"
/>
</TestWrapper>
);
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()
);
});
});
});
48 changes: 47 additions & 1 deletion frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import {
Button,
Drawer,
Menu,
MenuItem,
MenuList,
MenuPopover,
MenuTrigger,
mergeClasses,
Switch,
Text,
Tooltip,
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'
Expand All @@ -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'
Expand Down Expand Up @@ -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 })
Comment thread
varunj-msft marked this conversation as resolved.
}

return (
<div className={styles.root}>
<h1 className={styles.pageHeading}>Chat</h1>
Expand Down Expand Up @@ -654,6 +676,30 @@ export default function ChatWindow({
data-testid="global-markdown-toggle"
/>
</Tooltip>
<Menu>
<MenuTrigger disableButtonEnhancement>
<Tooltip content="Export conversation" relationship="label">
<Button
appearance="subtle"
className={styles.ribbonAction}
icon={<ArrowDownloadRegular />}
disabled={!canExportConversation}
aria-label="Export conversation"
data-testid="export-conversation-btn"
/>
</Tooltip>
</MenuTrigger>
<MenuPopover>
<MenuList>
<MenuItem onClick={() => handleExport('markdown')} data-testid="export-markdown-item">
Export as Markdown (.md)
</MenuItem>
<MenuItem onClick={() => handleExport('json')} data-testid="export-json-item">
Export as JSON (.json)
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
<Tooltip content="Toggle conversations panel" relationship="label">
<Button
{...restoreFocusTargetAttributes}
Expand Down
Loading
Loading