Merge remote-tracking branch 'upstream/main' into rebuild/auth-identity-foundation

# Conflicts:
#	backend/internal/service/openai_images.go
This commit is contained in:
IanShaw027
2026-04-22 18:13:05 +08:00
11 changed files with 523 additions and 71 deletions

View File

@@ -55,12 +55,12 @@
/>
</div>
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
<div v-if="supportsImageTest" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.geminiImagePromptLabel')"
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
:hint="t('admin.accounts.geminiImageTestHint')"
:label="t('admin.accounts.imagePromptLabel')"
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
:hint="t('admin.accounts.imageTestHint')"
:disabled="status === 'connecting'"
rows="3"
/>
@@ -122,25 +122,49 @@
<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') }}
{{ t('admin.accounts.imagePreview') }}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<a
<div class="flex flex-wrap justify-center gap-3">
<div
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"
class="group/img relative cursor-pointer 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"
@click="previewImageUrl = image.url"
>
<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">
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
</div>
<div class="border-t border-gray-100 px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
{{ image.mimeType || 'image/*' }}
</div>
</a>
</div>
</div>
</div>
<!-- Image Lightbox -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="previewImageUrl"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
@click.self="previewImageUrl = ''"
>
<button
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="previewImageUrl = ''"
>
<Icon name="x" size="lg" :stroke-width="2" />
</button>
<img
:src="previewImageUrl"
alt="preview"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
/>
</div>
</Transition>
</Teleport>
<!-- 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">
@@ -152,8 +176,8 @@
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{
supportsGeminiImageTest
? t('admin.accounts.geminiImageTestMode')
supportsImageTest
? t('admin.accounts.imageTestMode')
: t('admin.accounts.testPrompt')
}}
</span>
@@ -250,6 +274,7 @@ const testPrompt = ref('')
const loadingModels = ref(false)
let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([])
const previewImageUrl = ref('')
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
const modelID = selectedModelId.value.toLowerCase()
@@ -258,6 +283,14 @@ const supportsGeminiImageTest = computed(() => {
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
})
const supportsOpenAIImageTest = computed(() => {
const modelID = selectedModelId.value.toLowerCase()
if (!modelID.startsWith('gpt-image-')) return false
return props.account?.platform === 'openai'
})
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
const sortTestModels = (models: ClaudeModel[]) => {
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
@@ -284,8 +317,8 @@ watch(
)
watch(selectedModelId, () => {
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
if (supportsImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.imagePromptDefault')
}
})
@@ -325,6 +358,7 @@ const resetState = () => {
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
previewImageUrl.value = ''
}
const handleClose = () => {
@@ -377,7 +411,7 @@ const startTest = async () => {
},
body: JSON.stringify({
model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
}),
signal: abortController.signal
})
@@ -444,8 +478,8 @@ const handleEvent = (event: {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
supportsGeminiImageTest.value
? t('admin.accounts.sendingGeminiImageRequest')
supportsImageTest.value
? t('admin.accounts.sendingImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
)
@@ -466,7 +500,7 @@ const handleEvent = (event: {
url: event.image_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
addLine(t('admin.accounts.imageReceived', { count: generatedImages.value.length }), 'text-purple-300')
}
break
@@ -500,3 +534,14 @@ const copyOutput = () => {
copyToClipboard(text, t('admin.accounts.outputCopied'))
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -55,12 +55,12 @@
/>
</div>
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
<div v-if="supportsImageTest" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.geminiImagePromptLabel')"
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
:hint="t('admin.accounts.geminiImageTestHint')"
:label="t('admin.accounts.imagePromptLabel')"
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
:hint="t('admin.accounts.imageTestHint')"
:disabled="status === 'connecting'"
rows="3"
/>
@@ -122,25 +122,49 @@
<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') }}
{{ t('admin.accounts.imagePreview') }}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<a
<div class="flex flex-wrap justify-center gap-3">
<div
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"
class="group/img relative cursor-pointer 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"
@click="previewImageUrl = image.url"
>
<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">
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
</div>
<div class="border-t border-gray-100 px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
{{ image.mimeType || 'image/*' }}
</div>
</a>
</div>
</div>
</div>
<!-- Image Lightbox -->
<Teleport to="body">
<Transition name="fade">
<div
v-if="previewImageUrl"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
@click.self="previewImageUrl = ''"
>
<button
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="previewImageUrl = ''"
>
<Icon name="x" size="lg" :stroke-width="2" />
</button>
<img
:src="previewImageUrl"
alt="preview"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
/>
</div>
</Transition>
</Teleport>
<!-- 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">
@@ -152,8 +176,8 @@
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{
supportsGeminiImageTest
? t('admin.accounts.geminiImageTestMode')
supportsImageTest
? t('admin.accounts.imageTestMode')
: t('admin.accounts.testPrompt')
}}
</span>
@@ -250,6 +274,7 @@ const testPrompt = ref('')
const loadingModels = ref(false)
let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([])
const previewImageUrl = ref('')
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-3-flash-preview', 'gemini-3-pro-preview', 'gemini-2.0-flash']
const supportsGeminiImageTest = computed(() => {
const modelID = selectedModelId.value.toLowerCase()
@@ -258,6 +283,14 @@ const supportsGeminiImageTest = computed(() => {
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
})
const supportsOpenAIImageTest = computed(() => {
const modelID = selectedModelId.value.toLowerCase()
if (!modelID.startsWith('gpt-image-')) return false
return props.account?.platform === 'openai'
})
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
const sortTestModels = (models: ClaudeModel[]) => {
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
@@ -284,8 +317,8 @@ watch(
)
watch(selectedModelId, () => {
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
if (supportsImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.imagePromptDefault')
}
})
@@ -325,6 +358,7 @@ const resetState = () => {
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
previewImageUrl.value = ''
}
const handleClose = () => {
@@ -377,7 +411,7 @@ const startTest = async () => {
},
body: JSON.stringify({
model_id: selectedModelId.value,
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
}),
signal: abortController.signal
})
@@ -444,8 +478,8 @@ const handleEvent = (event: {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
supportsGeminiImageTest.value
? t('admin.accounts.sendingGeminiImageRequest')
supportsImageTest.value
? t('admin.accounts.sendingImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
)
@@ -466,7 +500,7 @@ const handleEvent = (event: {
url: event.image_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
addLine(t('admin.accounts.imageReceived', { count: generatedImages.value.length }), 'text-purple-300')
}
break
@@ -500,3 +534,14 @@ const copyOutput = () => {
copyToClipboard(text, t('admin.accounts.outputCopied'))
}
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -24,13 +24,13 @@ vi.mock('@/composables/useClipboard', () => ({
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.'
'admin.accounts.imagePromptDefault': '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) {
if (key === 'admin.accounts.imageReceived' && params?.count) {
return `received-${params.count}`
}
return messages[key] || key
@@ -140,7 +140,7 @@ describe('AccountTestModal', () => {
prompt: 'draw a tiny orange cat astronaut'
})
const preview = wrapper.find('img[alt="gemini-test-image-1"]')
const preview = wrapper.find('img[alt="test-image-1"]')
expect(preview.exists()).toBe(true)
expect(preview.attributes('src')).toBe('data:image/png;base64,QUJD')
})