feat(grok): admin account test modes with real media preview

Add mode-first connectivity probes for text/image/video/search/tts/stt/realtime,
standalone voice and web-search paths, media upload options, and in-browser
image/audio/video preview (including ZDR-safe b64 images and edit validation).
This commit is contained in:
IanShaw027
2026-08-08 01:07:08 +08:00
parent 72a56f862c
commit d0767eab9d
7 changed files with 2145 additions and 86 deletions
@@ -41,13 +41,28 @@
</span>
</div>
<div class="space-y-1.5">
<!-- Grok: mode first, then optional model / mode params -->
<div v-if="isGrokAccount" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.grok.testMode') }}
</label>
<Select
v-model="grokTestMode"
:options="grokTestModeOptions"
:disabled="status === 'connecting'"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.grok.testModeHint') }}
</p>
</div>
<div v-if="showModelSelect" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }}
</label>
<Select
v-model="selectedModelId"
:options="availableModels"
:options="modelOptionsForMode"
:disabled="loadingModels || status === 'connecting'"
value-key="id"
label-key="display_name"
@@ -66,16 +81,69 @@
/>
</div>
<div v-if="supportsImageTest" class="space-y-1.5">
<div v-if="supportsPromptInput" class="space-y-1.5">
<TextArea
v-model="testPrompt"
:label="t('admin.accounts.imagePromptLabel')"
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
:hint="t('admin.accounts.imageTestHint')"
:label="promptInputLabel"
:placeholder="promptInputPlaceholder"
:hint="promptInputHint"
:disabled="status === 'connecting'"
rows="3"
/>
</div>
<p
v-else-if="isGrokAccount && promptInputHint"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ promptInputHint }}
</p>
<!-- Optional media uploads for real generation / transcription -->
<div v-if="supportsImageUpload" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ imageUploadLabel }}
</label>
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
class="block w-full text-sm text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-primary-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-300 dark:file:bg-primary-500/20 dark:file:text-primary-300"
:disabled="status === 'connecting'"
@change="onImageFileChange"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.imageUploadHint') }}</p>
<div v-if="uploadImagePreview" class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-500">
<img :src="uploadImagePreview" alt="upload preview" class="max-h-40 w-full object-contain bg-gray-50 dark:bg-dark-700" />
</div>
</div>
<div v-if="supportsAudioUpload" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.grok.audioUploadLabel') }}
</label>
<input
type="file"
accept="audio/*,.wav,.mp3,.m4a,.ogg,.webm"
class="block w-full text-sm text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-primary-50 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-700 hover:file:bg-primary-100 dark:text-gray-300 dark:file:bg-primary-500/20 dark:file:text-primary-300"
:disabled="status === 'connecting'"
@change="onAudioFileChange"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.audioUploadHint') }}</p>
<p v-if="uploadAudioName" class="text-xs text-gray-600 dark:text-gray-300">{{ uploadAudioName }}</p>
</div>
<div v-if="supportsVideoUploadURL" class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.grok.videoUploadUrlLabel') }}
</label>
<input
v-model="videoUploadURL"
type="url"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100"
:placeholder="t('admin.accounts.grok.videoUploadUrlPlaceholder')"
:disabled="status === 'connecting'"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.grok.videoUploadUrlHint') }}</p>
</div>
<!-- Terminal Output -->
<div class="group relative">
@@ -153,6 +221,36 @@
</div>
</div>
<div v-if="generatedAudios.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.audioPreview') }}
</div>
<div
v-for="(audio, index) in generatedAudios"
:key="`audio-${index}`"
class="rounded-xl border border-gray-200 bg-white p-3 dark:border-dark-500 dark:bg-dark-700"
>
<audio :src="audio.url" controls class="w-full" :type="audio.mimeType" />
<div class="mt-1 text-xs text-gray-500 dark:text-gray-300">{{ audio.mimeType || 'audio/*' }}</div>
</div>
</div>
<div v-if="generatedVideos.length > 0" class="space-y-2">
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
{{ t('admin.accounts.videoPreview') }}
</div>
<div
v-for="(video, index) in generatedVideos"
:key="`video-${index}`"
class="overflow-hidden rounded-xl border border-gray-200 bg-black dark:border-dark-500"
>
<video :src="video.url" controls class="max-h-[360px] w-full" :type="video.mimeType" />
<div class="border-t border-gray-100 bg-white px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-300">
{{ video.mimeType || 'video/*' }}
</div>
</div>
</div>
<!-- Image Lightbox -->
<Teleport to="body">
<Transition name="fade">
@@ -186,11 +284,7 @@
</div>
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{
supportsImageTest
? t('admin.accounts.imageTestMode')
: t('admin.accounts.testPrompt')
}}
{{ testModeSummary }}
</span>
</div>
</div>
@@ -205,10 +299,10 @@
</button>
<button
@click="startTest"
:disabled="status === 'connecting' || !selectedModelId"
:disabled="!canStartTest"
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId
!canStartTest
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
@@ -262,7 +356,7 @@ interface OutputLine {
class: string
}
interface PreviewImage {
interface PreviewMedia {
url: string
mimeType?: string
}
@@ -286,14 +380,32 @@ const selectedModelId = ref('')
const testPrompt = ref('')
const loadingModels = ref(false)
let abortController: AbortController | null = null
const generatedImages = ref<PreviewImage[]>([])
const generatedImages = ref<PreviewMedia[]>([])
const generatedAudios = ref<PreviewMedia[]>([])
const generatedVideos = ref<PreviewMedia[]>([])
const previewImageUrl = ref('')
const testMode = ref<'default' | 'compact'>('default')
const grokTestMode = ref<'text' | 'image' | 'video' | 'search' | 'tts' | 'stt' | 'realtime'>('text')
const uploadImageDataURL = ref('')
const uploadImagePreview = ref('')
const uploadAudioDataURL = ref('')
const uploadAudioName = ref('')
const videoUploadURL = ref('')
const isOpenAIAccount = computed(() => props.account?.platform === 'openai')
const isGrokAccount = computed(() => props.account?.platform === 'grok')
const openAITestModeOptions = computed(() => [
{ value: 'default', label: t('admin.accounts.openai.testModeDefault') },
{ value: 'compact', label: t('admin.accounts.openai.testModeCompact') }
])
const grokTestModeOptions = computed(() => [
{ value: 'text', label: t('admin.accounts.grok.testModeText') },
{ value: 'image', label: t('admin.accounts.grok.testModeImage') },
{ value: 'video', label: t('admin.accounts.grok.testModeVideo') },
{ value: 'search', label: t('admin.accounts.grok.testModeSearch') },
{ value: 'tts', label: t('admin.accounts.grok.testModeTTS') },
{ value: 'stt', label: t('admin.accounts.grok.testModeSTT') },
{ value: 'realtime', label: t('admin.accounts.grok.testModeRealtime') }
])
const prioritizedGeminiModels = ['gemini-3.1-flash-image', 'gemini-2.5-flash-image', 'gemini-3.5-flash', '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()
@@ -308,7 +420,229 @@ const supportsOpenAIImageTest = computed(() => {
return props.account?.platform === 'openai'
})
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
const isGrokImageModel = (id: string) => {
const modelID = id.toLowerCase()
return (
modelID === 'grok-imagine' ||
modelID === 'grok-imagine-edit' ||
modelID.startsWith('grok-imagine-image')
)
}
const isGrokVideoModel = (id: string) => {
const modelID = id.toLowerCase()
return modelID.startsWith('grok-imagine-video') || modelID.startsWith('grok-video')
}
const isGrokTextModel = (id: string) => !isGrokImageModel(id) && !isGrokVideoModel(id)
const supportsGrokImageTest = computed(
() => isGrokAccount.value && grokTestMode.value === 'image'
)
const supportsGrokVideoTest = computed(
() => isGrokAccount.value && grokTestMode.value === 'video'
)
const supportsImageTest = computed(
() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value || supportsGrokImageTest.value
)
// Model select only when the mode needs a model.
const showModelSelect = computed(() => {
if (!isGrokAccount.value) return true
return grokTestMode.value === 'text' || grokTestMode.value === 'image' || grokTestMode.value === 'video'
})
const modelOptionsForMode = computed(() => {
if (!isGrokAccount.value) return availableModels.value
if (grokTestMode.value === 'image') {
return availableModels.value.filter((m) => isGrokImageModel(m.id))
}
if (grokTestMode.value === 'video') {
return availableModels.value.filter((m) => isGrokVideoModel(m.id))
}
if (grokTestMode.value === 'text') {
return availableModels.value.filter((m) => isGrokTextModel(m.id))
}
return []
})
const supportsPromptInput = computed(() => {
if (!isGrokAccount.value) {
return supportsImageTest.value
}
return (
grokTestMode.value === 'image' ||
grokTestMode.value === 'video' ||
grokTestMode.value === 'search' ||
grokTestMode.value === 'tts'
)
})
const supportsImageUpload = computed(
() => isGrokAccount.value && (grokTestMode.value === 'image' || grokTestMode.value === 'video')
)
const supportsAudioUpload = computed(() => isGrokAccount.value && grokTestMode.value === 'stt')
const supportsVideoUploadURL = computed(() => isGrokAccount.value && grokTestMode.value === 'video')
const imageUploadLabel = computed(() =>
grokTestMode.value === 'video'
? t('admin.accounts.grok.videoFirstFrameLabel')
: t('admin.accounts.grok.imageUploadLabel')
)
const readFileAsDataURL = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error('Failed to read file'))
reader.readAsDataURL(file)
})
const onImageFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
return
}
if (file.size > 6 * 1024 * 1024) {
errorMessage.value = t('admin.accounts.grok.mediaTooLarge')
status.value = 'error'
input.value = ''
return
}
try {
const dataURL = await readFileAsDataURL(file)
uploadImageDataURL.value = dataURL
uploadImagePreview.value = dataURL
} catch {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
}
}
const onAudioFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
return
}
if (file.size > 6 * 1024 * 1024) {
errorMessage.value = t('admin.accounts.grok.mediaTooLarge')
status.value = 'error'
input.value = ''
return
}
try {
uploadAudioDataURL.value = await readFileAsDataURL(file)
uploadAudioName.value = file.name
} catch {
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
}
}
const clearMediaUploads = () => {
uploadImageDataURL.value = ''
uploadImagePreview.value = ''
uploadAudioDataURL.value = ''
uploadAudioName.value = ''
videoUploadURL.value = ''
}
const promptInputLabel = computed(() => {
if (supportsGrokVideoTest.value || grokTestMode.value === 'video') {
return t('admin.accounts.videoPromptLabel')
}
if (supportsImageTest.value || grokTestMode.value === 'image') {
return t('admin.accounts.imagePromptLabel')
}
if (grokTestMode.value === 'search') {
return t('admin.accounts.grok.searchQueryLabel')
}
if (grokTestMode.value === 'tts') {
return t('admin.accounts.grok.ttsTextLabel')
}
return t('admin.accounts.imagePromptLabel')
})
const promptInputPlaceholder = computed(() => {
if (grokTestMode.value === 'video') {
return t('admin.accounts.videoPromptPlaceholder')
}
if (grokTestMode.value === 'image' || supportsImageTest.value) {
return t('admin.accounts.imagePromptPlaceholder')
}
if (grokTestMode.value === 'search') {
return t('admin.accounts.grok.searchQueryPlaceholder')
}
if (grokTestMode.value === 'tts') {
return t('admin.accounts.grok.ttsTextPlaceholder')
}
return ''
})
const promptInputHint = computed(() => {
if (grokTestMode.value === 'video') {
return t('admin.accounts.videoTestHint')
}
if (grokTestMode.value === 'image' || supportsImageTest.value) {
return t('admin.accounts.imageTestHint')
}
if (grokTestMode.value === 'search') {
return t('admin.accounts.grok.searchTestHint')
}
if (grokTestMode.value === 'tts') {
return t('admin.accounts.grok.ttsTestHint')
}
if (grokTestMode.value === 'stt') {
return t('admin.accounts.grok.sttTestHint')
}
if (grokTestMode.value === 'realtime') {
return t('admin.accounts.grok.realtimeTestHint')
}
return ''
})
const testModeSummary = computed(() => {
if (isGrokAccount.value) {
switch (grokTestMode.value) {
case 'video':
return t('admin.accounts.videoTestMode')
case 'image':
return t('admin.accounts.imageTestMode')
case 'search':
return t('admin.accounts.grok.searchTestMode')
case 'tts':
return t('admin.accounts.grok.ttsTestMode')
case 'stt':
return t('admin.accounts.grok.sttTestMode')
case 'realtime':
return t('admin.accounts.grok.realtimeTestMode')
default:
return t('admin.accounts.grok.textTestMode')
}
}
if (supportsImageTest.value) return t('admin.accounts.imageTestMode')
return t('admin.accounts.testPrompt')
})
const canStartTest = computed(() => {
if (status.value === 'connecting') return false
if (isGrokAccount.value) {
if (
grokTestMode.value === 'search' ||
grokTestMode.value === 'tts' ||
grokTestMode.value === 'stt' ||
grokTestMode.value === 'realtime'
) {
return true // standalone modes (prompt/model optional)
}
return Boolean(selectedModelId.value)
}
return Boolean(selectedModelId.value)
})
const sortTestModels = (models: ClaudeModel[]) => {
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
@@ -322,24 +656,63 @@ const sortTestModels = (models: ClaudeModel[]) => {
}
// Load available models when modal opens
const applyDefaultPromptForMode = () => {
if (!supportsPromptInput.value) return
if (testPrompt.value.trim()) return
if (grokTestMode.value === 'video') {
testPrompt.value = t('admin.accounts.videoPromptDefault')
} else if (grokTestMode.value === 'image' || supportsImageTest.value) {
testPrompt.value = t('admin.accounts.imagePromptDefault')
} else if (grokTestMode.value === 'search') {
testPrompt.value = t('admin.accounts.grok.searchQueryDefault')
} else if (grokTestMode.value === 'tts') {
testPrompt.value = t('admin.accounts.grok.ttsTextDefault')
}
}
const pickDefaultModelForMode = () => {
const opts = modelOptionsForMode.value
if (!opts.length) {
selectedModelId.value = ''
return
}
if (opts.some((m) => m.id === selectedModelId.value)) return
if (grokTestMode.value === 'text') {
const preferred =
opts.find((m) => m.id.includes('grok-4.5')) ||
opts.find((m) => m.id === 'grok') ||
opts[0]
selectedModelId.value = preferred.id
return
}
selectedModelId.value = opts[0].id
}
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
testPrompt.value = ''
testMode.value = 'default'
grokTestMode.value = 'text'
resetState()
await loadAvailableModels()
if (isGrokAccount.value) {
pickDefaultModelForMode()
applyDefaultPromptForMode()
}
} else {
abortStream()
}
}
)
watch(selectedModelId, () => {
if (supportsImageTest.value && !testPrompt.value.trim()) {
testPrompt.value = t('admin.accounts.imagePromptDefault')
}
watch(grokTestMode, () => {
if (!isGrokAccount.value) return
testPrompt.value = ''
clearMediaUploads()
pickDefaultModelForMode()
applyDefaultPromptForMode()
})
const loadAvailableModels = async () => {
@@ -378,6 +751,8 @@ const resetState = () => {
streamingContent.value = ''
errorMessage.value = ''
generatedImages.value = []
generatedAudios.value = []
generatedVideos.value = []
previewImageUrl.value = ''
}
@@ -406,12 +781,17 @@ const scrollToBottom = async () => {
}
const startTest = async () => {
if (!props.account || !selectedModelId.value) return
if (!props.account || !canStartTest.value) return
resetState()
status.value = 'connecting'
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
if (isGrokAccount.value) {
const modeLabel =
grokTestModeOptions.value.find((o) => o.value === grokTestMode.value)?.label || grokTestMode.value
addLine(t('admin.accounts.grok.selectedTestMode', { mode: modeLabel }), 'text-gray-400')
}
addLine('', 'text-gray-300')
abortStream()
@@ -422,14 +802,39 @@ const startTest = async () => {
const requestBody: {
model_id: string
prompt: string
mode?: 'default' | 'compact'
mode?: string
image_data_url?: string
audio_data_url?: string
video_upload_url?: string
} = {
model_id: selectedModelId.value,
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
model_id: showModelSelect.value ? selectedModelId.value : '',
prompt: supportsPromptInput.value ? testPrompt.value.trim() : ''
}
if (isOpenAIAccount.value) {
requestBody.mode = testMode.value
}
if (isGrokAccount.value) {
// Always send explicit Grok mode. search/tts/stt/realtime are standalone
// endpoints (no free-form model select). text/image/video use optional model.
requestBody.mode = grokTestMode.value
if (
grokTestMode.value === 'search' ||
grokTestMode.value === 'tts' ||
grokTestMode.value === 'stt' ||
grokTestMode.value === 'realtime'
) {
requestBody.model_id = ''
}
if (uploadImageDataURL.value && (grokTestMode.value === 'image' || grokTestMode.value === 'video')) {
requestBody.image_data_url = uploadImageDataURL.value
}
if (uploadAudioDataURL.value && grokTestMode.value === 'stt') {
requestBody.audio_data_url = uploadAudioDataURL.value
}
if (videoUploadURL.value.trim() && grokTestMode.value === 'video') {
requestBody.video_upload_url = videoUploadURL.value.trim()
}
}
// Use the configured API base; EventSource does not support POST.
const url = buildApiUrl(`/admin/accounts/${props.account.id}/test`)
@@ -499,6 +904,8 @@ const handleEvent = (event: {
success?: boolean
error?: string
image_url?: string
audio_url?: string
video_url?: string
mime_type?: string
}) => {
switch (event.type) {
@@ -508,7 +915,21 @@ const handleEvent = (event: {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(
supportsImageTest.value
isGrokAccount.value
? grokTestMode.value === 'video'
? t('admin.accounts.sendingVideoRequest')
: grokTestMode.value === 'image'
? t('admin.accounts.sendingImageRequest')
: grokTestMode.value === 'search'
? t('admin.accounts.grok.sendingSearchRequest')
: grokTestMode.value === 'tts'
? t('admin.accounts.grok.sendingTTSRequest')
: grokTestMode.value === 'stt'
? t('admin.accounts.grok.sendingSTTRequest')
: grokTestMode.value === 'realtime'
? t('admin.accounts.grok.sendingRealtimeRequest')
: t('admin.accounts.sendingTestMessage')
: supportsImageTest.value
? t('admin.accounts.sendingImageRequest')
: t('admin.accounts.sendingTestMessage'),
'text-gray-400'
@@ -534,6 +955,26 @@ const handleEvent = (event: {
}
break
case 'audio':
if (event.audio_url) {
generatedAudios.value.push({
url: event.audio_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.audioReceived', { count: generatedAudios.value.length }), 'text-purple-300')
}
break
case 'video':
if (event.video_url) {
generatedVideos.value.push({
url: event.video_url,
mimeType: event.mime_type
})
addLine(t('admin.accounts.videoReceived', { count: generatedVideos.value.length }), 'text-purple-300')
}
break
case 'status':
if (event.text) {
addLine(event.text, 'text-cyan-300')
@@ -179,7 +179,8 @@ describe('AccountTestModal', () => {
const [, request] = (global.fetch as any).mock.calls[0]
expect(JSON.parse(request.body)).toEqual({
model_id: 'grok-4.3',
prompt: ''
prompt: '',
mode: 'text'
})
})