diff --git a/frontend/e2e/converters.spec.ts b/frontend/e2e/converters.spec.ts index 1aa0c5096d..085e080e06 100644 --- a/frontend/e2e/converters.spec.ts +++ b/frontend/e2e/converters.spec.ts @@ -48,6 +48,14 @@ const MOCK_CATALOG = { is_llm_based: false, description: "Compresses images.", }, + { + converter_type: "AddImageTextConverter", + supported_input_types: ["text"], + supported_output_types: ["image_path"], + parameters: [], + is_llm_based: false, + description: "Renders text onto a generated image.", + }, { converter_type: "TranslationConverter", supported_input_types: ["text"], @@ -61,6 +69,17 @@ const MOCK_CATALOG = { const MOCK_CONVERSATION_ID = "e2e-conv-001"; +// 1x1 transparent PNG returned by the mock /api/media route so that the +// inline image preview element can resolve its src in headless mode. +const TINY_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGIAAgAABQABDQotsgAAAABJRU5ErkJggg=="; + +// Map of mock image-output converter types → path returned by the preview +// mock. Used to decide whether to emit text vs. image_path output. +const IMAGE_OUTPUT_CONVERTERS: Record = { + AddImageTextConverter: "/tmp/output.png", +}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -73,6 +92,9 @@ const MOCK_CONVERSATION_ID = "e2e-conv-001"; */ async function mockBackendAPIs(page: Page) { let accumulatedMessages: Record[] = []; + // Track the converter type for each created converter instance so the + // preview mock can decide between text and image_path output. + const converterTypeById: Record = {}; // ── Converter-specific routes ────────────────────────────────────────── @@ -89,6 +111,26 @@ async function mockBackendAPIs(page: Page) { await page.route(/\/api\/converters\/preview/, async (route) => { if (route.request().method() === "POST") { const body = JSON.parse(route.request().postData() ?? "{}"); + const converterIds: string[] = body.converter_ids ?? []; + const converterType = converterTypeById[converterIds[0] ?? ""] ?? ""; + + // Image-output converters emit a file path the frontend renders via + // /api/media → triggers the convertedFileChip + inline preview branch. + if (IMAGE_OUTPUT_CONVERTERS[converterType]) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + original_value: body.original_value, + original_value_data_type: body.original_value_data_type ?? "text", + converted_value: IMAGE_OUTPUT_CONVERTERS[converterType], + converted_value_data_type: "image_path", + steps: [], + }), + }); + return; + } + const converted = Buffer.from(body.original_value ?? "").toString("base64"); await route.fulfill({ status: 200, @@ -108,11 +150,13 @@ async function mockBackendAPIs(page: Page) { await page.route(/\/api\/converters$/, async (route) => { if (route.request().method() === "POST") { const body = JSON.parse(route.request().postData() ?? "{}"); + const converterId = `mock-converter-${body.type}`; + converterTypeById[converterId] = body.type; await route.fulfill({ status: 201, contentType: "application/json", body: JSON.stringify({ - converter_id: "mock-converter-001", + converter_id: converterId, converter_type: body.type, display_name: null, }), @@ -122,6 +166,16 @@ async function mockBackendAPIs(page: Page) { } }); + // Media route — serves the generated image referenced by the preview + // mock. Returning a tiny valid PNG keeps the element layout-stable. + await page.route(/\/api\/media\?path=/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "image/png", + body: Buffer.from(TINY_PNG_BASE64, "base64"), + }); + }); + // ── Standard chat routes (matching chat.spec.ts pattern) ─────────────── // Targets list @@ -515,4 +569,46 @@ test.describe("Converter Panel", () => { // The converter badge should be visible in the attack table await expect(page.getByText("Base64Converter")).toBeVisible({ timeout: 10000 }); }); + + test("should render inline image preview in input box after a text→image conversion", async ({ page }) => { + // Type a prompt before opening the panel + await page.getByTestId("chat-input").fill("hello world"); + + // Select AddImageTextConverter (text input → image_path output) + await selectConverter(page, "AddImageTextConverter"); + + // Auto-preview only fires for text-output converters, so click Preview + await page.getByTestId("converter-preview-btn").click(); + await expect(page.getByTestId("converter-preview-result")).toBeVisible({ timeout: 10000 }); + + // Apply the converted value to the input + await page.getByTestId("use-converted-btn").click(); + + // Close converter panel so the input area is unobscured + await page.getByTestId("close-converter-panel-btn").click(); + await expect(page.getByTestId("converter-panel")).not.toBeVisible(); + + // The converted-file-chip block is rendered in the input area for image_path outputs + const chip = page.getByTestId("converted-file-chip"); + await expect(chip).toBeVisible(); + await expect(chip).toContainText("output.png"); + + // The inline image preview is rendered alongside the filename + Open link + const preview = page.getByTestId("converted-file-preview-image"); + await expect(preview).toBeVisible({ timeout: 10000 }); + await expect(preview).toHaveAttribute("src", /\/api\/media\?path=/); + await expect(preview).toHaveAttribute("alt", "output.png"); + + // The Open link is still present alongside the preview + await expect(page.getByTestId("converted-file-open")).toHaveAttribute("href", /\/api\/media\?path=/); + + // Audio / video previews must NOT be rendered for an image conversion + await expect(page.getByTestId("converted-file-preview-audio")).toHaveCount(0); + await expect(page.getByTestId("converted-file-preview-video")).toHaveCount(0); + + // Dismissing the chip clears the entire block (including the preview) + await page.getByTestId("clear-converted-file-chip").click(); + await expect(chip).not.toBeVisible(); + await expect(page.getByTestId("converted-file-preview-image")).toHaveCount(0); + }); }); diff --git a/frontend/src/components/Chat/ChatInputArea.styles.ts b/frontend/src/components/Chat/ChatInputArea.styles.ts index f99c2f46b5..27aa9c61da 100644 --- a/frontend/src/components/Chat/ChatInputArea.styles.ts +++ b/frontend/src/components/Chat/ChatInputArea.styles.ts @@ -238,13 +238,32 @@ export const useChatInputAreaStyles = makeStyles({ borderRadius: '4px', }, }, - convertedMediaPreview: { - maxHeight: '60px', + convertedFileBlock: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + minWidth: 0, + }, + convertedImagePreview: { + display: 'block', + maxHeight: '120px', maxWidth: '100%', borderRadius: tokens.borderRadiusSmall, objectFit: 'contain' as const, - flex: 1, - minWidth: 0, + alignSelf: 'flex-start', + }, + convertedAudioPreview: { + display: 'block', + width: '100%', + maxWidth: '360px', + height: '32px', + }, + convertedVideoPreview: { + display: 'block', + maxHeight: '160px', + maxWidth: '100%', + borderRadius: tokens.borderRadiusSmall, + alignSelf: 'flex-start', }, convertedFilename: { flex: 1, @@ -256,6 +275,24 @@ export const useChatInputAreaStyles = makeStyles({ fontSize: tokens.fontSizeBase200, color: tokens.colorNeutralForeground2, }, + openLink: { + display: 'inline-flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXXS, + padding: `0 ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusSmall, + backgroundColor: tokens.colorBrandBackground, + color: tokens.colorNeutralForegroundOnBrand, + fontSize: tokens.fontSizeBase200, + fontWeight: tokens.fontWeightSemibold as unknown as string, + textDecoration: 'none', + flexShrink: 0, + height: '20px', + ':hover': { + backgroundColor: tokens.colorBrandBackgroundHover, + color: tokens.colorNeutralForegroundOnBrand, + }, + }, unsupportedWarning: { display: 'flex', alignItems: 'center', diff --git a/frontend/src/components/Chat/ChatInputArea.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx index 13f1ba8ae7..1a8d029e9c 100644 --- a/frontend/src/components/Chat/ChatInputArea.test.tsx +++ b/frontend/src/components/Chat/ChatInputArea.test.tsx @@ -574,7 +574,7 @@ describe("ChatInputArea", () => { ); @@ -602,7 +602,7 @@ describe("ChatInputArea", () => { @@ -679,6 +679,140 @@ describe("ChatInputArea", () => { expect(onClearConversion).toHaveBeenCalled(); }); + it("should render converted file chip with Open link for text→file conversion", async () => { + render( + + + + ); + + expect(screen.getByTestId("original-banner")).toBeInTheDocument(); + const chip = screen.getByTestId("converted-file-chip"); + expect(chip).toHaveTextContent("result.pdf"); + const openLink = screen.getByTestId("converted-file-open"); + expect(openLink).toHaveAttribute("href", "/api/media?path=%2Ftmp%2Fresult.pdf"); + expect(openLink).toHaveAttribute("target", "_blank"); + }); + + it("should call onClearConvertedFileChip when chip dismiss is clicked", async () => { + const onClearConvertedFileChip = jest.fn(); + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByTestId("clear-converted-file-chip")); + expect(onClearConvertedFileChip).toHaveBeenCalledTimes(1); + }); + + it("should render an inline image preview for an image conversion", () => { + render( + + + + ); + + const preview = screen.getByTestId("converted-file-preview-image"); + expect(preview.tagName).toBe("IMG"); + expect(preview).toHaveAttribute("src", "/api/media?path=%2Ftmp%2Fresult.png"); + expect(preview).toHaveAttribute("alt", "result.png"); + // Header info (filename + Open link) is still rendered alongside the preview. + expect(screen.getByTestId("converted-file-chip")).toHaveTextContent("result.png"); + expect(screen.getByTestId("converted-file-open")).toHaveAttribute("href", "/api/media?path=%2Ftmp%2Fresult.png"); + }); + + it("should render an inline audio preview for an audio conversion", () => { + render( + + + + ); + + const preview = screen.getByTestId("converted-file-preview-audio"); + expect(preview.tagName).toBe("AUDIO"); + expect(preview).toHaveAttribute("src", "/api/media?path=%2Ftmp%2Fspeech.wav"); + expect(preview).toHaveAttribute("controls"); + expect(screen.queryByTestId("converted-file-preview-image")).not.toBeInTheDocument(); + expect(screen.queryByTestId("converted-file-preview-video")).not.toBeInTheDocument(); + }); + + it("should render an inline video preview for a video conversion", () => { + render( + + + + ); + + const preview = screen.getByTestId("converted-file-preview-video"); + expect(preview.tagName).toBe("VIDEO"); + expect(preview).toHaveAttribute("src", "/api/media?path=%2Ftmp%2Fclip.mp4"); + expect(preview).toHaveAttribute("controls"); + }); + + it("should not render a media preview for a generic file conversion", () => { + render( + + + + ); + + expect(screen.queryByTestId("converted-file-preview-image")).not.toBeInTheDocument(); + expect(screen.queryByTestId("converted-file-preview-audio")).not.toBeInTheDocument(); + expect(screen.queryByTestId("converted-file-preview-video")).not.toBeInTheDocument(); + }); + // --------------------------------------------------------------------------- // Unsupported modality warnings // --------------------------------------------------------------------------- diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx index 749a7b8eed..6e56bc12b8 100644 --- a/frontend/src/components/Chat/ChatInputArea.tsx +++ b/frontend/src/components/Chat/ChatInputArea.tsx @@ -7,7 +7,7 @@ import { tokens, mergeClasses, } from '@fluentui/react-components' -import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular } from '@fluentui/react-icons' +import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular, OpenRegular } from '@fluentui/react-icons' import { MessageAttachment, TargetInstance } from '../../types' import { useChatInputAreaStyles } from './ChatInputArea.styles' import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes' @@ -16,6 +16,12 @@ import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes' // Reusable status banner // --------------------------------------------------------------------------- +export interface ConvertedFileChip { + name: string + url: string + iconKind: 'image' | 'audio' | 'video' | 'file' +} + interface StatusBannerProps { icon: React.ReactElement text: string @@ -55,7 +61,7 @@ function StatusBanner({ icon, text, buttonText, buttonIcon, onButtonClick, testI interface AttachmentListProps { attachments: MessageAttachment[] - mediaConversions: Array<{ pieceType: string; convertedValue: string }> + mediaConversions: Array<{ pieceType: string; convertedValue: string; convertedDataType: string }> onRemove: (index: number) => void onClearMediaConversion: (pieceType: string) => void formatFileSize: (bytes: number) => string @@ -120,21 +126,24 @@ function AttachmentList({ attachments, mediaConversions, onRemove, onClearMediaC interface TextInputRowsProps { input: string convertedValue?: string | null + convertedFileChip?: ConvertedFileChip | null disabled: boolean textareaRef: Ref convertedRef: Ref onInput: (e: React.ChangeEvent) => void onKeyDown: (e: KeyboardEvent) => void onConvertedValueChange: (value: string) => void + onClearConvertedFileChip?: () => void styles: ReturnType textInputClassName: string } -function TextInputRows({ input, convertedValue, disabled, textareaRef, convertedRef, onInput, onKeyDown, onConvertedValueChange, styles, textInputClassName }: TextInputRowsProps) { +function TextInputRows({ input, convertedValue, convertedFileChip, disabled, textareaRef, convertedRef, onInput, onKeyDown, onConvertedValueChange, onClearConvertedFileChip, styles, textInputClassName }: TextInputRowsProps) { + const hasConversion = Boolean(convertedValue) || Boolean(convertedFileChip) return ( <>
- {convertedValue && ( + {hasConversion && ( Original )}