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
f0e08e76
Unverified
Commit
f0e08e76
authored
Mar 12, 2026
by
Wesley Liddick
Committed by
GitHub
Mar 12, 2026
Browse files
Merge pull request #930 from GuangYiDing/feat/gemini-25-flash-image-support
feat: 修复 Gemini 生图接口并新增前端生图测试能力
parents
10b82592
3fcefe6c
Changes
25
Hide whitespace changes
Inline
Side-by-side
backend/internal/domain/constants.go
View file @
f0e08e76
...
...
@@ -84,10 +84,12 @@ var DefaultAntigravityModelMapping = map[string]string{
"claude-haiku-4-5"
:
"claude-sonnet-4-5"
,
"claude-haiku-4-5-20251001"
:
"claude-sonnet-4-5"
,
// Gemini 2.5 白名单
"gemini-2.5-flash"
:
"gemini-2.5-flash"
,
"gemini-2.5-flash-lite"
:
"gemini-2.5-flash-lite"
,
"gemini-2.5-flash-thinking"
:
"gemini-2.5-flash-thinking"
,
"gemini-2.5-pro"
:
"gemini-2.5-pro"
,
"gemini-2.5-flash"
:
"gemini-2.5-flash"
,
"gemini-2.5-flash-image"
:
"gemini-2.5-flash-image"
,
"gemini-2.5-flash-image-preview"
:
"gemini-2.5-flash-image"
,
"gemini-2.5-flash-lite"
:
"gemini-2.5-flash-lite"
,
"gemini-2.5-flash-thinking"
:
"gemini-2.5-flash-thinking"
,
"gemini-2.5-pro"
:
"gemini-2.5-pro"
,
// Gemini 3 白名单
"gemini-3-flash"
:
"gemini-3-flash"
,
"gemini-3-pro-high"
:
"gemini-3-pro-high"
,
...
...
backend/internal/domain/constants_test.go
View file @
f0e08e76
...
...
@@ -6,6 +6,8 @@ func TestDefaultAntigravityModelMapping_ImageCompatibilityAliases(t *testing.T)
t
.
Parallel
()
cases
:=
map
[
string
]
string
{
"gemini-2.5-flash-image"
:
"gemini-2.5-flash-image"
,
"gemini-2.5-flash-image-preview"
:
"gemini-2.5-flash-image"
,
"gemini-3.1-flash-image"
:
"gemini-3.1-flash-image"
,
"gemini-3.1-flash-image-preview"
:
"gemini-3.1-flash-image"
,
"gemini-3-pro-image"
:
"gemini-3.1-flash-image"
,
...
...
backend/internal/handler/admin/account_handler.go
View file @
f0e08e76
...
...
@@ -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/pkg/antigravity/claude_types.go
View file @
f0e08e76
...
...
@@ -159,6 +159,8 @@ var claudeModels = []modelDef{
// Antigravity 支持的 Gemini 模型
var
geminiModels
=
[]
modelDef
{
{
ID
:
"gemini-2.5-flash"
,
DisplayName
:
"Gemini 2.5 Flash"
,
CreatedAt
:
"2025-01-01T00:00:00Z"
},
{
ID
:
"gemini-2.5-flash-image"
,
DisplayName
:
"Gemini 2.5 Flash Image"
,
CreatedAt
:
"2025-01-01T00:00:00Z"
},
{
ID
:
"gemini-2.5-flash-image-preview"
,
DisplayName
:
"Gemini 2.5 Flash Image Preview"
,
CreatedAt
:
"2025-01-01T00:00:00Z"
},
{
ID
:
"gemini-2.5-flash-lite"
,
DisplayName
:
"Gemini 2.5 Flash Lite"
,
CreatedAt
:
"2025-01-01T00:00:00Z"
},
{
ID
:
"gemini-2.5-flash-thinking"
,
DisplayName
:
"Gemini 2.5 Flash Thinking"
,
CreatedAt
:
"2025-01-01T00:00:00Z"
},
{
ID
:
"gemini-3-flash"
,
DisplayName
:
"Gemini 3 Flash"
,
CreatedAt
:
"2025-06-01T00:00:00Z"
},
...
...
backend/internal/pkg/antigravity/claude_types_test.go
View file @
f0e08e76
...
...
@@ -13,6 +13,8 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
requiredIDs
:=
[]
string
{
"claude-opus-4-6-thinking"
,
"gemini-2.5-flash-image"
,
"gemini-2.5-flash-image-preview"
,
"gemini-3.1-flash-image"
,
"gemini-3.1-flash-image-preview"
,
"gemini-3-pro-image"
,
// legacy compatibility
...
...
backend/internal/pkg/gemini/models.go
View file @
f0e08e76
...
...
@@ -18,10 +18,12 @@ func DefaultModels() []Model {
return
[]
Model
{
{
Name
:
"models/gemini-2.0-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-flash"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-flash-image"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-2.5-pro"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-3-flash-preview"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-3-pro-preview"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-3.1-pro-preview"
,
SupportedGenerationMethods
:
methods
},
{
Name
:
"models/gemini-3.1-flash-image"
,
SupportedGenerationMethods
:
methods
},
}
}
...
...
backend/internal/pkg/gemini/models_test.go
0 → 100644
View file @
f0e08e76
package
gemini
import
"testing"
func
TestDefaultModels_ContainsImageModels
(
t
*
testing
.
T
)
{
t
.
Parallel
()
models
:=
DefaultModels
()
byName
:=
make
(
map
[
string
]
Model
,
len
(
models
))
for
_
,
model
:=
range
models
{
byName
[
model
.
Name
]
=
model
}
required
:=
[]
string
{
"models/gemini-2.5-flash-image"
,
"models/gemini-3.1-flash-image"
,
}
for
_
,
name
:=
range
required
{
model
,
ok
:=
byName
[
name
]
if
!
ok
{
t
.
Fatalf
(
"expected fallback model %q to exist"
,
name
)
}
if
len
(
model
.
SupportedGenerationMethods
)
==
0
{
t
.
Fatalf
(
"expected fallback model %q to advertise generation methods"
,
name
)
}
}
}
backend/internal/pkg/geminicli/models.go
View file @
f0e08e76
...
...
@@ -13,10 +13,12 @@ type Model struct {
var
DefaultModels
=
[]
Model
{
{
ID
:
"gemini-2.0-flash"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.0 Flash"
,
CreatedAt
:
""
},
{
ID
:
"gemini-2.5-flash"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.5 Flash"
,
CreatedAt
:
""
},
{
ID
:
"gemini-2.5-flash-image"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.5 Flash Image"
,
CreatedAt
:
""
},
{
ID
:
"gemini-2.5-pro"
,
Type
:
"model"
,
DisplayName
:
"Gemini 2.5 Pro"
,
CreatedAt
:
""
},
{
ID
:
"gemini-3-flash-preview"
,
Type
:
"model"
,
DisplayName
:
"Gemini 3 Flash Preview"
,
CreatedAt
:
""
},
{
ID
:
"gemini-3-pro-preview"
,
Type
:
"model"
,
DisplayName
:
"Gemini 3 Pro Preview"
,
CreatedAt
:
""
},
{
ID
:
"gemini-3.1-pro-preview"
,
Type
:
"model"
,
DisplayName
:
"Gemini 3.1 Pro Preview"
,
CreatedAt
:
""
},
{
ID
:
"gemini-3.1-flash-image"
,
Type
:
"model"
,
DisplayName
:
"Gemini 3.1 Flash Image"
,
CreatedAt
:
""
},
}
// DefaultTestModel is the default model to preselect in test flows.
...
...
backend/internal/pkg/geminicli/models_test.go
0 → 100644
View file @
f0e08e76
package
geminicli
import
"testing"
func
TestDefaultModels_ContainsImageModels
(
t
*
testing
.
T
)
{
t
.
Parallel
()
byID
:=
make
(
map
[
string
]
Model
,
len
(
DefaultModels
))
for
_
,
model
:=
range
DefaultModels
{
byID
[
model
.
ID
]
=
model
}
required
:=
[]
string
{
"gemini-2.5-flash-image"
,
"gemini-3.1-flash-image"
,
}
for
_
,
id
:=
range
required
{
if
_
,
ok
:=
byID
[
id
];
!
ok
{
t
.
Fatalf
(
"expected curated Gemini model %q to exist"
,
id
)
}
}
}
backend/internal/service/account_test_service.go
View file @
f0e08e76
...
...
@@ -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 @
f0e08e76
//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
\"
"
)
}
backend/migrations/071_add_gemini25_flash_image_to_model_mapping.sql
0 → 100644
View file @
f0e08e76
-- Add gemini-2.5-flash-image aliases to Antigravity model_mapping
--
-- Background:
-- Gemini native image generation now relies on gemini-2.5-flash-image, and
-- existing Antigravity accounts with persisted model_mapping need this alias in
-- order to participate in mixed scheduling from gemini groups.
--
-- Strategy:
-- Overwrite the stored model_mapping so it matches DefaultAntigravityModelMapping
-- in constants.go, including legacy gemini-3-pro-image aliases.
UPDATE
accounts
SET
credentials
=
jsonb_set
(
credentials
,
'{model_mapping}'
,
'{
"claude-opus-4-6-thinking": "claude-opus-4-6-thinking",
"claude-opus-4-6": "claude-opus-4-6-thinking",
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking",
"claude-opus-4-5-20251101": "claude-opus-4-6-thinking",
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-sonnet-4-5": "claude-sonnet-4-5",
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
"claude-haiku-4-5": "claude-sonnet-4-5",
"claude-haiku-4-5-20251001": "claude-sonnet-4-5",
"gemini-2.5-flash": "gemini-2.5-flash",
"gemini-2.5-flash-image": "gemini-2.5-flash-image",
"gemini-2.5-flash-image-preview": "gemini-2.5-flash-image",
"gemini-2.5-flash-lite": "gemini-2.5-flash-lite",
"gemini-2.5-flash-thinking": "gemini-2.5-flash-thinking",
"gemini-2.5-pro": "gemini-2.5-pro",
"gemini-3-flash": "gemini-3-flash",
"gemini-3-pro-high": "gemini-3-pro-high",
"gemini-3-pro-low": "gemini-3-pro-low",
"gemini-3-flash-preview": "gemini-3-flash",
"gemini-3-pro-preview": "gemini-3-pro-high",
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
"gemini-3.1-pro-low": "gemini-3.1-pro-low",
"gemini-3.1-pro-preview": "gemini-3.1-pro-high",
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
"gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
"gemini-3-pro-image": "gemini-3.1-flash-image",
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
"gpt-oss-120b-medium": "gpt-oss-120b-medium",
"tab_flash_lite_preview": "tab_flash_lite_preview"
}'
::
jsonb
)
WHERE
platform
=
'antigravity'
AND
deleted_at
IS
NULL
AND
credentials
->
'model_mapping'
IS
NOT
NULL
;
frontend/src/components/account/AccountStatusIndicator.vue
View file @
f0e08e76
...
...
@@ -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
'
,
...
...
frontend/src/components/account/AccountTestModal.vue
View file @
f0e08e76
...
...
@@ -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,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
)
{
...
...
frontend/src/components/account/AccountUsageCell.vue
View file @
f0e08e76
...
...
@@ -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)
...
...
frontend/src/components/account/BulkEditAccountModal.vue
View file @
f0e08e76
...
...
@@ -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
'
,
...
...
frontend/src/components/account/__tests__/AccountUsageCell.spec.ts
View file @
f0e08e76
...
...
@@ -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
'
...
...
frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts
View file @
f0e08e76
...
...
@@ -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
{
...
...
frontend/src/components/admin/account/AccountTestModal.vue
View file @
f0e08e76
...
...
@@ -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
)
{
...
...
frontend/src/components/admin/account/__tests__/AccountTestModal.spec.ts
0 → 100644
View file @
f0e08e76
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
'
)
})
})
Prev
1
2
Next
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