feat: add gemini image test preview

This commit is contained in:
Rose Ding
2026-03-11 17:12:57 +08:00
parent 1c0519f1c7
commit bf6585a40f
8 changed files with 478 additions and 103 deletions

View File

@@ -61,6 +61,17 @@
{{ t('admin.accounts.soraTestHint') }}
</div>
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.geminiImagePromptLabel')"
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
:hint="t('admin.accounts.geminiImageTestHint')"
:disabled="status === 'connecting'"
rows="3"
/>
</div>
<!-- Terminal Output -->
<div class="group relative">
<div
@@ -115,6 +126,27 @@
</button>
</div>
<div v-if="generatedImages.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.geminiImagePreview') }}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="(image, index) in generatedImages"
:key="`${image.url}-${index}`"
:href="image.url"
target="_blank"
rel="noopener noreferrer"
class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition hover:border-primary-300 hover:shadow-md dark:border-dark-500 dark:bg-dark-700"
>
<img :src="image.url" :alt="`gemini-test-image-${index + 1}`" class="h-48 w-full object-cover" />
<div class="border-t border-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
{{ image.mimeType || 'image/*' }}
</div>
</a>
</div>
</div>
<!-- Test Info -->
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
@@ -125,7 +157,13 @@
</div>
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{ isSoraAccount ? t('admin.accounts.soraTestMode') : t('admin.accounts.testPrompt') }}
{{
isSoraAccount
? t('admin.accounts.soraTestMode')
: supportsGeminiImageTest
? t('admin.accounts.geminiImageTestMode')
: t('admin.accounts.testPrompt')
}}
</span>
</div>
</div>
@@ -182,6 +220,7 @@ import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import TextArea from '@/components/common/TextArea.vue'
import { Icon } from '@/components/icons'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
@@ -195,6 +234,11 @@ interface OutputLine {
class: string
}
interface PreviewImage {
url: string
mimeType?: string
}
const props = defineProps<{
show: boolean
account: Account | null
@@ -211,15 +255,25 @@ const streamingContent = ref('')
const errorMessage = ref('')
const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('')
const testPrompt = ref('')
const loadingModels = ref(false)
let eventSource: EventSource | null = null
const isSoraAccount = computed(() => props.account?.platform === 'sora')
const generatedImages = ref<PreviewImage[]>([])
const supportsGeminiImageTest = computed(() => {
if (isSoraAccount.value) return false
const modelID = selectedModelId.value.toLowerCase()
if (!modelID.startsWith('gemini-') || !modelID.includes('-image')) return false
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
})
// Load available models when modal opens
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
testPrompt.value = ''
resetState()
await loadAvailableModels()
} else {
@@ -228,6 +282,12 @@ watch(
}
)
watch(selectedModelId, () => {
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
}
})
const loadAvailableModels = async () => {
if (!props.account) return
if (props.account.platform === 'sora') {
@@ -272,6 +332,7 @@ const resetState = () => {
outputLines.value = []
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
}
const handleClose = () => {
@@ -325,7 +386,12 @@ const startTest = async () => {
'Content-Type': 'application/json'
},
body: JSON.stringify(
isSoraAccount.value ? {} : { model_id: selectedModelId.value }
isSoraAccount.value
? {}
: {
model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
}
)
})
@@ -376,6 +442,8 @@ const handleEvent = (event: {
model?: string
success?: boolean
error?: string
image_url?: string
mime_type?: string
}) => {
switch (event.type) {
case 'test_start':
@@ -384,7 +452,11 @@ const handleEvent = (event: {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
isSoraAccount.value ? t('admin.accounts.soraTestingFlow') : t('admin.accounts.sendingTestMessage'),
isSoraAccount.value
? t('admin.accounts.soraTestingFlow')
: supportsGeminiImageTest.value
? t('admin.accounts.sendingGeminiImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
)
addLine('', 'text-gray-300')
@@ -398,6 +470,16 @@ const handleEvent = (event: {
}
break
case 'image':
if (event.image_url) {
generatedImages.value.push({
url: event.image_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
}
break
case 'test_complete':
// Move streaming content to output lines
if (streamingContent.value) {

View File

@@ -0,0 +1,145 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AccountTestModal from '../AccountTestModal.vue'
const { getAvailableModels, copyToClipboard } = vi.hoisted(() => ({
getAvailableModels: vi.fn(),
copyToClipboard: vi.fn()
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
accounts: {
getAvailableModels
}
}
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({
copyToClipboard
})
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
const messages: Record<string, string> = {
'admin.accounts.geminiImagePromptDefault': 'Generate a cute orange cat astronaut sticker on a clean pastel background.'
}
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, string | number>) => {
if (key === 'admin.accounts.geminiImageReceived' && params?.count) {
return `received-${params.count}`
}
return messages[key] || key
}
})
}
})
function createStreamResponse(lines: string[]) {
const encoder = new TextEncoder()
const chunks = lines.map((line) => encoder.encode(line))
let index = 0
return {
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockImplementation(async () => {
if (index < chunks.length) {
return { done: false, value: chunks[index++] }
}
return { done: true, value: undefined }
})
})
}
} as Response
}
function mountModal() {
return mount(AccountTestModal, {
props: {
show: false,
account: {
id: 42,
name: 'Gemini Image Test',
platform: 'gemini',
type: 'apikey',
status: 'active'
}
} as any,
global: {
stubs: {
BaseDialog: { template: '<div><slot /><slot name="footer" /></div>' },
Select: { template: '<div class="select-stub"></div>' },
TextArea: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: '<textarea class="textarea-stub" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />'
},
Icon: true
}
}
})
}
describe('AccountTestModal', () => {
beforeEach(() => {
getAvailableModels.mockResolvedValue([
{ id: 'gemini-2.5-flash-image', display_name: 'Gemini 2.5 Flash Image' }
])
copyToClipboard.mockReset()
Object.defineProperty(globalThis, 'localStorage', {
value: {
getItem: vi.fn((key: string) => (key === 'auth_token' ? 'test-token' : null)),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
},
configurable: true
})
global.fetch = vi.fn().mockResolvedValue(
createStreamResponse([
'data: {"type":"test_start","model":"gemini-2.5-flash-image"}\n',
'data: {"type":"image","image_url":"data:image/png;base64,QUJD","mime_type":"image/png"}\n',
'data: {"type":"test_complete","success":true}\n'
])
) as any
})
afterEach(() => {
vi.restoreAllMocks()
})
it('gemini 图片模型测试会携带提示词并渲染图片预览', async () => {
const wrapper = mountModal()
await wrapper.setProps({ show: true })
await flushPromises()
const promptInput = wrapper.find('textarea.textarea-stub')
expect(promptInput.exists()).toBe(true)
await promptInput.setValue('draw a tiny orange cat astronaut')
const buttons = wrapper.findAll('button')
const startButton = buttons.find((button) => button.text().includes('admin.accounts.startTest'))
expect(startButton).toBeTruthy()
await startButton!.trigger('click')
await flushPromises()
await flushPromises()
expect(global.fetch).toHaveBeenCalledTimes(1)
const [, request] = (global.fetch as any).mock.calls[0]
expect(JSON.parse(request.body)).toEqual({
model_id: 'gemini-2.5-flash-image',
prompt: 'draw a tiny orange cat astronaut'
})
const preview = wrapper.find('img[alt="gemini-test-image-1"]')
expect(preview.exists()).toBe(true)
expect(preview.attributes('src')).toBe('data:image/png;base64,QUJD')
})
})