mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 02:27:11 +00:00
feat: add gemini image test preview
This commit is contained in:
@@ -628,6 +628,7 @@ func (h *AccountHandler) Delete(c *gin.Context) {
|
|||||||
// TestAccountRequest represents the request body for testing an account
|
// TestAccountRequest represents the request body for testing an account
|
||||||
type TestAccountRequest struct {
|
type TestAccountRequest struct {
|
||||||
ModelID string `json:"model_id"`
|
ModelID string `json:"model_id"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncFromCRSRequest struct {
|
type SyncFromCRSRequest struct {
|
||||||
@@ -658,7 +659,7 @@ func (h *AccountHandler) Test(c *gin.Context) {
|
|||||||
_ = c.ShouldBindJSON(&req)
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
// Use AccountTestService to test the account with SSE streaming
|
// 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
|
// Error already sent via SSE, just log
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,16 +45,23 @@ const (
|
|||||||
|
|
||||||
// TestEvent represents a SSE event for account testing
|
// TestEvent represents a SSE event for account testing
|
||||||
type TestEvent struct {
|
type TestEvent struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Code string `json:"code,omitempty"`
|
Code string `json:"code,omitempty"`
|
||||||
Data any `json:"data,omitempty"`
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
Success bool `json:"success,omitempty"`
|
MimeType string `json:"mime_type,omitempty"`
|
||||||
Error string `json:"error,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
|
// AccountTestService handles account testing operations
|
||||||
type AccountTestService struct {
|
type AccountTestService struct {
|
||||||
accountRepo AccountRepository
|
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
|
// TestAccountConnection tests an account's connection by sending a test request
|
||||||
// All account types use full Claude Code client characteristics, only auth header differs
|
// All account types use full Claude Code client characteristics, only auth header differs
|
||||||
// modelID is optional - if empty, defaults to claude.DefaultTestModel
|
// 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()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
// Get account
|
// Get account
|
||||||
@@ -176,11 +183,11 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
|||||||
}
|
}
|
||||||
|
|
||||||
if account.IsGemini() {
|
if account.IsGemini() {
|
||||||
return s.testGeminiAccountConnection(c, account, modelID)
|
return s.testGeminiAccountConnection(c, account, modelID, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
if account.Platform == PlatformAntigravity {
|
if account.Platform == PlatformAntigravity {
|
||||||
return s.routeAntigravityTest(c, account, modelID)
|
return s.routeAntigravityTest(c, account, modelID, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
if account.Platform == PlatformSora {
|
if account.Platform == PlatformSora {
|
||||||
@@ -435,7 +442,7 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testGeminiAccountConnection tests a Gemini account's connection
|
// 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()
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
// Determine the model to use
|
// Determine the model to use
|
||||||
@@ -462,7 +469,7 @@ func (s *AccountTestService) testGeminiAccountConnection(c *gin.Context, account
|
|||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
|
|
||||||
// Create test payload (Gemini format)
|
// Create test payload (Gemini format)
|
||||||
payload := createGeminiTestPayload()
|
payload := createGeminiTestPayload(testModelID, prompt)
|
||||||
|
|
||||||
// Build request based on account type
|
// Build request based on account type
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
@@ -1198,10 +1205,10 @@ func truncateSoraErrorBody(body []byte, max int) string {
|
|||||||
|
|
||||||
// routeAntigravityTest 路由 Antigravity 账号的测试请求。
|
// routeAntigravityTest 路由 Antigravity 账号的测试请求。
|
||||||
// APIKey 类型走原生协议(与 gateway_handler 路由一致),OAuth/Upstream 走 CRS 中转。
|
// 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 account.Type == AccountTypeAPIKey {
|
||||||
if strings.HasPrefix(modelID, "gemini-") {
|
if strings.HasPrefix(modelID, "gemini-") {
|
||||||
return s.testGeminiAccountConnection(c, account, modelID)
|
return s.testGeminiAccountConnection(c, account, modelID, prompt)
|
||||||
}
|
}
|
||||||
return s.testClaudeAccountConnection(c, account, modelID)
|
return s.testClaudeAccountConnection(c, account, modelID)
|
||||||
}
|
}
|
||||||
@@ -1349,14 +1356,46 @@ func (s *AccountTestService) buildCodeAssistRequest(ctx context.Context, accessT
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createGeminiTestPayload creates a minimal test payload for Gemini API
|
// createGeminiTestPayload creates a minimal test payload for Gemini API.
|
||||||
func createGeminiTestPayload() []byte {
|
// 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{
|
payload := map[string]any{
|
||||||
"contents": []map[string]any{
|
"contents": []map[string]any{
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"parts": []map[string]any{
|
"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 != "" {
|
if text, ok := partMap["text"].(string); ok && text != "" {
|
||||||
s.sendEvent(c, TestEvent{Type: "content", Text: 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, _ := gin.CreateTestContext(w)
|
||||||
ginCtx.Request = (&http.Request{}).WithContext(ctx)
|
ginCtx.Request = (&http.Request{}).WithContext(ctx)
|
||||||
|
|
||||||
testErr := s.TestAccountConnection(ginCtx, accountID, modelID)
|
testErr := s.TestAccountConnection(ginCtx, accountID, modelID, "")
|
||||||
|
|
||||||
finishedAt := time.Now()
|
finishedAt := time.Now()
|
||||||
body := w.Body.String()
|
body := w.Body.String()
|
||||||
|
|||||||
59
backend/internal/service/account_test_service_gemini_test.go
Normal file
59
backend/internal/service/account_test_service_gemini_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//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\ndata: [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\"")
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
|
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>
|
<div>
|
||||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
||||||
@@ -61,6 +61,17 @@
|
|||||||
{{ t('admin.accounts.soraTestHint') }}
|
{{ t('admin.accounts.soraTestHint') }}
|
||||||
</div>
|
</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 -->
|
<!-- Terminal Output -->
|
||||||
<div class="group relative">
|
<div class="group relative">
|
||||||
<div
|
<div
|
||||||
@@ -69,25 +80,11 @@
|
|||||||
>
|
>
|
||||||
<!-- Status Line -->
|
<!-- Status Line -->
|
||||||
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
|
<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>
|
<span>{{ t('admin.accounts.readyToTest') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
|
<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">
|
<Icon name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
|
||||||
<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>
|
|
||||||
<span>{{ t('admin.accounts.connectingToApi') }}</span>
|
<span>{{ t('admin.accounts.connectingToApi') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,21 +103,14 @@
|
|||||||
v-if="status === 'success'"
|
v-if="status === 'success'"
|
||||||
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
|
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">
|
<Icon name="check" size="sm" :stroke-width="2" />
|
||||||
<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>
|
|
||||||
<span>{{ t('admin.accounts.testCompleted') }}</span>
|
<span>{{ t('admin.accounts.testCompleted') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="status === 'error'"
|
v-else-if="status === 'error'"
|
||||||
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
|
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
|
||||||
>
|
>
|
||||||
<Icon name="xCircle" size="sm" :stroke-width="2" />
|
<Icon name="x" size="sm" :stroke-width="2" />
|
||||||
<span>{{ errorMessage }}</span>
|
<span>{{ errorMessage }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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')"
|
:title="t('admin.accounts.copyOutput')"
|
||||||
>
|
>
|
||||||
<Icon name="copy" size="sm" :stroke-width="2" />
|
<Icon name="link" size="sm" :stroke-width="2" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- 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 justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="flex items-center gap-1">
|
<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') }}
|
{{ isSoraAccount ? t('admin.accounts.soraTestTarget') : t('admin.accounts.testModel') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<Icon name="chatBubble" size="sm" :stroke-width="2" />
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,54 +191,15 @@
|
|||||||
: 'bg-primary-500 text-white hover:bg-primary-600'
|
: 'bg-primary-500 text-white hover:bg-primary-600'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<svg
|
<Icon
|
||||||
v-if="status === 'connecting'"
|
v-if="status === 'connecting'"
|
||||||
class="h-4 w-4 animate-spin"
|
name="refresh"
|
||||||
fill="none"
|
size="sm"
|
||||||
viewBox="0 0 24 24"
|
class="animate-spin"
|
||||||
>
|
:stroke-width="2"
|
||||||
<circle
|
/>
|
||||||
class="opacity-25"
|
<Icon v-else-if="status === 'idle'" name="play" size="sm" :stroke-width="2" />
|
||||||
cx="12"
|
<Icon v-else name="refresh" size="sm" :stroke-width="2" />
|
||||||
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>
|
|
||||||
<span>
|
<span>
|
||||||
{{
|
{{
|
||||||
status === 'connecting'
|
status === 'connecting'
|
||||||
@@ -242,7 +220,8 @@ import { computed, ref, watch, nextTick } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import Select from '@/components/common/Select.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 { useClipboard } from '@/composables/useClipboard'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { Account, ClaudeModel } from '@/types'
|
import type { Account, ClaudeModel } from '@/types'
|
||||||
@@ -255,6 +234,11 @@ interface OutputLine {
|
|||||||
class: string
|
class: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PreviewImage {
|
||||||
|
url: string
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
account: Account | null
|
account: Account | null
|
||||||
@@ -271,15 +255,25 @@ const streamingContent = ref('')
|
|||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const availableModels = ref<ClaudeModel[]>([])
|
const availableModels = ref<ClaudeModel[]>([])
|
||||||
const selectedModelId = ref('')
|
const selectedModelId = ref('')
|
||||||
|
const testPrompt = ref('')
|
||||||
const loadingModels = ref(false)
|
const loadingModels = ref(false)
|
||||||
let eventSource: EventSource | null = null
|
let eventSource: EventSource | null = null
|
||||||
const isSoraAccount = computed(() => props.account?.platform === 'sora')
|
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
|
// Load available models when modal opens
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
async (newVal) => {
|
async (newVal) => {
|
||||||
if (newVal && props.account) {
|
if (newVal && props.account) {
|
||||||
|
testPrompt.value = ''
|
||||||
resetState()
|
resetState()
|
||||||
await loadAvailableModels()
|
await loadAvailableModels()
|
||||||
} else {
|
} else {
|
||||||
@@ -288,6 +282,12 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(selectedModelId, () => {
|
||||||
|
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
|
||||||
|
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const loadAvailableModels = async () => {
|
const loadAvailableModels = async () => {
|
||||||
if (!props.account) return
|
if (!props.account) return
|
||||||
if (props.account.platform === 'sora') {
|
if (props.account.platform === 'sora') {
|
||||||
@@ -332,6 +332,7 @@ const resetState = () => {
|
|||||||
outputLines.value = []
|
outputLines.value = []
|
||||||
streamingContent.value = ''
|
streamingContent.value = ''
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
generatedImages.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -385,7 +386,12 @@ const startTest = async () => {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify(
|
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
|
model?: string
|
||||||
success?: boolean
|
success?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
|
image_url?: string
|
||||||
|
mime_type?: string
|
||||||
}) => {
|
}) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'test_start':
|
case 'test_start':
|
||||||
@@ -444,7 +452,11 @@ const handleEvent = (event: {
|
|||||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||||
}
|
}
|
||||||
addLine(
|
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'
|
'text-gray-400'
|
||||||
)
|
)
|
||||||
addLine('', 'text-gray-300')
|
addLine('', 'text-gray-300')
|
||||||
@@ -458,6 +470,16 @@ const handleEvent = (event: {
|
|||||||
}
|
}
|
||||||
break
|
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':
|
case 'test_complete':
|
||||||
// Move streaming content to output lines
|
// Move streaming content to output lines
|
||||||
if (streamingContent.value) {
|
if (streamingContent.value) {
|
||||||
|
|||||||
@@ -61,6 +61,17 @@
|
|||||||
{{ t('admin.accounts.soraTestHint') }}
|
{{ t('admin.accounts.soraTestHint') }}
|
||||||
</div>
|
</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 -->
|
<!-- Terminal Output -->
|
||||||
<div class="group relative">
|
<div class="group relative">
|
||||||
<div
|
<div
|
||||||
@@ -115,6 +126,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- 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 justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -125,7 +157,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
<Icon name="chat" size="sm" :stroke-width="2" />
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,6 +220,7 @@ import { computed, ref, watch, nextTick } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
|
import TextArea from '@/components/common/TextArea.vue'
|
||||||
import { Icon } from '@/components/icons'
|
import { Icon } from '@/components/icons'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
@@ -195,6 +234,11 @@ interface OutputLine {
|
|||||||
class: string
|
class: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PreviewImage {
|
||||||
|
url: string
|
||||||
|
mimeType?: string
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
account: Account | null
|
account: Account | null
|
||||||
@@ -211,15 +255,25 @@ const streamingContent = ref('')
|
|||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const availableModels = ref<ClaudeModel[]>([])
|
const availableModels = ref<ClaudeModel[]>([])
|
||||||
const selectedModelId = ref('')
|
const selectedModelId = ref('')
|
||||||
|
const testPrompt = ref('')
|
||||||
const loadingModels = ref(false)
|
const loadingModels = ref(false)
|
||||||
let eventSource: EventSource | null = null
|
let eventSource: EventSource | null = null
|
||||||
const isSoraAccount = computed(() => props.account?.platform === 'sora')
|
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
|
// Load available models when modal opens
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
async (newVal) => {
|
async (newVal) => {
|
||||||
if (newVal && props.account) {
|
if (newVal && props.account) {
|
||||||
|
testPrompt.value = ''
|
||||||
resetState()
|
resetState()
|
||||||
await loadAvailableModels()
|
await loadAvailableModels()
|
||||||
} else {
|
} else {
|
||||||
@@ -228,6 +282,12 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(selectedModelId, () => {
|
||||||
|
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
|
||||||
|
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const loadAvailableModels = async () => {
|
const loadAvailableModels = async () => {
|
||||||
if (!props.account) return
|
if (!props.account) return
|
||||||
if (props.account.platform === 'sora') {
|
if (props.account.platform === 'sora') {
|
||||||
@@ -272,6 +332,7 @@ const resetState = () => {
|
|||||||
outputLines.value = []
|
outputLines.value = []
|
||||||
streamingContent.value = ''
|
streamingContent.value = ''
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
generatedImages.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -325,7 +386,12 @@ const startTest = async () => {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify(
|
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
|
model?: string
|
||||||
success?: boolean
|
success?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
|
image_url?: string
|
||||||
|
mime_type?: string
|
||||||
}) => {
|
}) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'test_start':
|
case 'test_start':
|
||||||
@@ -384,7 +452,11 @@ const handleEvent = (event: {
|
|||||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||||
}
|
}
|
||||||
addLine(
|
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'
|
'text-gray-400'
|
||||||
)
|
)
|
||||||
addLine('', 'text-gray-300')
|
addLine('', 'text-gray-300')
|
||||||
@@ -398,6 +470,16 @@ const handleEvent = (event: {
|
|||||||
}
|
}
|
||||||
break
|
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':
|
case 'test_complete':
|
||||||
// Move streaming content to output lines
|
// Move streaming content to output lines
|
||||||
if (streamingContent.value) {
|
if (streamingContent.value) {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2411,6 +2411,7 @@ export default {
|
|||||||
connectedToApi: 'Connected to API',
|
connectedToApi: 'Connected to API',
|
||||||
usingModel: 'Using model: {model}',
|
usingModel: 'Using model: {model}',
|
||||||
sendingTestMessage: 'Sending test message: "hi"',
|
sendingTestMessage: 'Sending test message: "hi"',
|
||||||
|
sendingGeminiImageRequest: 'Sending Gemini image generation test request...',
|
||||||
response: 'Response:',
|
response: 'Response:',
|
||||||
startTest: 'Start Test',
|
startTest: 'Start Test',
|
||||||
testing: 'Testing...',
|
testing: 'Testing...',
|
||||||
@@ -2422,6 +2423,13 @@ export default {
|
|||||||
selectTestModel: 'Select Test Model',
|
selectTestModel: 'Select Test Model',
|
||||||
testModel: 'Test model',
|
testModel: 'Test model',
|
||||||
testPrompt: 'Prompt: "hi"',
|
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)',
|
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).',
|
soraTestHint: 'Sora test runs connectivity and capability checks (/backend/me, subscription, Sora2 invite and remaining quota).',
|
||||||
soraTestTarget: 'Target: Sora account capability',
|
soraTestTarget: 'Target: Sora account capability',
|
||||||
|
|||||||
@@ -2540,6 +2540,7 @@ export default {
|
|||||||
connectedToApi: '已连接到 API',
|
connectedToApi: '已连接到 API',
|
||||||
usingModel: '使用模型:{model}',
|
usingModel: '使用模型:{model}',
|
||||||
sendingTestMessage: '发送测试消息:"hi"',
|
sendingTestMessage: '发送测试消息:"hi"',
|
||||||
|
sendingGeminiImageRequest: '发送 Gemini 生图测试请求...',
|
||||||
response: '响应:',
|
response: '响应:',
|
||||||
startTest: '开始测试',
|
startTest: '开始测试',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
@@ -2550,6 +2551,13 @@ export default {
|
|||||||
selectTestModel: '选择测试模型',
|
selectTestModel: '选择测试模型',
|
||||||
testModel: '测试模型',
|
testModel: '测试模型',
|
||||||
testPrompt: '提示词:"hi"',
|
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)',
|
soraUpstreamBaseUrlHint: '上游 Sora 服务地址(另一个 Sub2API 实例或兼容 API)',
|
||||||
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
|
soraTestHint: 'Sora 测试将执行连通性与能力检测(/backend/me、订阅信息、Sora2 邀请码与剩余额度)。',
|
||||||
soraTestTarget: '检测目标:Sora 账号能力',
|
soraTestTarget: '检测目标:Sora 账号能力',
|
||||||
|
|||||||
Reference in New Issue
Block a user