mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 00:31:24 +00:00
Merge pull request #930 from GuangYiDing/feat/gemini-25-flash-image-support
feat: 修复 Gemini 生图接口并新增前端生图测试能力
This commit is contained in:
@@ -176,6 +176,7 @@ const formatScopeName = (scope: string): string => {
|
||||
'gemini-2.5-flash-lite': 'G25FL',
|
||||
'gemini-2.5-flash-thinking': 'G25FT',
|
||||
'gemini-2.5-pro': 'G25P',
|
||||
'gemini-2.5-flash-image': 'G25I',
|
||||
// Gemini 3 系列
|
||||
'gemini-3-flash': 'G3F',
|
||||
'gemini-3.1-pro-high': 'G3PH',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
|
||||
>
|
||||
<Icon name="userCircle" size="md" class="text-white" :stroke-width="2" />
|
||||
<Icon name="play" size="md" class="text-white" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
||||
@@ -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
|
||||
@@ -69,25 +80,11 @@
|
||||
>
|
||||
<!-- Status Line -->
|
||||
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
|
||||
<Icon name="bolt" size="sm" :stroke-width="2" />
|
||||
<Icon name="play" size="sm" :stroke-width="2" />
|
||||
<span>{{ t('admin.accounts.readyToTest') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<Icon name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
|
||||
<span>{{ t('admin.accounts.connectingToApi') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -106,21 +103,14 @@
|
||||
v-if="status === 'success'"
|
||||
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="check" size="sm" :stroke-width="2" />
|
||||
<span>{{ t('admin.accounts.testCompleted') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
|
||||
>
|
||||
<Icon name="xCircle" size="sm" :stroke-width="2" />
|
||||
<Icon name="x" size="sm" :stroke-width="2" />
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,21 +122,48 @@
|
||||
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
|
||||
:title="t('admin.accounts.copyOutput')"
|
||||
>
|
||||
<Icon name="copy" size="sm" :stroke-width="2" />
|
||||
<Icon name="link" size="sm" :stroke-width="2" />
|
||||
</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">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="cpu" size="sm" :stroke-width="2" />
|
||||
<Icon name="grid" size="sm" :stroke-width="2" />
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chatBubble" size="sm" :stroke-width="2" />
|
||||
{{ isSoraAccount ? t('admin.accounts.soraTestMode') : t('admin.accounts.testPrompt') }}
|
||||
<Icon name="chat" size="sm" :stroke-width="2" />
|
||||
{{
|
||||
isSoraAccount
|
||||
? t('admin.accounts.soraTestMode')
|
||||
: supportsGeminiImageTest
|
||||
? t('admin.accounts.geminiImageTestMode')
|
||||
: t('admin.accounts.testPrompt')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,54 +191,15 @@
|
||||
: 'bg-primary-500 text-white hover:bg-primary-600'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
<Icon
|
||||
v-if="status === 'connecting'"
|
||||
class="h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="status === 'idle'"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
name="refresh"
|
||||
size="sm"
|
||||
class="animate-spin"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<Icon v-else-if="status === 'idle'" name="play" size="sm" :stroke-width="2" />
|
||||
<Icon v-else name="refresh" size="sm" :stroke-width="2" />
|
||||
<span>
|
||||
{{
|
||||
status === 'connecting'
|
||||
@@ -242,7 +220,8 @@ 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 Icon from '@/components/icons/Icon.vue'
|
||||
import TextArea from '@/components/common/TextArea.vue'
|
||||
import { Icon } from '@/components/icons'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, ClaudeModel } from '@/types'
|
||||
@@ -255,6 +234,11 @@ interface OutputLine {
|
||||
class: string
|
||||
}
|
||||
|
||||
interface PreviewImage {
|
||||
url: string
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
account: Account | null
|
||||
@@ -271,15 +255,37 @@ 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 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(() => {
|
||||
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')
|
||||
})
|
||||
|
||||
const sortTestModels = (models: ClaudeModel[]) => {
|
||||
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
|
||||
|
||||
return [...models].sort((a, b) => {
|
||||
const aPriority = priorityMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const bPriority = priorityMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
if (aPriority !== bPriority) return aPriority - bPriority
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// Load available models when modal opens
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
testPrompt.value = ''
|
||||
resetState()
|
||||
await loadAvailableModels()
|
||||
} else {
|
||||
@@ -288,6 +294,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') {
|
||||
@@ -300,17 +312,14 @@ const loadAvailableModels = async () => {
|
||||
loadingModels.value = true
|
||||
selectedModelId.value = '' // Reset selection before loading
|
||||
try {
|
||||
availableModels.value = await adminAPI.accounts.getAvailableModels(props.account.id)
|
||||
const models = await adminAPI.accounts.getAvailableModels(props.account.id)
|
||||
availableModels.value = props.account.platform === 'gemini' || props.account.platform === 'antigravity'
|
||||
? sortTestModels(models)
|
||||
: models
|
||||
// Default selection by platform
|
||||
if (availableModels.value.length > 0) {
|
||||
if (props.account.platform === 'gemini') {
|
||||
const preferred =
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.0-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-flash-preview') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro-preview')
|
||||
selectedModelId.value = preferred?.id || availableModels.value[0].id
|
||||
selectedModelId.value = availableModels.value[0].id
|
||||
} else {
|
||||
// Try to select Sonnet as default, otherwise use first model
|
||||
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
|
||||
@@ -332,6 +341,7 @@ const resetState = () => {
|
||||
outputLines.value = []
|
||||
streamingContent.value = ''
|
||||
errorMessage.value = ''
|
||||
generatedImages.value = []
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -385,7 +395,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() : ''
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -436,6 +451,8 @@ const handleEvent = (event: {
|
||||
model?: string
|
||||
success?: boolean
|
||||
error?: string
|
||||
image_url?: string
|
||||
mime_type?: string
|
||||
}) => {
|
||||
switch (event.type) {
|
||||
case 'test_start':
|
||||
@@ -444,7 +461,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')
|
||||
@@ -458,6 +479,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) {
|
||||
|
||||
@@ -521,7 +521,7 @@ const antigravity3FlashUsageFromAPI = computed(() => getAntigravityUsageFromAPI(
|
||||
|
||||
// Gemini Image from API
|
||||
const antigravity3ImageUsageFromAPI = computed(() =>
|
||||
getAntigravityUsageFromAPI(['gemini-3.1-flash-image', 'gemini-3-pro-image'])
|
||||
getAntigravityUsageFromAPI(['gemini-2.5-flash-image', 'gemini-3.1-flash-image', 'gemini-3-pro-image'])
|
||||
)
|
||||
|
||||
// Claude from API (all Claude model variants)
|
||||
|
||||
@@ -959,10 +959,11 @@ const allModels = [
|
||||
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
|
||||
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
|
||||
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' },
|
||||
{ value: 'gemini-3.1-flash-image', label: 'Gemini 3.1 Flash Image' },
|
||||
{ value: 'gemini-2.5-flash-image', label: 'Gemini 2.5 Flash Image' },
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-3.1-flash-image', label: 'Gemini 3.1 Flash Image' },
|
||||
{ value: 'gemini-3-pro-image', label: 'Gemini 3 Pro Image (Legacy)' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }
|
||||
@@ -1042,6 +1043,12 @@ const presetMappings = [
|
||||
to: 'claude-sonnet-4-5-20250929',
|
||||
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
},
|
||||
{
|
||||
label: 'Gemini 2.5 Image',
|
||||
from: 'gemini-2.5-flash-image',
|
||||
to: 'gemini-2.5-flash-image',
|
||||
color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400'
|
||||
},
|
||||
{
|
||||
label: 'Gemini 3.1 Image',
|
||||
from: 'gemini-3.1-flash-image',
|
||||
|
||||
@@ -32,6 +32,10 @@ describe('AccountUsageCell', () => {
|
||||
it('Antigravity 图片用量会聚合新旧 image 模型', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
antigravity_quota: {
|
||||
'gemini-2.5-flash-image': {
|
||||
utilization: 45,
|
||||
reset_time: '2026-03-01T11:00:00Z'
|
||||
},
|
||||
'gemini-3.1-flash-image': {
|
||||
utilization: 20,
|
||||
reset_time: '2026-03-01T10:00:00Z'
|
||||
|
||||
@@ -18,6 +18,10 @@ vi.mock('@/api/admin', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/accounts', () => ({
|
||||
getAntigravityDefaultModelMapping: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
return {
|
||||
|
||||
@@ -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,37 @@ 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 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(() => {
|
||||
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')
|
||||
})
|
||||
|
||||
const sortTestModels = (models: ClaudeModel[]) => {
|
||||
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
|
||||
|
||||
return [...models].sort((a, b) => {
|
||||
const aPriority = priorityMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const bPriority = priorityMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
if (aPriority !== bPriority) return aPriority - bPriority
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// Load available models when modal opens
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
testPrompt.value = ''
|
||||
resetState()
|
||||
await loadAvailableModels()
|
||||
} else {
|
||||
@@ -228,6 +294,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') {
|
||||
@@ -240,17 +312,14 @@ const loadAvailableModels = async () => {
|
||||
loadingModels.value = true
|
||||
selectedModelId.value = '' // Reset selection before loading
|
||||
try {
|
||||
availableModels.value = await adminAPI.accounts.getAvailableModels(props.account.id)
|
||||
const models = await adminAPI.accounts.getAvailableModels(props.account.id)
|
||||
availableModels.value = props.account.platform === 'gemini' || props.account.platform === 'antigravity'
|
||||
? sortTestModels(models)
|
||||
: models
|
||||
// Default selection by platform
|
||||
if (availableModels.value.length > 0) {
|
||||
if (props.account.platform === 'gemini') {
|
||||
const preferred =
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.0-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-flash') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-flash-preview') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro-preview')
|
||||
selectedModelId.value = preferred?.id || availableModels.value[0].id
|
||||
selectedModelId.value = availableModels.value[0].id
|
||||
} else {
|
||||
// Try to select Sonnet as default, otherwise use first model
|
||||
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
|
||||
@@ -272,6 +341,7 @@ const resetState = () => {
|
||||
outputLines.value = []
|
||||
streamingContent.value = ''
|
||||
errorMessage.value = ''
|
||||
generatedImages.value = []
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -325,7 +395,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 +451,8 @@ const handleEvent = (event: {
|
||||
model?: string
|
||||
success?: boolean
|
||||
error?: string
|
||||
image_url?: string
|
||||
mime_type?: string
|
||||
}) => {
|
||||
switch (event.type) {
|
||||
case 'test_start':
|
||||
@@ -384,7 +461,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 +479,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) {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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.0-flash', display_name: 'Gemini 2.0 Flash' },
|
||||
{ id: 'gemini-2.5-flash-image', display_name: 'Gemini 2.5 Flash Image' },
|
||||
{ id: 'gemini-3.1-flash-image', display_name: 'Gemini 3.1 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-3.1-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')
|
||||
})
|
||||
})
|
||||
@@ -959,6 +959,23 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin
|
||||
}
|
||||
}
|
||||
},
|
||||
'gemini-2.5-flash-image': {
|
||||
name: 'Gemini 2.5 Flash Image',
|
||||
limit: {
|
||||
context: 1048576,
|
||||
output: 65536
|
||||
},
|
||||
modalities: {
|
||||
input: ['text', 'image'],
|
||||
output: ['image']
|
||||
},
|
||||
options: {
|
||||
thinking: {
|
||||
budgetTokens: 24576,
|
||||
type: 'enabled'
|
||||
}
|
||||
}
|
||||
},
|
||||
'gemini-3.1-flash-image': {
|
||||
name: 'Gemini 3.1 Flash Image',
|
||||
limit: {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/api/admin/accounts', () => ({
|
||||
getAntigravityDefaultModelMapping: vi.fn()
|
||||
}))
|
||||
|
||||
import { buildModelMappingObject, getModelsByPlatform } from '../useModelWhitelist'
|
||||
|
||||
describe('useModelWhitelist', () => {
|
||||
@@ -12,10 +17,27 @@ describe('useModelWhitelist', () => {
|
||||
it('antigravity 模型列表包含图片模型兼容项', () => {
|
||||
const models = getModelsByPlatform('antigravity')
|
||||
|
||||
expect(models).toContain('gemini-2.5-flash-image')
|
||||
expect(models).toContain('gemini-3.1-flash-image')
|
||||
expect(models).toContain('gemini-3-pro-image')
|
||||
})
|
||||
|
||||
it('gemini 模型列表包含原生生图模型', () => {
|
||||
const models = getModelsByPlatform('gemini')
|
||||
|
||||
expect(models).toContain('gemini-2.5-flash-image')
|
||||
expect(models).toContain('gemini-3.1-flash-image')
|
||||
expect(models.indexOf('gemini-3.1-flash-image')).toBeLessThan(models.indexOf('gemini-2.0-flash'))
|
||||
expect(models.indexOf('gemini-2.5-flash-image')).toBeLessThan(models.indexOf('gemini-2.5-flash'))
|
||||
})
|
||||
|
||||
it('antigravity 模型列表会把新的 Gemini 图片模型排在前面', () => {
|
||||
const models = getModelsByPlatform('antigravity')
|
||||
|
||||
expect(models.indexOf('gemini-3.1-flash-image')).toBeLessThan(models.indexOf('gemini-2.5-flash'))
|
||||
expect(models.indexOf('gemini-2.5-flash-image')).toBeLessThan(models.indexOf('gemini-2.5-flash-lite'))
|
||||
})
|
||||
|
||||
it('whitelist 模式会忽略通配符条目', () => {
|
||||
const mapping = buildModelMappingObject('whitelist', ['claude-*', 'gemini-3.1-flash-image'], [])
|
||||
expect(mapping).toEqual({
|
||||
|
||||
@@ -51,6 +51,8 @@ export const claudeModels = [
|
||||
const geminiModels = [
|
||||
// Keep in sync with backend curated Gemini lists.
|
||||
// This list is intentionally conservative (models commonly available across OAuth/API key).
|
||||
'gemini-3.1-flash-image',
|
||||
'gemini-2.5-flash-image',
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
@@ -85,6 +87,8 @@ const antigravityModels = [
|
||||
'claude-sonnet-4-5',
|
||||
'claude-sonnet-4-5-thinking',
|
||||
// Gemini 2.5 系列
|
||||
'gemini-3.1-flash-image',
|
||||
'gemini-2.5-flash-image',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemini-2.5-flash-thinking',
|
||||
@@ -96,7 +100,6 @@ const antigravityModels = [
|
||||
// Gemini 3.1 系列
|
||||
'gemini-3.1-pro-high',
|
||||
'gemini-3.1-pro-low',
|
||||
'gemini-3.1-flash-image',
|
||||
'gemini-3-pro-image',
|
||||
// 其他
|
||||
'gpt-oss-120b-medium',
|
||||
@@ -291,7 +294,9 @@ const soraPresetMappings: { label: string; from: string; to: string; color: stri
|
||||
const geminiPresetMappings = [
|
||||
{ label: 'Flash 2.0', from: 'gemini-2.0-flash', to: 'gemini-2.0-flash', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ label: '2.5 Flash', from: 'gemini-2.5-flash', to: 'gemini-2.5-flash', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
||||
{ label: '2.5 Pro', from: 'gemini-2.5-pro', to: 'gemini-2.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }
|
||||
{ label: '2.5 Image', from: 'gemini-2.5-flash-image', to: 'gemini-2.5-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: '2.5 Pro', from: 'gemini-2.5-pro', to: 'gemini-2.5-pro', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ label: '3.1 Image', from: 'gemini-3.1-flash-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' }
|
||||
]
|
||||
|
||||
// Antigravity 预设映射(支持通配符)
|
||||
@@ -314,6 +319,9 @@ const antigravityPresetMappings = [
|
||||
// Gemini 通配符映射
|
||||
{ label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
|
||||
{ label: 'Gemini 2.5→Flash', from: 'gemini-2.5*', to: 'gemini-2.5-flash', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: '2.5-Flash-Image透传', from: 'gemini-2.5-flash-image', to: 'gemini-2.5-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: '3.1-Flash-Image透传', from: 'gemini-3.1-flash-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: '3-Pro-Image→3.1', from: 'gemini-3-pro-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: '3-Flash透传', from: 'gemini-3-flash', to: 'gemini-3-flash', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
|
||||
{ label: '2.5-Flash-Lite透传', from: 'gemini-2.5-flash-lite', to: 'gemini-2.5-flash-lite', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
|
||||
// 精确映射
|
||||
|
||||
@@ -2416,6 +2416,7 @@ export default {
|
||||
connectedToApi: 'Connected to API',
|
||||
usingModel: 'Using model: {model}',
|
||||
sendingTestMessage: 'Sending test message: "hi"',
|
||||
sendingGeminiImageRequest: 'Sending Gemini image generation test request...',
|
||||
response: 'Response:',
|
||||
startTest: 'Start Test',
|
||||
testing: 'Testing...',
|
||||
@@ -2427,6 +2428,13 @@ export default {
|
||||
selectTestModel: 'Select Test Model',
|
||||
testModel: 'Test model',
|
||||
testPrompt: 'Prompt: "hi"',
|
||||
geminiImagePromptLabel: 'Image prompt',
|
||||
geminiImagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
|
||||
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||
geminiImageTestHint: 'When a Gemini image model is selected, this test sends a real image-generation request and previews the returned image below.',
|
||||
geminiImageTestMode: 'Mode: Gemini image generation test',
|
||||
geminiImagePreview: 'Generated images:',
|
||||
geminiImageReceived: 'Received test image #{count}',
|
||||
soraUpstreamBaseUrlHint: 'Upstream Sora service URL (another Sub2API instance or compatible API)',
|
||||
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
|
||||
soraTestTarget: 'Target: Sora account capability',
|
||||
|
||||
@@ -2545,6 +2545,7 @@ export default {
|
||||
connectedToApi: '已连接到 API',
|
||||
usingModel: '使用模型:{model}',
|
||||
sendingTestMessage: '发送测试消息:"hi"',
|
||||
sendingGeminiImageRequest: '发送 Gemini 生图测试请求...',
|
||||
response: '响应:',
|
||||
startTest: '开始测试',
|
||||
retry: '重试',
|
||||
@@ -2555,6 +2556,13 @@ export default {
|
||||
selectTestModel: '选择测试模型',
|
||||
testModel: '测试模型',
|
||||
testPrompt: '提示词:"hi"',
|
||||
geminiImagePromptLabel: '生图提示词',
|
||||
geminiImagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
|
||||
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||
geminiImageTestHint: '选择 Gemini 图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
|
||||
geminiImageTestMode: '模式:Gemini 生图测试',
|
||||
geminiImagePreview: '生成结果:',
|
||||
geminiImageReceived: '已收到第 {count} 张测试图片',
|
||||
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 Sub2API 实例或兼容 API)',
|
||||
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
|
||||
soraTestTarget: '检测目标:Sora 账号能力',
|
||||
|
||||
Reference in New Issue
Block a user