Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
bf6585a4
"...internal/handler/git@web.lueluesay.top:chenxi/sub2api.git" did not exist on "2d123a11ad208aef42b982655c825e5347c8b7f9"
Commit
bf6585a4
authored
Mar 11, 2026
by
Rose Ding
Browse files
feat: add gemini image test preview
parent
1c0519f1
Changes
8
Hide whitespace changes
Inline
Side-by-side
backend/internal/handler/admin/account_handler.go
View file @
bf6585a4
...
...
@@ -628,6 +628,7 @@ func (h *AccountHandler) Delete(c *gin.Context) {
// TestAccountRequest represents the request body for testing an account
type
TestAccountRequest
struct
{
ModelID
string
`json:"model_id"`
Prompt
string
`json:"prompt"`
}
type
SyncFromCRSRequest
struct
{
...
...
@@ -658,7 +659,7 @@ func (h *AccountHandler) Test(c *gin.Context) {
_
=
c
.
ShouldBindJSON
(
&
req
)
// Use AccountTestService to test the account with SSE streaming
if
err
:=
h
.
accountTestService
.
TestAccountConnection
(
c
,
accountID
,
req
.
ModelID
);
err
!=
nil
{
if
err
:=
h
.
accountTestService
.
TestAccountConnection
(
c
,
accountID
,
req
.
ModelID
,
req
.
Prompt
);
err
!=
nil
{
// Error already sent via SSE, just log
return
}
...
...
backend/internal/service/account_test_service.go
View file @
bf6585a4
...
...
@@ -45,16 +45,23 @@ const (
// TestEvent represents a SSE event for account testing
type
TestEvent
struct
{
Type
string
`json:"type"`
Text
string
`json:"text,omitempty"`
Model
string
`json:"model,omitempty"`
Status
string
`json:"status,omitempty"`
Code
string
`json:"code,omitempty"`
Data
any
`json:"data,omitempty"`
Success
bool
`json:"success,omitempty"`
Error
string
`json:"error,omitempty"`
Type
string
`json:"type"`
Text
string
`json:"text,omitempty"`
Model
string
`json:"model,omitempty"`
Status
string
`json:"status,omitempty"`
Code
string
`json:"code,omitempty"`
ImageURL
string
`json:"image_url,omitempty"`
MimeType
string
`json:"mime_type,omitempty"`
Data
any
`json:"data,omitempty"`
Success
bool
`json:"success,omitempty"`
Error
string
`json:"error,omitempty"`
}
const
(
defaultGeminiTextTestPrompt
=
"hi"
defaultGeminiImageTestPrompt
=
"Generate a cute orange cat astronaut sticker on a clean pastel background."
)
// AccountTestService handles account testing operations
type
AccountTestService
struct
{
accountRepo
AccountRepository
...
...
@@ -161,7 +168,7 @@ func createTestPayload(modelID string) (map[string]any, error) {
// TestAccountConnection tests an account's connection by sending a test request
// All account types use full Claude Code client characteristics, only auth header differs
// modelID is optional - if empty, defaults to claude.DefaultTestModel
func
(
s
*
AccountTestService
)
TestAccountConnection
(
c
*
gin
.
Context
,
accountID
int64
,
modelID
string
)
error
{
func
(
s
*
AccountTestService
)
TestAccountConnection
(
c
*
gin
.
Context
,
accountID
int64
,
modelID
string
,
prompt
string
)
error
{
ctx
:=
c
.
Request
.
Context
()
// Get account
...
...
@@ -176,11 +183,11 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
}
if
account
.
IsGemini
()
{
return
s
.
testGeminiAccountConnection
(
c
,
account
,
modelID
)
return
s
.
testGeminiAccountConnection
(
c
,
account
,
modelID
,
prompt
)
}
if
account
.
Platform
==
PlatformAntigravity
{
return
s
.
routeAntigravityTest
(
c
,
account
,
modelID
)
return
s
.
routeAntigravityTest
(
c
,
account
,
modelID
,
prompt
)
}
if
account
.
Platform
==
PlatformSora
{
...
...
@@ -435,7 +442,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
}
// testGeminiAccountConnection tests a Gemini account's connection
func
(
s
*
AccountTestService
)
testGeminiAccountConnection
(
c
*
gin
.
Context
,
account
*
Account
,
modelID
string
)
error
{
func
(
s
*
AccountTestService
)
testGeminiAccountConnection
(
c
*
gin
.
Context
,
account
*
Account
,
modelID
string
,
prompt
string
)
error
{
ctx
:=
c
.
Request
.
Context
()
// Determine the model to use
...
...
@@ -462,7 +469,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
c
.
Writer
.
Flush
()
// Create test payload (Gemini format)
payload
:=
createGeminiTestPayload
()
payload
:=
createGeminiTestPayload
(
testModelID
,
prompt
)
// Build request based on account type
var
req
*
http
.
Request
...
...
@@ -1198,10 +1205,10 @@ func truncateSoraErrorBody(body []byte, max int) string {
// routeAntigravityTest 路由 Antigravity 账号的测试请求。
// APIKey 类型走原生协议(与 gateway_handler 路由一致),OAuth/Upstream 走 CRS 中转。
func
(
s
*
AccountTestService
)
routeAntigravityTest
(
c
*
gin
.
Context
,
account
*
Account
,
modelID
string
)
error
{
func
(
s
*
AccountTestService
)
routeAntigravityTest
(
c
*
gin
.
Context
,
account
*
Account
,
modelID
string
,
prompt
string
)
error
{
if
account
.
Type
==
AccountTypeAPIKey
{
if
strings
.
HasPrefix
(
modelID
,
"gemini-"
)
{
return
s
.
testGeminiAccountConnection
(
c
,
account
,
modelID
)
return
s
.
testGeminiAccountConnection
(
c
,
account
,
modelID
,
prompt
)
}
return
s
.
testClaudeAccountConnection
(
c
,
account
,
modelID
)
}
...
...
@@ -1349,14 +1356,46 @@ func (s *AccountTestService) buildCodeAssistRequest(ctx context.Context, accessT
return
req
,
nil
}
// createGeminiTestPayload creates a minimal test payload for Gemini API
func
createGeminiTestPayload
()
[]
byte
{
// createGeminiTestPayload creates a minimal test payload for Gemini API.
// Image models use the image-generation path so the frontend can preview the returned image.
func
createGeminiTestPayload
(
modelID
string
,
prompt
string
)
[]
byte
{
if
isImageGenerationModel
(
modelID
)
{
imagePrompt
:=
strings
.
TrimSpace
(
prompt
)
if
imagePrompt
==
""
{
imagePrompt
=
defaultGeminiImageTestPrompt
}
payload
:=
map
[
string
]
any
{
"contents"
:
[]
map
[
string
]
any
{
{
"role"
:
"user"
,
"parts"
:
[]
map
[
string
]
any
{
{
"text"
:
imagePrompt
},
},
},
},
"generationConfig"
:
map
[
string
]
any
{
"responseModalities"
:
[]
string
{
"TEXT"
,
"IMAGE"
},
"imageConfig"
:
map
[
string
]
any
{
"aspectRatio"
:
"1:1"
,
},
},
}
bytes
,
_
:=
json
.
Marshal
(
payload
)
return
bytes
}
textPrompt
:=
strings
.
TrimSpace
(
prompt
)
if
textPrompt
==
""
{
textPrompt
=
defaultGeminiTextTestPrompt
}
payload
:=
map
[
string
]
any
{
"contents"
:
[]
map
[
string
]
any
{
{
"role"
:
"user"
,
"parts"
:
[]
map
[
string
]
any
{
{
"text"
:
"hi"
},
{
"text"
:
textPrompt
},
},
},
},
...
...
@@ -1416,6 +1455,17 @@ func (s *AccountTestService) processGeminiStream(c *gin.Context, body io.Reader)
if
text
,
ok
:=
partMap
[
"text"
]
.
(
string
);
ok
&&
text
!=
""
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"content"
,
Text
:
text
})
}
if
inlineData
,
ok
:=
partMap
[
"inlineData"
]
.
(
map
[
string
]
any
);
ok
{
mimeType
,
_
:=
inlineData
[
"mimeType"
]
.
(
string
)
data
,
_
:=
inlineData
[
"data"
]
.
(
string
)
if
strings
.
HasPrefix
(
strings
.
ToLower
(
mimeType
),
"image/"
)
&&
data
!=
""
{
s
.
sendEvent
(
c
,
TestEvent
{
Type
:
"image"
,
ImageURL
:
fmt
.
Sprintf
(
"data:%s;base64,%s"
,
mimeType
,
data
),
MimeType
:
mimeType
,
})
}
}
}
}
}
...
...
@@ -1602,7 +1652,7 @@ func (s *AccountTestService) RunTestBackground(ctx context.Context, accountID in
ginCtx
,
_
:=
gin
.
CreateTestContext
(
w
)
ginCtx
.
Request
=
(
&
http
.
Request
{})
.
WithContext
(
ctx
)
testErr
:=
s
.
TestAccountConnection
(
ginCtx
,
accountID
,
modelID
)
testErr
:=
s
.
TestAccountConnection
(
ginCtx
,
accountID
,
modelID
,
""
)
finishedAt
:=
time
.
Now
()
body
:=
w
.
Body
.
String
()
...
...
backend/internal/service/account_test_service_gemini_test.go
0 → 100644
View file @
bf6585a4
//go:build unit
package
service
import
(
"encoding/json"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func
TestCreateGeminiTestPayload_ImageModel
(
t
*
testing
.
T
)
{
t
.
Parallel
()
payload
:=
createGeminiTestPayload
(
"gemini-2.5-flash-image"
,
"draw a tiny robot"
)
var
parsed
struct
{
Contents
[]
struct
{
Parts
[]
struct
{
Text
string
`json:"text"`
}
`json:"parts"`
}
`json:"contents"`
GenerationConfig
struct
{
ResponseModalities
[]
string
`json:"responseModalities"`
ImageConfig
struct
{
AspectRatio
string
`json:"aspectRatio"`
}
`json:"imageConfig"`
}
`json:"generationConfig"`
}
require
.
NoError
(
t
,
json
.
Unmarshal
(
payload
,
&
parsed
))
require
.
Len
(
t
,
parsed
.
Contents
,
1
)
require
.
Len
(
t
,
parsed
.
Contents
[
0
]
.
Parts
,
1
)
require
.
Equal
(
t
,
"draw a tiny robot"
,
parsed
.
Contents
[
0
]
.
Parts
[
0
]
.
Text
)
require
.
Equal
(
t
,
[]
string
{
"TEXT"
,
"IMAGE"
},
parsed
.
GenerationConfig
.
ResponseModalities
)
require
.
Equal
(
t
,
"1:1"
,
parsed
.
GenerationConfig
.
ImageConfig
.
AspectRatio
)
}
func
TestProcessGeminiStream_EmitsImageEvent
(
t
*
testing
.
T
)
{
t
.
Parallel
()
gin
.
SetMode
(
gin
.
TestMode
)
ctx
,
recorder
:=
newSoraTestContext
()
svc
:=
&
AccountTestService
{}
stream
:=
strings
.
NewReader
(
"data: {
\"
candidates
\"
:[{
\"
content
\"
:{
\"
parts
\"
:[{
\"
text
\"
:
\"
ok
\"
},{
\"
inlineData
\"
:{
\"
mimeType
\"
:
\"
image/png
\"
,
\"
data
\"
:
\"
QUJD
\"
}}]}}]}
\n\n
data: [DONE]
\n\n
"
)
err
:=
svc
.
processGeminiStream
(
ctx
,
stream
)
require
.
NoError
(
t
,
err
)
body
:=
recorder
.
Body
.
String
()
require
.
Contains
(
t
,
body
,
"
\"
type
\"
:
\"
content
\"
"
)
require
.
Contains
(
t
,
body
,
"
\"
text
\"
:
\"
ok
\"
"
)
require
.
Contains
(
t
,
body
,
"
\"
type
\"
:
\"
image
\"
"
)
require
.
Contains
(
t
,
body
,
"
\"
image_url
\"
:
\"
data:image/png;base64,QUJD
\"
"
)
require
.
Contains
(
t
,
body
,
"
\"
mime_type
\"
:
\"
image/png
\"
"
)
}
frontend/src/components/account/AccountTestModal.vue
View file @
bf6585a4
...
...
@@ -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=
"x
Circle
"
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,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
{
...
...
@@ -288,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
'
)
{
...
...
@@ -332,6 +332,7 @@ const resetState = () => {
outputLines
.
value
=
[]
streamingContent
.
value
=
''
errorMessage
.
value
=
''
generatedImages
.
value
=
[]
}
const
handleClose
=
()
=>
{
...
...
@@ -385,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
()
:
''
}
)
})
...
...
@@ -436,6 +442,8 @@ const handleEvent = (event: {
model
?:
string
success
?:
boolean
error
?:
string
image_url
?:
string
mime_type
?:
string
})
=>
{
switch
(
event
.
type
)
{
case
'
test_start
'
:
...
...
@@ -444,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
'
)
...
...
@@ -458,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
)
{
...
...
frontend/src/components/admin/account/AccountTestModal.vue
View file @
bf6585a4
...
...
@@ -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
)
{
...
...
frontend/src/components/admin/account/__tests__/AccountTestModal.spec.ts
0 → 100644
View file @
bf6585a4
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
'
)
})
})
frontend/src/i18n/locales/en.ts
View file @
bf6585a4
...
...
@@ -2411,6 +2411,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...
'
,
...
...
@@ -2422,6 +2423,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
'
,
...
...
frontend/src/i18n/locales/zh.ts
View file @
bf6585a4
...
...
@@ -2540,6 +2540,7 @@ export default {
connectedToApi
:
'
已连接到 API
'
,
usingModel
:
'
使用模型:{model}
'
,
sendingTestMessage
:
'
发送测试消息:"hi"
'
,
sendingGeminiImageRequest
:
'
发送 Gemini 生图测试请求...
'
,
response
:
'
响应:
'
,
startTest
:
'
开始测试
'
,
retry
:
'
重试
'
,
...
...
@@ -2550,6 +2551,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 账号能力
'
,
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment