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
7 changes: 5 additions & 2 deletions evalscope/api/messages/chat_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,12 @@ def messages_to_markdown(messages: List[ChatMessage], max_length: Optional[int]
# Use markdown image syntax
image_base64_or_url = content_item.image
if os.path.isfile(image_base64_or_url):
# Local file: emit the absolute path so that renderers can resolve it,
# e.g. the Web dashboard proxies it through its media file endpoint.
# The destination is wrapped in <> so that paths containing spaces or
# unbalanced parentheses still parse as a markdown image.
image_base64_or_url = os.path.abspath(image_base64_or_url)
# If it's a file, convert to a markdown image with a gradio-compatible path
content_parts.append(f'![image](gradio_api/file={image_base64_or_url})')
content_parts.append(f'![image](<{image_base64_or_url}>)')
else:
# If it's not a file, assume it's a base64 string
if max_length and len(image_base64_or_url) > max_length:
Expand Down
30 changes: 29 additions & 1 deletion evalscope/utils/data_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,34 @@ def _load_perf_map(cache_manager: CacheManager, dataset_name: str, subset_name:
return perf_map


# Serialised ContentBlock types that may carry a server-side media path. The
# payload field of each of these blocks is named after the block type.
_MEDIA_BLOCK_TYPES = frozenset({'image', 'audio', 'video'})


def _absolutize_media_path(block: Dict[str, Any]) -> Dict[str, Any]:
"""Rewrite a local media path in a serialised ContentBlock to an absolute path.

A renderer can only resolve a server-side file when it is given an absolute
path: a relative path is indistinguishable from a base64 payload on the
client side. This mirrors what :func:`messages_to_markdown` does for the
markdown chain, so both chains expose local media the same way.

Args:
block (Dict[str, Any]): A serialised ContentBlock, mutated in place.

Returns:
Dict[str, Any]: The same block, for convenient chaining.
"""
block_type = block.get('type')
if block_type not in _MEDIA_BLOCK_TYPES:
return block
value = block.get(block_type)
if isinstance(value, str) and value and not value.startswith('data:') and os.path.isfile(value):
block[block_type] = os.path.abspath(value)
return block


def _serialize_messages(review_result: ReviewResult) -> List[Dict[str, Any]]:
"""Serialize a ReviewResult's message list into frontend-compatible dicts.

Expand Down Expand Up @@ -200,7 +228,7 @@ def _serialize_messages(review_result: ReviewResult) -> List[Dict[str, Any]]:
# reasoning, text, …). model_dump() mirrors the Python
# ContentBase subclasses to plain dicts that match the
# TypeScript ContentBlock interface in types.ts.
serialised_content = [c.model_dump() for c in m.content]
serialised_content = [_absolutize_media_path(c.model_dump()) for c in m.content]
else:
# Text-only / legacy path – content is already a str.
serialised_content = m.content
Expand Down
53 changes: 53 additions & 0 deletions evalscope/web/src/components/common/MarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,56 @@ describe('MarkdownRenderer heavy-content gating', () => {
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})

describe('MarkdownRenderer image sources', () => {
it('proxies a local absolute image path through the media file endpoint', () => {
// The markdown chain (e.g. the `Input` column built by `messages_to_markdown`)
// must resolve server-side paths the same way structured messages do.
const { container } = renderMarkdown('![shot](/tmp/miniwob/step-000.png)')

expect(container.querySelector('img')).toHaveAttribute(
'src',
'/api/v1/reports/media/file?path=%2Ftmp%2Fminiwob%2Fstep-000.png',
)
})

it('decodes a percent-encoded non-ascii path back to the real filesystem path', () => {
// The markdown parser encodes non-ascii destinations, so without decoding
// the proxy would receive the literal escape sequences and return 404.
const { container } = renderMarkdown('![shot](</tmp/\u6570\u636e\u96c6/a.png>)')

expect(container.querySelector('img')).toHaveAttribute(
'src',
`/api/v1/reports/media/file?path=${encodeURIComponent('/tmp/\u6570\u636e\u96c6/a.png')}`,
)
})

it('renders a local path containing spaces via the angle-bracket destination', () => {
const { container } = renderMarkdown('![shot](</tmp/my dir/a.png>)')

expect(container.querySelector('img')).toHaveAttribute(
'src',
`/api/v1/reports/media/file?path=${encodeURIComponent('/tmp/my dir/a.png')}`,
)
})

it('keeps http and data URI image sources untouched', () => {
const { container } = renderMarkdown(
'![remote](https://example.com/a%20b.png)\n\n![inline](data:image/png;base64,aGVsbG8=)',
)

const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', 'https://example.com/a%20b.png')
expect(images[1]).toHaveAttribute('src', 'data:image/png;base64,aGVsbG8=')
})

it('keeps an uppercase scheme and a protocol-relative url untouched', () => {
// These are valid remote sources; treating them as local paths or base64
// payloads would break images that rendered fine before.
const { container } = renderMarkdown('![up](HTTPS://example.com/a.png)\n\n![rel](//cdn.example.com/a.png)')

const images = container.querySelectorAll('img')
expect(images[0]).toHaveAttribute('src', 'HTTPS://example.com/a.png')
expect(images[1]).toHaveAttribute('src', '//cdn.example.com/a.png')
})
})
5 changes: 4 additions & 1 deletion evalscope/web/src/components/common/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm'
import type { Components, Options } from 'react-markdown'
import { useTheme } from '@/contexts/ThemeContext'
import { useLocale } from '@/contexts/LocaleContext'
import { resolveMarkdownMediaSrc } from '@/utils/media'
import ImageLightbox from './ImageLightbox'
import LazyCodeBlock from './LazyCodeBlock'

Expand Down Expand Up @@ -115,7 +116,9 @@ export default function MarkdownRenderer({ content, collapsed = false }: Props)
const { plugins: mathPlugins, state: mathState } = useMathPlugins(content, collapsed)

const markdownComponents = useMemo<Components>(() => ({
img: ({ src, alt }) => <ImageLightbox src={src ?? ''} alt={alt} style={INLINE_IMG_STYLE} />,
img: ({ src, alt }) => (
<ImageLightbox src={src ? resolveMarkdownMediaSrc(src, 'image/jpeg') : ''} alt={alt} style={INLINE_IMG_STYLE} />
),
code: ({ className, children }) => {
const match = /language-(\w+)/.exec(className || '')
if (match) {
Expand Down
16 changes: 1 addition & 15 deletions evalscope/web/src/components/single/chat/MediaBlocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,7 @@ import { useLocale } from '@/contexts/LocaleContext'
import MarkdownRenderer from '@/components/common/MarkdownRenderer'
import ImageLightbox from '@/components/common/ImageLightbox'
import ChatBubble, { bubbleAccent } from '@/components/ui/ChatBubble'

/**
* Resolve a server-side path / URL / base64 payload to a browser-loadable src.
*
* - http(s) and data: URIs are returned as-is.
* - Absolute POSIX/Windows paths are proxied through the media file endpoint.
* - Anything else is treated as base64 and wrapped in a data: URI with `mimeType`.
*/
function resolveMediaSrc(src: string, mimeType: string): string {
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:')) return src
if (src.startsWith('/') || /^[A-Za-z]:[/\\]/.test(src)) {
return `/api/v1/reports/media/file?path=${encodeURIComponent(src)}`
}
return `data:${mimeType};base64,${src}`
}
import { resolveMediaSrc } from '@/utils/media'

const IMG_INLINE_CLASS = 'cursor-zoom-in hover:scale-[1.02] transition-transform max-w-full max-h-[360px] rounded-lg border border-[var(--border)] block'

Expand Down
43 changes: 43 additions & 0 deletions evalscope/web/src/utils/media.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'

import { resolveMarkdownMediaSrc, resolveMediaSrc } from './media'

const PROXY = '/api/v1/reports/media/file?path='

describe('resolveMediaSrc', () => {
it('proxies absolute posix and windows paths', () => {
expect(resolveMediaSrc('/tmp/a.png', 'image/jpeg')).toBe(`${PROXY}${encodeURIComponent('/tmp/a.png')}`)
expect(resolveMediaSrc('C:\\tmp\\a.png', 'image/jpeg')).toBe(`${PROXY}${encodeURIComponent('C:\\tmp\\a.png')}`)
})

it('passes remote sources through regardless of scheme case', () => {
for (const src of ['http://e.com/a.png', 'HTTPS://e.com/a.png', 'data:image/png;base64,x']) {
expect(resolveMediaSrc(src, 'image/jpeg')).toBe(src)
}
})

it('treats anything else as a base64 payload', () => {
expect(resolveMediaSrc('aGVsbG8=', 'image/png')).toBe('data:image/png;base64,aGVsbG8=')
})
})

describe('resolveMarkdownMediaSrc', () => {
it('decodes a percent-encoded destination before proxying it', () => {
const raw = '/tmp/my dir/\u56fe.png'
expect(resolveMarkdownMediaSrc(encodeURI(raw), 'image/jpeg')).toBe(`${PROXY}${encodeURIComponent(raw)}`)
})

it('round-trips a path whose name contains a percent sign', () => {
const raw = '/tmp/100%.png'
expect(resolveMarkdownMediaSrc(encodeURI(raw), 'image/jpeg')).toBe(`${PROXY}${encodeURIComponent(raw)}`)
})

it('leaves a protocol-relative url alone instead of proxying it as a path', () => {
expect(resolveMarkdownMediaSrc('//cdn.example.com/a.png', 'image/jpeg')).toBe('//cdn.example.com/a.png')
})

it('falls back to the raw value when the escape sequence is malformed', () => {
// A lone '%' makes decodeURI throw; the src must still be resolved.
expect(resolveMarkdownMediaSrc('/tmp/a%zz.png', 'image/jpeg')).toBe(`${PROXY}${encodeURIComponent('/tmp/a%zz.png')}`)
})
})
44 changes: 44 additions & 0 deletions evalscope/web/src/utils/media.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/** Schemes that already point at something the browser can load directly. */
const REMOTE_SCHEME_RE = /^(?:https?|data):/i

/**
* Resolve a server-side path / URL / base64 payload to a browser-loadable src.
*
* - http(s) and data: URIs are returned as-is (the scheme match is case-insensitive).
* - Absolute POSIX/Windows paths are proxied through the media file endpoint.
* - Anything else is treated as base64 and wrapped in a data: URI with `mimeType`.
*
* Shared by both rendering chains so that a local media path shows up the same
* way in structured `ContentBlock` messages and in Markdown text (e.g. the
* `Input` column produced by `messages_to_markdown`).
*/
export function resolveMediaSrc(src: string, mimeType: string): string {
if (REMOTE_SCHEME_RE.test(src)) return src
if (src.startsWith('/') || /^[A-Za-z]:[/\\]/.test(src)) {
return `/api/v1/reports/media/file?path=${encodeURIComponent(src)}`
}
return `data:${mimeType};base64,${src}`
}

/**
* Resolve an image src taken from rendered Markdown.
*
* A Markdown parser percent-encodes the link destination, so a local path
* containing spaces, non-ASCII characters or backslashes arrives here already
* encoded (`/tmp/my img.jpg` -> `/tmp/my%20img.jpg`). It has to be decoded back
* to the real filesystem path before the media endpoint can look it up,
* otherwise the proxy receives the literal escape sequences.
*
* Markdown content is authored text rather than a media field, so anything that
* looks like a URL is passed through untouched - including protocol-relative
* URLs and intentionally escaped remote URLs.
*/
export function resolveMarkdownMediaSrc(src: string, mimeType: string): string {
if (REMOTE_SCHEME_RE.test(src) || src.startsWith('//')) return src
try {
return resolveMediaSrc(decodeURI(src), mimeType)
} catch {
// Malformed escape sequence – fall back to the raw value.
return resolveMediaSrc(src, mimeType)
}
}
59 changes: 59 additions & 0 deletions tests/api/test_messages_to_markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import pytest
from pathlib import Path

from evalscope.api.messages import ChatMessageUser, ContentImage, ContentText, messages_to_markdown


def test_local_image_path_is_emitted_as_an_absolute_path(tmp_path: Path) -> None:
# Regression test: the legacy `gradio_api/file=` prefix is no longer
# understood by any renderer, so a local file must be emitted as a path.
image_path = tmp_path / 'screenshot.png'
image_path.write_bytes(b'fake-png')
messages = [ChatMessageUser(content=[ContentText(text='Look:'), ContentImage(image=str(image_path))])]

markdown = messages_to_markdown(messages)

assert f'![image](<{image_path}>)' in markdown
assert 'gradio_api' not in markdown


def test_local_image_path_with_spaces_stays_a_single_destination(tmp_path: Path) -> None:
# An unwrapped destination containing a space does not parse as a markdown
# image at all, so the <> form is required here.
image_dir = tmp_path / 'my images'
image_dir.mkdir()
image_path = image_dir / 'a shot.png'
image_path.write_bytes(b'fake-png')
messages = [ChatMessageUser(content=[ContentImage(image=str(image_path))])]

markdown = messages_to_markdown(messages)

assert f'![image](<{image_path}>)' in markdown


def test_relative_image_path_is_absolutised(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
image_path = tmp_path / 'frame.jpg'
image_path.write_bytes(b'fake-jpg')
monkeypatch.chdir(tmp_path)
messages = [ChatMessageUser(content=[ContentImage(image='frame.jpg')])]

markdown = messages_to_markdown(messages)

assert f'![image](<{image_path.resolve()}>)' in markdown


def test_data_uri_image_is_passed_through() -> None:
data_uri = 'data:image/png;base64,aGVsbG8='
messages = [ChatMessageUser(content=[ContentImage(image=data_uri)])]

markdown = messages_to_markdown(messages)

assert f'![image]({data_uri})' in markdown


def test_base64_image_is_truncated_by_max_length() -> None:
messages = [ChatMessageUser(content=[ContentImage(image='a' * 100)])]

markdown = messages_to_markdown(messages, max_length=10)

assert f'![image]({"a" * 10})' in markdown
50 changes: 50 additions & 0 deletions tests/report/test_prediction_media_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import pytest
from pathlib import Path
from typing import List

from evalscope.api.evaluator import ReviewResult
from evalscope.api.messages import ChatMessageUser, ContentImage, ContentText
from evalscope.api.metric import SampleScore, Score
from evalscope.utils.data_utils import _serialize_messages


def _review_result(image: str) -> ReviewResult:
return ReviewResult(
index=0,
target='Dog',
messages=[ChatMessageUser(content=[ContentText(text='What animal is this?'), ContentImage(image=image)])],
sample_score=SampleScore(score=Score(value={'acc': 1.0}), sample_id='0'),
)


def _image_values(review_result: ReviewResult) -> List[str]:
values = []
for message in _serialize_messages(review_result):
content = message['content']
if isinstance(content, list):
values.extend(block['image'] for block in content if block['type'] == 'image')
return values


def test_local_image_path_is_absolutised_for_the_frontend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# A relative path cannot be told apart from a base64 payload by the client,
# so it must be absolutised before it reaches the dashboard.
image_path = tmp_path / 'dog.jpg'
image_path.write_bytes(b'fake-jpg')
monkeypatch.chdir(tmp_path)

assert _image_values(_review_result('dog.jpg')) == [str(image_path.resolve())]


def test_absolute_image_path_is_preserved(tmp_path: Path) -> None:
image_path = tmp_path / 'dog.jpg'
image_path.write_bytes(b'fake-jpg')

assert _image_values(_review_result(str(image_path))) == [str(image_path)]


def test_data_uri_and_base64_images_are_left_alone() -> None:
data_uri = 'data:image/png;base64,aGVsbG8='

assert _image_values(_review_result(data_uri)) == [data_uri]
assert _image_values(_review_result('aGVsbG8=')) == ['aGVsbG8=']
Loading