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
8 changes: 6 additions & 2 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@ objectives on the server, not dataset names, and it does not search
simulated-conversation JSON. Filter by that seed type to find those examples.
Matching ignores ASCII case only. Examples stay in the groups the server
returns, including multimodal members. Expanding an example loads its stored
records for inspection only: nothing is executed, imported into chat, or played
as media. Filters, page size, and the open example stay in the address bar.
records for inspection only: nothing is executed or imported into chat.
**Show image/audio/video preview** loads supported media on demand, without
autoplay. Reopening waits for a fresh display URL, with Show disabled while the
request is pending. If refreshing fails, the preview stays closed and shows a
message so you can try again. Hiding a preview stops playback.
Filters, page size, and the open example stay in the address bar.
**Back to datasets** returns to the catalog search, facets, and sort you came
from. A direct link without that return state goes to `/datasets`.
On narrow screens, scroll the example table horizontally to read its columns.
Expand Down
13 changes: 13 additions & 0 deletions frontend/e2e/datasets.seeded.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface SeedExampleSummary {
interface SeedMember {
data_type: string;
value: string;
value_url?: string | null;
}

interface SeedExampleDetail extends SeedExampleSummary {
Expand Down Expand Up @@ -82,6 +83,16 @@ test.describe("dataset examples against the real backend @seeded", () => {
if (!textMember) {
throw new Error("Expected the stored example to include a text member");
}
for (const member of detail.members) {
if (member.data_type === "text") {
expect(member.value_url ?? null).toBeNull();
}
}

const mediaRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/media") mediaRequests.push(request.url());
});

await page.goto(`/datasets/detail?selection_key=${encodeURIComponent(SELECTION_KEY)}`);
await expect(page.getByRole("heading", { level: 1, name: "jailbreak_templates" })).toBeVisible();
Expand All @@ -96,7 +107,9 @@ test.describe("dataset examples against the real backend @seeded", () => {
const region = page.getByRole("region", { name: "Example detail" });
const snippet = textMember.value.replace(/\s+/g, " ").trim().slice(0, 80);
await expect(region.getByText(snippet, { exact: false })).toBeVisible();
await expect(page.getByRole("table", { name: "Examples" }).locator("img, audio, video")).toHaveCount(0);
await expect(region.locator("img, audio, video")).toHaveCount(0);
expect(mediaRequests).toEqual([]);
await expect(page).toHaveURL(new RegExp(`[?&]example=${first.example_id}`));

await page.reload();
Expand Down
430 changes: 427 additions & 3 deletions frontend/e2e/datasets.spec.ts

Large diffs are not rendered by default.

Binary file added frontend/e2e/fixtures/portrait-600x1200.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
61 changes: 9 additions & 52 deletions frontend/src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
Text,
Avatar,
Expand Down Expand Up @@ -28,6 +28,7 @@ import {
OpenRegular,
} from '@fluentui/react-icons'
import MarkdownContent from '@/components/Markdown/MarkdownContent'
import MediaPreview from '@/components/Media/MediaPreview'

import type { DisplayScore, Message, MessageAttachment } from '@/types'

Expand All @@ -49,51 +50,6 @@ interface MessageListProps {
globalMarkdown?: boolean
}

/** Image that shows a spinner while loading. */
function ImageWithSpinner({ src, alt, className, hiddenClassName, containerClassName, spinnerClassName }: {
src: string
alt: string
className: string
hiddenClassName: string
containerClassName: string
spinnerClassName: string
}) {
const [loaded, setLoaded] = useState(false)
const [error, setError] = useState(false)
const onLoad = useCallback(() => setLoaded(true), [])
const onError = useCallback(() => { setError(true); setLoaded(true) }, [])

return (
<div className={containerClassName}>
{!loaded && <Spinner size="small" className={spinnerClassName} />}
{error
? <Text size={200} italic>Image failed to load</Text>
: <img
src={src}
alt={alt}
className={loaded ? className : hiddenClassName}
onLoad={onLoad}
onError={onError}
/>
}
</div>
)
}

function MediaWithFallback({ type, src, className }: { type: 'video' | 'audio'; src: string; className?: string }) {
const [error, setError] = useState(false)
const handleError = useCallback(() => setError(true), [])

if (error) {
return <Text size={200} italic data-testid={`${type}-error`}>{type === 'video' ? 'Video' : 'Audio'} failed to load</Text>
}

if (type === 'video') {
return <video src={src} controls className={className} onError={handleError} data-testid="video-player" />
}
return <audio src={src} controls className={className} onError={handleError} data-testid="audio-player" />
}

function scoreDisplayValue(score: DisplayScore): string {
// An undetermined score has no value, so name its status instead.
return score.score_value ?? score.status ?? ''
Expand Down Expand Up @@ -588,9 +544,9 @@ export default function MessageList({
<div className={styles.attachmentsContainer}>
{message.originalAttachments.map((att, i) => (
<div key={i} className={styles.attachmentItem}>
{att.type === 'image' && <ImageWithSpinner src={att.url} alt={att.name} className={styles.attachmentPreview} hiddenClassName={styles.attachmentPreviewHidden} containerClassName={styles.imageContainer} spinnerClassName={styles.imageSpinner} />}
{att.type === 'video' && <MediaWithFallback type="video" src={att.url} className={styles.videoPreview} />}
{att.type === 'audio' && <MediaWithFallback type="audio" src={att.url} className={styles.audioPreview} />}
{att.type === 'image' && <MediaPreview type="image" src={att.url} alt={att.name} className={styles.attachmentPreview} hiddenClassName={styles.attachmentPreviewHidden} containerClassName={styles.imageContainer} spinnerClassName={styles.imageSpinner} />}
{att.type === 'video' && <MediaPreview type="video" src={att.url} className={styles.videoPreview} />}
{att.type === 'audio' && <MediaPreview type="audio" src={att.url} className={styles.audioPreview} />}
{att.type === 'file' && <div className={styles.attachmentFile}><Text size={200}>📄 {att.name}</Text></div>}
</div>
))}
Expand Down Expand Up @@ -656,7 +612,8 @@ export default function MessageList({
data-testid={`message-piece-${index}-${piece.pieceIndex}`}
>
{att?.type === 'image' && att.url && (
<ImageWithSpinner
<MediaPreview
type="image"
src={att.url}
alt={att.name}
className={styles.attachmentPreview}
Expand All @@ -666,10 +623,10 @@ export default function MessageList({
/>
)}
{att?.type === 'video' && att.url && (
<MediaWithFallback type="video" src={att.url} className={styles.videoPreview} />
<MediaPreview type="video" src={att.url} className={styles.videoPreview} />
)}
{att?.type === 'audio' && att.url && (
<MediaWithFallback type="audio" src={att.url} className={styles.audioPreview} />
<MediaPreview type="audio" src={att.url} className={styles.audioPreview} />
)}
{att?.type === 'file' && (
<div className={styles.attachmentFile}>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/Datasets/SeedExampleBrowser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,9 @@ describe('SeedExampleBrowser', () => {
expect(members[0]).toHaveAccessibleName('Member 1')
expect(members.map((item) => item.getAttribute('aria-label'))).toEqual(['Member 1', 'Member 2', 'Member 3'])
expect(within(members[0]).getByTestId('seed-value-prompt-1').textContent).toBe('prompt body\nkept')
expect(within(members[2]).getByText('Media is not played in this view.')).toBeInTheDocument()
expect(within(members[2]).getByText('File is missing.')).toBeInTheDocument()
expect(within(screen.getByRole('table', { name: 'Examples' })).queryByRole('img')).not.toBeInTheDocument()
expect(document.querySelector('audio, video')).toBeNull()
await waitFor(() => expect(screen.getByRole('heading', { level: 2, name: 'alpha' })).toHaveFocus())
const detailCall = mockGet.mock.calls.find((call) => String(call[0]).startsWith('/datasets/seeds/'))
expect(detailCall?.[0]).toBe('/datasets/seeds/example-1')
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/Datasets/SeedExampleBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,11 @@ export function SeedExampleBrowserView({
</div>
)}
{detailState.detail && query.exampleId === detailState.detail.example_id && (
<SeedExampleDetail detail={detailState.detail} headingRef={detailHeadingRef} />
<SeedExampleDetail
detail={detailState.detail}
headingRef={detailHeadingRef}
onRefreshUrls={detailState.refreshDisplayUrls}
/>
)}
</div>
)
Expand Down
Loading
Loading