feat(provider): show image previews in model test dialog

This commit is contained in:
ZheFox
2026-05-20 19:59:20 +08:00
parent a31e237cc3
commit ab2287202d
3 changed files with 146 additions and 8 deletions

View File

@@ -393,6 +393,39 @@
</div>
</div>
<div
v-if="resultImagePreviews.length > 0"
class="rounded-lg border border-border/60 bg-muted/20 p-3 space-y-3"
>
<div class="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>图片预览</span>
<span>{{ resultImagePreviews.length }} </span>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<button
v-for="(preview, index) in resultImagePreviews"
:key="`${preview.src}-${index}`"
type="button"
class="group overflow-hidden rounded-md border border-border/60 bg-background text-left transition-colors hover:border-primary/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/70"
:title="preview.label"
@click="openImagePreview(preview)"
>
<div class="aspect-square w-full overflow-hidden bg-muted/30">
<img
:src="preview.src"
:alt="preview.label"
class="h-full w-full object-contain"
loading="lazy"
>
</div>
<div class="flex items-center justify-between gap-2 border-t border-border/60 px-2 py-1 text-[11px] text-muted-foreground">
<span>{{ preview.label }}</span>
<span>{{ preview.source === 'base64' ? 'base64' : 'URL' }}</span>
</div>
</button>
</div>
</div>
<div
v-if="shouldCollapseAttempts"
class="flex items-center justify-between gap-3 text-xs text-muted-foreground"
@@ -935,11 +968,17 @@ const activeImagePreview = ref<ModelTestImagePreview | null>(null)
watch(() => props.result, () => {
showAllAttempts.value = false
inspectionTab.value = 'request-body'
inspectionExpandDepth.value = 0
inspectionCopiedStates.value = {}
const defaultAttempt = inspectableAttempts.value[0] ?? resultAttempts.value[0] ?? null
const defaultAttempt = resultImageAttempt.value
?? resultAttempts.value.find(attempt => attempt.status === 'success')
?? inspectableAttempts.value[0]
?? resultAttempts.value[0]
?? null
selectedInspectionKey.value = defaultAttempt ? inspectionKey(defaultAttempt) : null
inspectionTab.value = defaultAttempt && attemptImagePreviews(defaultAttempt).length > 0
? 'response-body'
: 'request-body'
})
const shouldCollapseAttempts = computed(() => resultAttempts.value.length > 20)
@@ -1251,6 +1290,18 @@ const selectedInspectionImagePreviews = computed(() => (
: []
))
const resultImageAttempt = computed(() => {
return resultAttempts.value.find(attempt => attempt.status === 'success' && attemptImagePreviews(attempt).length > 0)
?? resultAttempts.value.find(attempt => attemptImagePreviews(attempt).length > 0)
?? null
})
const resultImagePreviews = computed(() => (
resultImageAttempt.value
? extractModelTestImagePreviews(resultImageAttempt.value.response_body)
: []
))
const resultWinningTitle = computed(() => {
const summary = resultSummary.value
const keyName = summary.winning_key_name || summary.winning_key_id
@@ -1432,7 +1483,7 @@ function inspectionKey(attempt: TestAttemptDetail): string {
function selectInspectionAttempt(attempt: TestAttemptDetail) {
selectedInspectionKey.value = inspectionKey(attempt)
inspectionTab.value = 'request-body'
inspectionTab.value = attemptImagePreviews(attempt).length > 0 ? 'response-body' : 'request-body'
}
function hasDebugData(attempt: TestAttemptDetail): boolean {

View File

@@ -479,6 +479,23 @@ describe('extractModelTestResponsePreview', () => {
})).toBe('图片https://example.com/generated.png')
})
it('extracts renderable URL image previews from OpenAI image responses', () => {
expect(extractModelTestImagePreviews({
data: [
{
url: 'https://example.com/generated.png',
revised_prompt: 'A generated image',
},
],
})).toEqual([
{
src: 'https://example.com/generated.png',
label: '图片 1',
source: 'url',
},
])
})
it('summarizes base64 image responses without dumping the image payload', () => {
expect(extractModelTestResponsePreview({
data: [
@@ -506,6 +523,55 @@ describe('extractModelTestResponsePreview', () => {
])
})
it('accepts root-relative URLs in OpenAI image responses', () => {
expect(extractModelTestImagePreviews({
data: [
{
url: '/v1/files/image?id=2af14311-a0cb-4bbf-ae20-d1fcf44e0479',
},
],
})).toEqual([
{
src: '/v1/files/image?id=2af14311-a0cb-4bbf-ae20-d1fcf44e0479',
label: '图片 1',
source: 'url',
},
])
})
it('parses stringified JSON image responses', () => {
expect(extractModelTestImagePreviews(JSON.stringify({
data: [
{
url: 'https://example.com/generated.png',
},
],
}))).toEqual([
{
src: 'https://example.com/generated.png',
label: '图片 1',
source: 'url',
},
])
})
it('parses stringified JSON base64 image responses', () => {
expect(extractModelTestImagePreviews(JSON.stringify({
data: [
{
b64_json: 'aGVsbG8=',
mime_type: 'image/jpeg',
},
],
}))).toEqual([
{
src: 'data:image/jpeg;base64,aGVsbG8=',
label: '图片 1',
source: 'base64',
},
])
})
it('extracts image previews from nested response image urls', () => {
expect(extractModelTestImagePreviews({
output: [

View File

@@ -10,16 +10,17 @@ export type ModelTestImagePreview = {
}
export function extractModelTestResponsePreview(responseBody: unknown): string | null {
const text = extractResponseText(responseBody)
const normalizedBody = normalizeModelTestResponseBody(responseBody)
const text = extractResponseText(normalizedBody)
if (text) return text
const reasoning = extractResponseReasoning(responseBody)
const reasoning = extractResponseReasoning(normalizedBody)
if (reasoning) return `推理:${reasoning}`
const image = extractImagePreview(responseBody)
const image = extractImagePreview(normalizedBody)
if (image) return image
const summary = extractResponseSummary(responseBody)
const summary = extractResponseSummary(normalizedBody)
if (summary) return summary
return null
@@ -27,10 +28,26 @@ export function extractModelTestResponsePreview(responseBody: unknown): string |
export function extractModelTestImagePreviews(responseBody: unknown): ModelTestImagePreview[] {
const previews: ModelTestImagePreview[] = []
collectImagePreviews(responseBody, previews, new Set(), 0)
collectImagePreviews(normalizeModelTestResponseBody(responseBody), previews, new Set(), 0)
return previews
}
function normalizeModelTestResponseBody(value: unknown): unknown {
if (typeof value !== 'string') return value
const trimmed = value.trim()
if (!trimmed) return value
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
return JSON.parse(trimmed)
} catch {
return value
}
}
return value
}
function isJsonRecord(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
@@ -146,6 +163,7 @@ function collectImagePreviews(
value.output,
value.images,
value.content,
value.item,
]
for (const nested of nestedValues) {
collectImagePreviews(nested, previews, seen, depth + 1)
@@ -203,6 +221,9 @@ function imageUrlToPreview(value: unknown, source: 'url'): ModelTestImagePreview
if (url.startsWith('data:image/')) {
return { src: url, label: 'base64', source: 'base64' }
}
if (url.startsWith('/')) {
return { src: url, label: 'URL', source }
}
try {
const parsed = new URL(url)