Skip to content
Open
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
13 changes: 13 additions & 0 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ Browse datasets already loaded into backend memory. Open a dataset to inspect
its examples, search text, or filter by seed type. Supported media can be
previewed on request.

Use **Import into Chat** to stage an example directly into Chat without copying
text or reattaching files:
- **Single prompt or multimodal message**: loads the text and attachments into the
editable composer.
- **Multi-turn conversation**: opens the conversation editor with all turns intact,
mapping assistant and tool messages to simulated roles. If the conversation ends
in a user turn, earlier turns become prepended history while the final user turn
remains editable in the composer.
- **Import destinations**: choose between starting a new attack, continuing the
current conversation, or creating a new conversation within the current attack.
- **No automatic sending**: importing and saving do not make model calls or execute
tools; edits remain under your control until you explicitly send or save.

Load data through PyRIT before refreshing this catalog. See
[datasets](../code/datasets/0_dataset.md) for loading options.

Expand Down
348 changes: 337 additions & 11 deletions frontend/src/App.drafts.test.tsx

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
routerPathParamValue,
scenarioRunProvenance,
findingEvidenceOrigin,
parseChatRoute,
scenarioRunRoutePath,
} from './utils/routeParams'

Expand Down Expand Up @@ -932,7 +933,15 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) {
/>
<Route path="/scanner/suites/executions/:executionId" element={<ScenarioSuiteExecutionPage />} />
<Route path={DATASETS_PATH} element={<DatasetCatalog />} />
<Route path={DATASET_DETAIL_PATH} element={<DatasetSelection />} />
<Route
path={DATASET_DETAIL_PATH}
element={
<DatasetSelection
activeAttackId={parseChatRoute(lastChatLocation.current.path).attackId}
activeConversationId={parseChatRoute(lastChatLocation.current.path).conversationId}
/>
}
/>
<Route path="/scanner/presets" element={<Navigate to="/registry/scenario-presets" replace />} />
<Route path="/scanner/presets/new" element={<Navigate to="/registry/scenario-presets/new" replace />} />
<Route path="/scanner/presets/:presetName/edit" element={<LegacyPresetEditorRedirect />} />
Expand Down
138 changes: 126 additions & 12 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import {
hasChatDraftState,
isChatDraftDirty,
} from '@/utils/chatDraft'
import { targetReference } from '@/utils/targetIdentity'
import { resolveTargetReference, targetReference } from '@/utils/targetIdentity'
import type { PieceConversion } from './converterTypes'
import { useChatConverters } from '@/hooks/useChatConverters'
import { useRuntime } from '@/hooks/useRuntime'
Expand Down Expand Up @@ -95,6 +95,7 @@ import type {
ChatDraftContent,
ChatDraftContextState,
ConversationMessagesResponse,
ConversationDraftMessage,
ConversationDraftPiece,
ConverterInputPiece,
ConverterPipelineStage,
Expand Down Expand Up @@ -507,6 +508,8 @@ export default function ChatWindow({
const undoInFlight = useRef(false)

const handoffRestoredRef = useRef<number | null>(null)
const importInFlightRef = useRef<string | null>(null)
const importFailedRef = useRef<string | null>(null)
const ownsDraftRef = useRef(false)
const discardDraftOnUnmountRef = useRef(false)
const preserveOnUnmountRef = useRef<(() => void) | null>(null)
Expand All @@ -528,6 +531,7 @@ export default function ChatWindow({
const pendingUserMessagesRef = useRef<Map<string, Message[]>>(new Map())
const pendingSendsRef = useRef<Map<string, PendingSend>>(new Map())
const loadedUserPieceIdsRef = useRef<Map<string, Set<string>>>(new Map())
const loadedBackendMessagesRef = useRef<Map<string, BackendMessage[]>>(new Map())
const isSendInFlight = useCallback(
(convId: string): boolean => sendingConvIdsRef.current.has(convId),
[],
Expand All @@ -538,6 +542,9 @@ export default function ChatWindow({
setConversationScoringFailure(response.scoring_failure ?? null)
const savedUserIds = userPieceIds(response)
loadedUserPieceIdsRef.current.set(convId, savedUserIds)
if (response.messages) {
loadedBackendMessagesRef.current.set(convId, response.messages)
}
const persistedRecovery = getPersistedProcessingRecovery(convId, response)
setRecoverableSends((currentRecoveries) => {
const currentRecovery = currentRecoveries[convId]
Expand Down Expand Up @@ -714,6 +721,19 @@ export default function ChatWindow({
target: draftTargetReference !== undefined
? draftTargetReference : activeTarget ? targetReference(activeTarget) : null,
converters: converters.snapshot(),
...(editDraft
? {
conversation: {
messages: editDraft.messages,
objective: editDraft.objective,
initialObjective: editDraft.initialObjective,
sourceConversationId: editDraft.sourceConversationId,
sourceAttackId: editDraft.sourceAttackId,
temperature: editDraft.temperature,
target: editDraft.target ? targetReference(editDraft.target) : null,
},
}
: {}),
}
const isActiveDraftDirty = isChatDraftDirty(activeDraft)
useLayoutEffect(() => {
Expand Down Expand Up @@ -801,21 +821,28 @@ export default function ChatWindow({
pending
&& isDraftDestinationReady
&& runtime.ready
&& matchingPreservedDraft === null
&& (matchingPreservedDraft === null || pending.replaceDirty || !draftHandoff.isDraftDirty(matchingPreservedDraft))
&& canApplyPendingImport(pending, draftAttackId, draftConversationId)
&& pending.sequence >= draftHandoff.activeSequence
&& (!isActiveDraftDirty || pending.replaceDirty),
)

useEffect(() => {
if (!pending) {
importInFlightRef.current = null
importFailedRef.current = null
} else if (importFailedRef.current && importFailedRef.current !== pending.id) {
importFailedRef.current = null
}
if (pending && pending.sequence < draftHandoff.activeSequence) {
draftHandoff.dismissPendingImport(pending.id)
return
}
if (!runtime.ready || !isDraftDestinationReady || !inputBoxRef.current) return
const preserved = draftHandoff.preservedDraft
const restoringPreserved = Boolean(matchingPreservedDraft && preserved
&& handoffRestoredRef.current !== preserved.sequence)
&& handoffRestoredRef.current !== preserved.sequence
&& !canApplyImportNow)
let draft: ChatDraftContent
let focus = false
if (restoringPreserved && preserved) {
Expand All @@ -827,20 +854,103 @@ export default function ChatWindow({
if (!consumed) return
handoffRestoredRef.current = preserved.sequence
draft = { ...consumed, systemPrompt: consumed.systemPrompt ?? '' }
if (consumed.conversation) {
let editorTarget: TargetInstance | null = activeTarget
if (consumed.conversation.target) {
const resolved = resolveTargetReference(consumed.conversation.target, availableTargets)
if (resolved) {
editorTarget = resolved
} else {
editorTarget = null
queueMicrotask(() => {
setEditorError("The conversation's configured target is no longer available in the registry. Select an available target.")
})
}
}
editor.begin({
messages: consumed.conversation.messages,
objective: consumed.conversation.objective ?? currentObjective,
initialObjective: consumed.conversation.initialObjective ?? currentObjective,
sourceConversationId: consumed.conversation.sourceConversationId ?? null,
sourceAttackId: consumed.conversation.sourceAttackId ?? draftAttackId,
target: editorTarget,
temperature: consumed.conversation.temperature ?? '',
labels,
})
}
} else {
if (!canApplyImportNow || !pending) return
const consumed = draftHandoff.consumePendingImport(pending.id, draftAttackId, draftConversationId)
if (!consumed) return
converters.clearAll()
draft = {
text: consumed.content.text,
attachments: consumed.content.attachments ?? [],
textMetadata: consumed.content.textMetadata,
if (importInFlightRef.current === pending.id || importFailedRef.current === pending.id) return

const executeImportHandoff = async (): Promise<void> => {
importInFlightRef.current = pending.id
let existingTurns: ConversationDraftMessage[] = []
if (pending.conversation && pending.destinationType === 'current_conversation' && draftConversationId && draftAttackId) {
let backendMsgs = loadedBackendMessagesRef.current.get(draftConversationId)
if (!backendMsgs || backendMsgs.length === 0) {
try {
const res = await attacksApi.getMessages(draftAttackId, draftConversationId)
backendMsgs = res?.messages ?? []
if (backendMsgs.length > 0) {
loadedBackendMessagesRef.current.set(draftConversationId, backendMsgs)
}
} catch (err) {
importInFlightRef.current = null
importFailedRef.current = pending.id
setEditorError(toApiError(err).detail || 'Failed to load conversation history. Preserved pending import.')
return
}
}
try {
existingTurns = toConversationDraft(backendMsgs)
} catch (err) {
importInFlightRef.current = null
importFailedRef.current = pending.id
setEditorError(toApiError(err).detail || 'Failed to parse conversation history. Preserved pending import.')
return
}
}

const consumed = draftHandoff.consumePendingImport(pending.id, draftAttackId, draftConversationId)
importInFlightRef.current = null
if (!consumed) return
importFailedRef.current = null
if (preserved) {
draftHandoff.clearPreservedDraft(preserved.sequence)
}
converters.clearAll()
if (consumed.conversation) {
const allMessages = [...existingTurns, ...consumed.conversation.messages]
editor.begin({
messages: allMessages,
objective: consumed.conversation.objective ?? currentObjective,
initialObjective: consumed.conversation.objective ?? currentObjective,
sourceConversationId: draftConversationId ?? null,
sourceAttackId: draftAttackId,
target: activeTarget,
unsavedImport: true,
labels,
})
if (consumed.conversation.systemPrompt) {
if (systemPrompt && systemPrompt.trim() !== consumed.conversation.systemPrompt.trim()) {
setEditorNotice('The imported example includes a system prompt that differs from the existing system prompt. The existing system prompt was preserved.')
}
}
}
draft = {
text: consumed.content.text,
attachments: consumed.content.attachments ?? [],
textMetadata: consumed.content.textMetadata,
...(consumed.conversation?.systemPrompt && !systemPrompt ? { systemPrompt: consumed.conversation.systemPrompt } : {}),
}
focus = consumed.focus === true
restoreComposerDraft(draft)
if (focus) inputBoxRef.current?.focus()
}
focus = consumed.focus === true
void executeImportHandoff()
return
}
// Claiming a handoff synchronizes the imperative input and its parent-owned draft atomically.
// eslint-disable-next-line react-hooks/set-state-in-effect
const attachments = restoreComposerDraft(draft)
if (restoringPreserved) {
if (draft.converters?.generation === runtime.generation) {
Expand All @@ -858,6 +968,7 @@ export default function ChatWindow({
canApplyImportNow, pending, draftHandoff, draftAttackId, draftConversationId, converters,
isDraftDestinationReady, matchingPreservedDraft, isActiveDraftDirty, runtime.ready, runtime.generation,
restoreComposerDraft, restoreConverterSnapshot, restoreConversions,
activeTarget, currentObjective, editor, labels, setEditorNotice, systemPrompt, availableTargets,
])

// Handle conversation selection from the panel
Expand Down Expand Up @@ -975,6 +1086,9 @@ export default function ChatWindow({
} : recovery
if (isLatestRead) {
loadedUserPieceIdsRef.current.set(effectiveConvId, userPieceIds(response.messages))
if (response.messages?.messages) {
loadedBackendMessagesRef.current.set(effectiveConvId, response.messages.messages)
}
setRecoverableSends((currentRecoveries) => {
const next = { ...currentRecoveries }
if (recoveredDraft) { next[effectiveConvId] = recoveredDraft } else { delete next[effectiveConvId] }
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/components/Chat/ConversationEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ function setupEditor(
target?: TargetInstance,
newAttackDisabledReason?: string,
initialConverters?: ChatConverterSnapshot,
unsavedImport?: boolean,
) {
const onSaved = jest.fn()
function EditorHarness() {
Expand All @@ -50,6 +51,7 @@ function setupEditor(
begin({
messages, initialObjective: 'Objective', objective: 'Objective', target: target ?? null,
sourceAttackId: 'source-attack', sourceConversationId: 'source-conversation', labels: { operator: 'owner' },
unsavedImport,
})
}, [begin])
return controller.draft && <ConversationEditor controller={controller}
Expand Down Expand Up @@ -394,6 +396,33 @@ describe('ConversationEditor', () => {
})
expect(onSaved).not.toHaveBeenCalled()
})

it('protects untouched imported content from silent discard on cancel and navigation', async () => {
const user = userEvent.setup()
const { router } = setupEditor(
undefined,
undefined,
undefined,
undefined,
undefined,
true,
)
expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()

// 1. Clicking Cancel without editing prompts for discard confirmation
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByRole('dialog', { name: 'Discard conversation draft?' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Keep editing' }))
expect(screen.queryByRole('dialog', { name: 'Discard conversation draft?' })).not.toBeInTheDocument()
expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()

// 2. Navigating away without editing blocks navigation and prompts for discard confirmation
await act(async () => { await router.navigate('/next') })
expect(screen.getByRole('dialog', { name: 'Discard conversation draft?' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Keep editing' }))
expect(screen.queryByText('Next page')).not.toBeInTheDocument()
expect(screen.getByTestId('conversation-editor')).toBeInTheDocument()
})
})

it('revokes the blob URL when an attachment is removed from the editor', async () => {
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/components/Datasets/DatasetSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ function shownSelectionKey(selectionKey: string): string {
return `${selectionKey.slice(0, MAX_SHOWN_SELECTION_KEY_LENGTH - 1)}…`
}

interface DatasetSelectionProps {
activeAttackId?: string | null
activeConversationId?: string | null
}

/**
* Selected dataset summary plus a read-only example table.
* A missing or repeated selection key does not choose a dataset or request seeds.
*/
export default function DatasetSelection() {
export default function DatasetSelection({ activeAttackId, activeConversationId }: DatasetSelectionProps = {}) {
const styles = useDatasetSelectionStyles()
const headingRef = useRef<HTMLHeadingElement>(null)
const retryRef = useRef<HTMLButtonElement>(null)
Expand Down Expand Up @@ -79,7 +84,13 @@ export default function DatasetSelection() {
<DatasetCard dataset={dataset} linked={false} />
</div>
<div className={styles.browser}>
<SeedExampleBrowserView dataset={dataset} onCatalogStale={refresh} browserState={seedBrowser} />
<SeedExampleBrowserView
dataset={dataset}
onCatalogStale={refresh}
browserState={seedBrowser}
activeAttackId={activeAttackId}
activeConversationId={activeConversationId}
/>
</div>
</>
) : (
Expand Down
Loading
Loading