diff --git a/frontend/e2e/converters.spec.ts b/frontend/e2e/converters.spec.ts index 4d4bfeba4e..1aa0c5096d 100644 --- a/frontend/e2e/converters.spec.ts +++ b/frontend/e2e/converters.spec.ts @@ -137,7 +137,16 @@ async function mockBackendAPIs(page: Page) { target_type: "OpenAIChatTarget", endpoint: "https://mock.openai.com", model_name: "gpt-4o-mock", - supports_multi_turn: true, + capabilities: { + supports_multi_turn: true, + supports_multi_message_pieces: false, + supports_json_schema: false, + supports_json_output: false, + supports_editable_history: false, + supports_system_prompt: false, + supported_input_data_types: ["text"], + supported_output_data_types: ["text"], + }, }, ], pagination: { limit: 50, has_more: false }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c4f9d85124..a6e8f34487 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "pyrit-frontend", - "version": "0.13.0-dev.0", + "version": "0.14.0-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pyrit-frontend", - "version": "0.13.0-dev.0", + "version": "0.14.0-dev.0", "dependencies": { "@azure/msal-browser": "^5.5.0", "@azure/msal-react": "^5.0.7", diff --git a/frontend/src/components/Chat/ChatInputArea.styles.ts b/frontend/src/components/Chat/ChatInputArea.styles.ts index b53e720e58..f99c2f46b5 100644 --- a/frontend/src/components/Chat/ChatInputArea.styles.ts +++ b/frontend/src/components/Chat/ChatInputArea.styles.ts @@ -67,7 +67,7 @@ export const useChatInputAreaStyles = makeStyles({ columnLeft: { display: 'flex', flexDirection: 'column', - gap: tokens.spacingVerticalXXS, + gap: tokens.spacingVerticalS, alignItems: 'center', marginRight: tokens.spacingHorizontalS, alignSelf: 'center', @@ -77,24 +77,24 @@ export const useChatInputAreaStyles = makeStyles({ flexDirection: 'column', flex: 1, minWidth: 0, - gap: tokens.spacingVerticalXXS, + gap: tokens.spacingVerticalXS, }, columnRight: { display: 'flex', flexDirection: 'column', - gap: tokens.spacingVerticalXXS, + gap: tokens.spacingVerticalS, alignItems: 'center', marginLeft: tokens.spacingHorizontalS, alignSelf: 'center', }, textRow: { display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', gap: tokens.spacingHorizontalXXS, }, convertedRow: { display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', gap: tokens.spacingHorizontalXXS, }, textInput: { @@ -107,7 +107,7 @@ export const useChatInputAreaStyles = makeStyles({ color: tokens.colorNeutralForeground1, resize: 'none', minHeight: '24px', - maxHeight: '96px', + maxHeight: '60vh', overflowY: 'auto', '::placeholder': { color: tokens.colorNeutralForeground4, @@ -123,12 +123,16 @@ export const useChatInputAreaStyles = makeStyles({ borderRadius: '4px', }, }, + textInputShared: { + maxHeight: '30vh', + }, iconButton: { minWidth: '32px', width: '32px', height: '32px', padding: 0, borderRadius: '50%', + border: `1px solid ${tokens.colorNeutralStroke1}`, }, dismissBtn: { minWidth: '24px', @@ -143,6 +147,20 @@ export const useChatInputAreaStyles = makeStyles({ padding: 0, borderRadius: '50%', }, + clearConversionButton: { + minWidth: '32px', + width: '32px', + height: '32px', + padding: 0, + borderRadius: '50%', + border: `1px solid ${tokens.colorNeutralStroke1}`, + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground2, + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + color: tokens.colorNeutralForeground1, + }, + }, singleTurnWarning: { display: 'flex', alignItems: 'center', @@ -204,11 +222,21 @@ export const useChatInputAreaStyles = makeStyles({ color: tokens.colorNeutralForeground1, resize: 'none', minHeight: '20px', - maxHeight: '80px', + maxHeight: '30vh', overflowY: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word', padding: 0, + '::-webkit-scrollbar': { + width: '8px', + }, + '::-webkit-scrollbar-track': { + backgroundColor: 'transparent', + }, + '::-webkit-scrollbar-thumb': { + backgroundColor: tokens.colorNeutralStroke1, + borderRadius: '4px', + }, }, convertedMediaPreview: { maxHeight: '60px', @@ -228,6 +256,13 @@ export const useChatInputAreaStyles = makeStyles({ fontSize: tokens.fontSizeBase200, color: tokens.colorNeutralForeground2, }, + unsupportedWarning: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXXS, + color: tokens.colorPaletteYellowForeground2, + fontSize: tokens.fontSizeBase200, + }, originalBadge: { display: 'inline-block', padding: `0 ${tokens.spacingHorizontalXS}`, @@ -242,6 +277,7 @@ export const useChatInputAreaStyles = makeStyles({ convertedBadge: { display: 'inline-block', padding: `0 ${tokens.spacingHorizontalXS}`, + marginRight: tokens.spacingHorizontalXS, borderRadius: tokens.borderRadiusSmall, backgroundColor: tokens.colorPaletteGreenBackground2, color: tokens.colorPaletteGreenForeground2, diff --git a/frontend/src/components/Chat/ChatInputArea.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx index 570fb93039..0689a67112 100644 --- a/frontend/src/components/Chat/ChatInputArea.test.tsx +++ b/frontend/src/components/Chat/ChatInputArea.test.tsx @@ -4,6 +4,7 @@ import userEvent from "@testing-library/user-event"; import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import ChatInputArea from "./ChatInputArea"; import type { ChatInputAreaHandle } from "./ChatInputArea"; +import type { TargetCapabilitiesInfo } from "../../types"; // Wrapper component for Fluent UI context const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( @@ -13,6 +14,19 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( // Helper to get the send button specifically const getSendButton = () => screen.getByRole("button", { name: /send/i }); +// Helper to build a TargetCapabilitiesInfo with sensible defaults +const makeCaps = (overrides: Partial = {}): TargetCapabilitiesInfo => ({ + supports_multi_turn: false, + supports_multi_message_pieces: false, + supports_json_schema: false, + supports_json_output: false, + supports_editable_history: false, + supports_system_prompt: false, + supported_input_data_types: ["text"], + supported_output_data_types: ["text"], + ...overrides, +}); + describe("ChatInputArea", () => { const defaultProps = { onSend: jest.fn(), @@ -367,7 +381,7 @@ describe("ChatInputArea", () => { activeTarget={{ target_registry_name: "test", target_type: "TextTarget", - supports_multi_turn: false, + capabilities: makeCaps({ supports_multi_turn: false }), }} /> @@ -388,7 +402,7 @@ describe("ChatInputArea", () => { activeTarget={{ target_registry_name: "test", target_type: "OpenAIChatTarget", - supports_multi_turn: true, + capabilities: makeCaps({ supports_multi_turn: true }), }} /> @@ -647,4 +661,233 @@ describe("ChatInputArea", () => { expect(onSend).toHaveBeenCalledWith("hello", "convertedHello", []); expect(onClearConversion).toHaveBeenCalled(); }); + + // --------------------------------------------------------------------------- + // Unsupported modality warnings + // --------------------------------------------------------------------------- + + it("should show warning when attaching image to text-only target", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const file = new File(["img"], "photo.png", { type: "image/png" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + expect(screen.getByText(/does not support image files/)).toBeInTheDocument(); + }); + }); + + it("should not show warning when attaching image to image-capable target", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const file = new File(["img"], "photo.png", { type: "image/png" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByText(/photo\.png/)).toBeInTheDocument(); + }); + expect(screen.queryByTestId("unsupported-modality-warning")).not.toBeInTheDocument(); + }); + + it("should not show warning when no target is selected", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const file = new File(["img"], "photo.png", { type: "image/png" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByText(/photo\.png/)).toBeInTheDocument(); + }); + expect(screen.queryByTestId("unsupported-modality-warning")).not.toBeInTheDocument(); + }); + + it("should show warning for audio attachment on text+image target", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const file = new File(["audio"], "sound.mp3", { type: "audio/mpeg" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + expect(screen.getByText(/does not support audio files/)).toBeInTheDocument(); + }); + }); + + it("should show warning listing multiple unsupported types", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const files = [ + new File(["img"], "photo.png", { type: "image/png" }), + new File(["audio"], "sound.mp3", { type: "audio/mpeg" }), + ]; + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, files); + + await waitFor(() => { + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + expect(screen.getByText(/does not support image, audio files/)).toBeInTheDocument(); + }); + }); + + it("should disable send button with unsupported attachment type", async () => { + const user = userEvent.setup(); + const onSend = jest.fn(); + + render( + + + + ); + + const file = new File(["img"], "photo.png", { type: "image/png" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + }); + + // Send should be disabled (warning is blocking) + expect(getSendButton()).toBeDisabled(); + }); + + it("should show warning for file attachment to text-only target", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const file = new File(["pdf content"], "document.pdf", { type: "application/pdf" }); + const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(fileInput, file); + + await waitFor(() => { + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + expect(screen.getByText(/does not support file files/)).toBeInTheDocument(); + }); + }); + + it("should block sending when converter output type is unsupported by target", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + const input = screen.getByRole("textbox"); + await user.type(input, "convert this to image"); + + expect(screen.getByTestId("unsupported-modality-warning")).toBeInTheDocument(); + expect(screen.getByText(/does not support image_path/)).toBeInTheDocument(); + expect(getSendButton()).toBeDisabled(); + }); + + it("should not block when converter output type is supported by target", () => { + render( + + + + ); + + expect(screen.queryByTestId("unsupported-modality-warning")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 615244d0e9..509b78b506 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -5,10 +5,12 @@ import { Tooltip, Text, tokens, + mergeClasses, } from '@fluentui/react-components' import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular } from '@fluentui/react-icons' import { MessageAttachment, TargetInstance } from '../../types' import { useChatInputAreaStyles } from './ChatInputArea.styles' +import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes' // --------------------------------------------------------------------------- // Reusable status banner @@ -120,14 +122,15 @@ interface TextInputRowsProps { convertedValue?: string | null disabled: boolean textareaRef: Ref + convertedRef: Ref onInput: (e: React.ChangeEvent) => void onKeyDown: (e: KeyboardEvent) => void onConvertedValueChange: (value: string) => void - onClearConversion: () => void styles: ReturnType + textInputClassName: string } -function TextInputRows({ input, convertedValue, disabled, textareaRef, onInput, onKeyDown, onConvertedValueChange, onClearConversion, styles }: TextInputRowsProps) { +function TextInputRows({ input, convertedValue, disabled, textareaRef, convertedRef, onInput, onKeyDown, onConvertedValueChange, styles, textInputClassName }: TextInputRowsProps) { return ( <>
@@ -136,7 +139,7 @@ function TextInputRows({ input, convertedValue, disabled, textareaRef, onInput, )}