Merge remote-tracking branch 'upstream/main' into rebuild/auth-identity-foundation
# Conflicts: # backend/internal/service/openai_images.go
This commit is contained in:
@@ -20,6 +20,8 @@ var DefaultModels = []Model{
|
|||||||
{ID: "gpt-5.3-codex", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex"},
|
{ID: "gpt-5.3-codex", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex"},
|
||||||
{ID: "gpt-5.3-codex-spark", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex Spark"},
|
{ID: "gpt-5.3-codex-spark", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.3 Codex Spark"},
|
||||||
{ID: "gpt-5.2", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.2"},
|
{ID: "gpt-5.2", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.2"},
|
||||||
|
{ID: "gpt-image-1", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1"},
|
||||||
|
{ID: "gpt-image-1.5", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1.5"},
|
||||||
{ID: "gpt-image-2", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2"},
|
{ID: "gpt-image-2", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -52,8 +53,14 @@ type TestEvent struct {
|
|||||||
const (
|
const (
|
||||||
defaultGeminiTextTestPrompt = "hi"
|
defaultGeminiTextTestPrompt = "hi"
|
||||||
defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
|
defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
|
||||||
|
defaultOpenAIImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isOpenAIImageModel checks if the model is an OpenAI image generation model (e.g. gpt-image-2).
|
||||||
|
func isOpenAIImageModel(model string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(model), "gpt-image-")
|
||||||
|
}
|
||||||
|
|
||||||
// AccountTestService handles account testing operations
|
// AccountTestService handles account testing operations
|
||||||
type AccountTestService struct {
|
type AccountTestService struct {
|
||||||
accountRepo AccountRepository
|
accountRepo AccountRepository
|
||||||
@@ -430,6 +437,18 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route to image generation test if an image model is selected
|
||||||
|
if isOpenAIImageModel(testModelID) {
|
||||||
|
imagePrompt := strings.TrimSpace(prompt)
|
||||||
|
if imagePrompt == "" {
|
||||||
|
imagePrompt = defaultOpenAIImageTestPrompt
|
||||||
|
}
|
||||||
|
if account.Type == "apikey" {
|
||||||
|
return s.testOpenAIImageAPIKey(c, ctx, account, testModelID, imagePrompt)
|
||||||
|
}
|
||||||
|
return s.testOpenAIImageOAuth(c, ctx, account, testModelID, imagePrompt)
|
||||||
|
}
|
||||||
|
|
||||||
// Determine authentication method and API URL
|
// Determine authentication method and API URL
|
||||||
var authToken string
|
var authToken string
|
||||||
var apiURL string
|
var apiURL string
|
||||||
@@ -1026,7 +1045,336 @@ func (s *AccountTestService) processOpenAIStream(c *gin.Context, body io.Reader)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendEvent sends a SSE event to the client
|
// testOpenAIImageAPIKey tests OpenAI image generation using an API Key account.
|
||||||
|
func (s *AccountTestService) testOpenAIImageAPIKey(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
|
||||||
|
authToken := account.GetOpenAIApiKey()
|
||||||
|
if authToken == "" {
|
||||||
|
return s.sendErrorAndEnd(c, "No API key available")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := account.GetOpenAIBaseURL()
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com"
|
||||||
|
}
|
||||||
|
normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error()))
|
||||||
|
}
|
||||||
|
apiURL := strings.TrimSuffix(normalizedBaseURL, "/") + "/v1/images/generations"
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
|
c.Writer.Header().Set("Connection", "keep-alive")
|
||||||
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
c.Writer.Flush()
|
||||||
|
|
||||||
|
s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID})
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"model": modelID,
|
||||||
|
"prompt": prompt,
|
||||||
|
"n": 1,
|
||||||
|
"response_format": "b64_json",
|
||||||
|
}
|
||||||
|
payloadBytes, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payloadBytes))
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, "Failed to create request")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||||
|
|
||||||
|
proxyURL := ""
|
||||||
|
if account.ProxyID != nil && account.Proxy != nil {
|
||||||
|
proxyURL = account.Proxy.URL()
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Request failed: %s", err.Error()))
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to read response: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("API returned %d: %s", resp.StatusCode, string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse {"data": [{"b64_json": "...", "revised_prompt": "..."}]}
|
||||||
|
var result struct {
|
||||||
|
Data []struct {
|
||||||
|
B64JSON string `json:"b64_json"`
|
||||||
|
RevisedPrompt string `json:"revised_prompt"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to parse response: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Data) == 0 {
|
||||||
|
return s.sendErrorAndEnd(c, "No images returned from API")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range result.Data {
|
||||||
|
if item.RevisedPrompt != "" {
|
||||||
|
s.sendEvent(c, TestEvent{Type: "content", Text: item.RevisedPrompt})
|
||||||
|
}
|
||||||
|
if item.B64JSON != "" {
|
||||||
|
s.sendEvent(c, TestEvent{
|
||||||
|
Type: "image",
|
||||||
|
ImageURL: "data:image/png;base64," + item.B64JSON,
|
||||||
|
MimeType: "image/png",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// testOpenAIImageOAuth tests OpenAI image generation using an OAuth account via ChatGPT backend API.
|
||||||
|
func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Context, account *Account, modelID, prompt string) error {
|
||||||
|
authToken := account.GetOpenAIAccessToken()
|
||||||
|
if authToken == "" {
|
||||||
|
return s.sendErrorAndEnd(c, "No access token available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set SSE headers
|
||||||
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||||
|
c.Writer.Header().Set("Connection", "keep-alive")
|
||||||
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
c.Writer.Flush()
|
||||||
|
|
||||||
|
s.sendEvent(c, TestEvent{Type: "test_start", Model: modelID})
|
||||||
|
s.sendEvent(c, TestEvent{Type: "content", Text: "Initializing ChatGPT backend...\n"})
|
||||||
|
|
||||||
|
// Build headers (replicating buildOpenAIBackendAPIHeaders logic)
|
||||||
|
headers := buildOpenAIBackendAPIHeadersForTest(ctx, account, authToken, s.accountRepo)
|
||||||
|
|
||||||
|
proxyURL := ""
|
||||||
|
if account.ProxyID != nil && account.Proxy != nil {
|
||||||
|
proxyURL = account.Proxy.URL()
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := newOpenAIBackendAPIClient(proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Failed to create client: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap
|
||||||
|
if bootstrapErr := bootstrapOpenAIBackendAPI(ctx, client, headers); bootstrapErr != nil {
|
||||||
|
log.Printf("OpenAI image test bootstrap warning: %v", bootstrapErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch chat requirements
|
||||||
|
s.sendEvent(c, TestEvent{Type: "content", Text: "Fetching chat requirements...\n"})
|
||||||
|
chatReqs, err := fetchOpenAIChatRequirements(ctx, client, headers)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Chat requirements failed: %s", err.Error()))
|
||||||
|
}
|
||||||
|
if chatReqs.Arkose.Required {
|
||||||
|
return s.sendErrorAndEnd(c, "Unsupported challenge: arkose required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize and prepare conversation
|
||||||
|
s.sendEvent(c, TestEvent{Type: "content", Text: "Preparing image conversation...\n"})
|
||||||
|
parentMessageID := uuid.NewString()
|
||||||
|
proofToken := generateOpenAIProofToken(chatReqs.ProofOfWork.Required, chatReqs.ProofOfWork.Seed, chatReqs.ProofOfWork.Difficulty, headers.Get("User-Agent"))
|
||||||
|
_ = initializeOpenAIImageConversation(ctx, client, headers)
|
||||||
|
conduitToken, err := prepareOpenAIImageConversation(ctx, client, headers, prompt, parentMessageID, chatReqs.Token, proofToken)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation prepare failed: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build simplified conversation request (no file uploads)
|
||||||
|
convReq := buildOpenAIImageTestConversationRequest(prompt, parentMessageID)
|
||||||
|
convHeaders := cloneHTTPHeader(headers)
|
||||||
|
convHeaders.Set("Accept", "text/event-stream")
|
||||||
|
convHeaders.Set("Content-Type", "application/json")
|
||||||
|
convHeaders.Set("openai-sentinel-chat-requirements-token", chatReqs.Token)
|
||||||
|
if conduitToken != "" {
|
||||||
|
convHeaders.Set("x-conduit-token", conduitToken)
|
||||||
|
}
|
||||||
|
if proofToken != "" {
|
||||||
|
convHeaders.Set("openai-sentinel-proof-token", proofToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendEvent(c, TestEvent{Type: "content", Text: "Generating image...\n"})
|
||||||
|
|
||||||
|
resp, err := client.R().
|
||||||
|
SetContext(ctx).
|
||||||
|
DisableAutoReadResponse().
|
||||||
|
SetHeaders(headerToMap(convHeaders)).
|
||||||
|
SetBodyJsonMarshal(convReq).
|
||||||
|
Post(openAIChatGPTConversationURL)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation request failed: %s", err.Error()))
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if resp != nil && resp.Body != nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Conversation API returned %d", resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
conversationID, pointerInfos, _, _, err := readOpenAIImageConversationStream(resp, startTime)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Stream read failed: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerInfos = mergeOpenAIImagePointerInfos(pointerInfos, nil)
|
||||||
|
if conversationID != "" && !hasOpenAIFileServicePointerInfos(pointerInfos) {
|
||||||
|
s.sendEvent(c, TestEvent{Type: "content", Text: "Waiting for image generation to complete...\n"})
|
||||||
|
polledPointers, pollErr := pollOpenAIImageConversation(ctx, client, headers, conversationID)
|
||||||
|
if pollErr != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Poll failed: %s", pollErr.Error()))
|
||||||
|
}
|
||||||
|
pointerInfos = mergeOpenAIImagePointerInfos(pointerInfos, polledPointers)
|
||||||
|
}
|
||||||
|
pointerInfos = preferOpenAIFileServicePointerInfos(pointerInfos)
|
||||||
|
if len(pointerInfos) == 0 {
|
||||||
|
return s.sendErrorAndEnd(c, "No images returned from conversation")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendEvent(c, TestEvent{Type: "content", Text: "Downloading generated image...\n"})
|
||||||
|
|
||||||
|
// Download and encode each image
|
||||||
|
for _, pointer := range pointerInfos {
|
||||||
|
downloadURL, err := fetchOpenAIImageDownloadURL(ctx, client, headers, conversationID, pointer.Pointer)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Download URL fetch failed: %s", err.Error()))
|
||||||
|
}
|
||||||
|
data, err := downloadOpenAIImageBytes(ctx, client, headers, downloadURL)
|
||||||
|
if err != nil {
|
||||||
|
return s.sendErrorAndEnd(c, fmt.Sprintf("Image download failed: %s", err.Error()))
|
||||||
|
}
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(data)
|
||||||
|
mimeType := http.DetectContentType(data)
|
||||||
|
if pointer.Prompt != "" {
|
||||||
|
s.sendEvent(c, TestEvent{Type: "content", Text: pointer.Prompt})
|
||||||
|
}
|
||||||
|
s.sendEvent(c, TestEvent{
|
||||||
|
Type: "image",
|
||||||
|
ImageURL: "data:" + mimeType + ";base64," + b64,
|
||||||
|
MimeType: mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOpenAIBackendAPIHeadersForTest builds ChatGPT backend API headers for test purposes.
|
||||||
|
// Replicates the logic from OpenAIGatewayService.buildOpenAIBackendAPIHeaders without
|
||||||
|
// requiring the full gateway service dependency.
|
||||||
|
func buildOpenAIBackendAPIHeadersForTest(ctx context.Context, account *Account, token string, repo AccountRepository) http.Header {
|
||||||
|
// Ensure device and session IDs exist
|
||||||
|
deviceID := account.GetOpenAIDeviceID()
|
||||||
|
sessionID := account.GetOpenAISessionID()
|
||||||
|
if deviceID == "" || sessionID == "" {
|
||||||
|
updates := map[string]any{}
|
||||||
|
if deviceID == "" {
|
||||||
|
deviceID = uuid.NewString()
|
||||||
|
updates["openai_device_id"] = deviceID
|
||||||
|
}
|
||||||
|
if sessionID == "" {
|
||||||
|
sessionID = uuid.NewString()
|
||||||
|
updates["openai_session_id"] = sessionID
|
||||||
|
}
|
||||||
|
if account.Extra == nil {
|
||||||
|
account.Extra = map[string]any{}
|
||||||
|
}
|
||||||
|
for key, value := range updates {
|
||||||
|
account.Extra[key] = value
|
||||||
|
}
|
||||||
|
if repo != nil {
|
||||||
|
updateCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = repo.UpdateExtra(updateCtx, account.ID, updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := make(http.Header)
|
||||||
|
headers.Set("Authorization", "Bearer "+token)
|
||||||
|
headers.Set("Accept", "application/json")
|
||||||
|
headers.Set("Origin", "https://chatgpt.com")
|
||||||
|
headers.Set("Referer", "https://chatgpt.com/")
|
||||||
|
headers.Set("Sec-Fetch-Dest", "empty")
|
||||||
|
headers.Set("Sec-Fetch-Mode", "cors")
|
||||||
|
headers.Set("Sec-Fetch-Site", "same-origin")
|
||||||
|
headers.Set("User-Agent", openAIImageBackendUserAgent)
|
||||||
|
if customUA := strings.TrimSpace(account.GetOpenAIUserAgent()); customUA != "" {
|
||||||
|
headers.Set("User-Agent", customUA)
|
||||||
|
}
|
||||||
|
if chatgptAccountID := strings.TrimSpace(account.GetChatGPTAccountID()); chatgptAccountID != "" {
|
||||||
|
headers.Set("chatgpt-account-id", chatgptAccountID)
|
||||||
|
}
|
||||||
|
if deviceID != "" {
|
||||||
|
headers.Set("oai-device-id", deviceID)
|
||||||
|
headers.Set("Cookie", "oai-did="+deviceID)
|
||||||
|
}
|
||||||
|
if sessionID != "" {
|
||||||
|
headers.Set("oai-session-id", sessionID)
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOpenAIImageTestConversationRequest creates a simplified image generation conversation request.
|
||||||
|
func buildOpenAIImageTestConversationRequest(prompt, parentMessageID string) map[string]any {
|
||||||
|
promptText := strings.TrimSpace(prompt)
|
||||||
|
if promptText == "" {
|
||||||
|
promptText = "Generate an image."
|
||||||
|
}
|
||||||
|
metadata := map[string]any{
|
||||||
|
"developer_mode_connector_ids": []any{},
|
||||||
|
"selected_github_repos": []any{},
|
||||||
|
"selected_all_github_repos": false,
|
||||||
|
"system_hints": []string{"picture_v2"},
|
||||||
|
"serialization_metadata": map[string]any{
|
||||||
|
"custom_symbol_offsets": []any{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
message := map[string]any{
|
||||||
|
"id": uuid.NewString(),
|
||||||
|
"author": map[string]any{"role": "user"},
|
||||||
|
"content": map[string]any{
|
||||||
|
"content_type": "text",
|
||||||
|
"parts": []any{promptText},
|
||||||
|
},
|
||||||
|
"metadata": metadata,
|
||||||
|
"create_time": float64(time.Now().UnixMilli()) / 1000,
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"action": "next",
|
||||||
|
"client_prepare_state": "sent",
|
||||||
|
"parent_message_id": parentMessageID,
|
||||||
|
"messages": []any{message},
|
||||||
|
"model": "auto",
|
||||||
|
"timezone_offset_min": openAITimezoneOffsetMinutes(),
|
||||||
|
"timezone": openAITimezoneName(),
|
||||||
|
"conversation_mode": map[string]any{"kind": "primary_assistant"},
|
||||||
|
"system_hints": []string{"picture_v2"},
|
||||||
|
"supports_buffering": true,
|
||||||
|
"supported_encodings": []string{"v1"},
|
||||||
|
"client_contextual_info": map[string]any{"app_name": "chatgpt.com"},
|
||||||
|
"force_nulligen": false,
|
||||||
|
"force_paragen": false,
|
||||||
|
"force_paragen_model_slug": "",
|
||||||
|
"force_rate_limit": false,
|
||||||
|
"websocket_request_id": uuid.NewString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
|
func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
|
||||||
eventJSON, _ := json.Marshal(event)
|
eventJSON, _ := json.Marshal(event)
|
||||||
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {
|
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {
|
||||||
|
|||||||
@@ -917,7 +917,15 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages(
|
|||||||
excludedIDs map[int64]struct{},
|
excludedIDs map[int64]struct{},
|
||||||
requiredCapability OpenAIImagesCapability,
|
requiredCapability OpenAIImagesCapability,
|
||||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||||
return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, requiredCapability)
|
selection, decision, err := s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, requiredCapability)
|
||||||
|
if err == nil && selection != nil && selection.Account != nil {
|
||||||
|
return selection, decision, nil
|
||||||
|
}
|
||||||
|
// 如果要求 native 能力(如指定了模型)但没有可用的 APIKey 账号,回退到 basic(OAuth 账号)
|
||||||
|
if requiredCapability == OpenAIImagesCapabilityNative {
|
||||||
|
return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, OpenAIImagesCapabilityBasic)
|
||||||
|
}
|
||||||
|
return selection, decision, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) selectAccountWithScheduler(
|
func (s *OpenAIGatewayService) selectAccountWithScheduler(
|
||||||
|
|||||||
@@ -1294,7 +1294,7 @@ type openAIImageToolMessage struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readOpenAIImageConversationStream(resp *req.Response, startTime time.Time) (string, []openAIImagePointerInfo, OpenAIUsage, *int, error) {
|
func readOpenAIImageConversationStream(resp *req.Response, startTime time.Time) (string, []openAIImagePointerInfo, OpenAIUsage, *int, error) {
|
||||||
if resp == nil || resp.Response == nil || resp.Body == nil {
|
if resp == nil || resp.Body == nil {
|
||||||
return "", nil, OpenAIUsage{}, nil, fmt.Errorf("empty conversation response")
|
return "", nil, OpenAIUsage{}, nil, fmt.Errorf("empty conversation response")
|
||||||
}
|
}
|
||||||
reader := bufio.NewReader(resp.Body)
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
|||||||
@@ -55,12 +55,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
|
<div v-if="supportsImageTest" class="space-y-1.5">
|
||||||
<TextArea
|
<TextArea
|
||||||
v-model="testPrompt"
|
v-model="testPrompt"
|
||||||
:label="t('admin.accounts.geminiImagePromptLabel')"
|
:label="t('admin.accounts.imagePromptLabel')"
|
||||||
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
|
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
|
||||||
:hint="t('admin.accounts.geminiImageTestHint')"
|
:hint="t('admin.accounts.imageTestHint')"
|
||||||
:disabled="status === 'connecting'"
|
:disabled="status === 'connecting'"
|
||||||
rows="3"
|
rows="3"
|
||||||
/>
|
/>
|
||||||
@@ -122,25 +122,49 @@
|
|||||||
|
|
||||||
<div v-if="generatedImages.length > 0" class="space-y-2">
|
<div v-if="generatedImages.length > 0" class="space-y-2">
|
||||||
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||||
{{ t('admin.accounts.geminiImagePreview') }}
|
{{ t('admin.accounts.imagePreview') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
<a
|
<div
|
||||||
v-for="(image, index) in generatedImages"
|
v-for="(image, index) in generatedImages"
|
||||||
:key="`${image.url}-${index}`"
|
:key="`${image.url}-${index}`"
|
||||||
:href="image.url"
|
class="group/img relative cursor-pointer 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"
|
||||||
target="_blank"
|
@click="previewImageUrl = image.url"
|
||||||
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" />
|
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
|
||||||
<div class="border-t border-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
|
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
|
||||||
|
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-100 px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
|
||||||
{{ image.mimeType || 'image/*' }}
|
{{ image.mimeType || 'image/*' }}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Lightbox -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="previewImageUrl"
|
||||||
|
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
|
||||||
|
@click.self="previewImageUrl = ''"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||||
|
@click="previewImageUrl = ''"
|
||||||
|
>
|
||||||
|
<Icon name="x" size="lg" :stroke-width="2" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
:src="previewImageUrl"
|
||||||
|
alt="preview"
|
||||||
|
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- 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">
|
||||||
@@ -152,8 +176,8 @@
|
|||||||
<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" />
|
||||||
{{
|
{{
|
||||||
supportsGeminiImageTest
|
supportsImageTest
|
||||||
? t('admin.accounts.geminiImageTestMode')
|
? t('admin.accounts.imageTestMode')
|
||||||
: t('admin.accounts.testPrompt')
|
: t('admin.accounts.testPrompt')
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
@@ -250,6 +274,7 @@ const testPrompt = ref('')
|
|||||||
const loadingModels = ref(false)
|
const loadingModels = ref(false)
|
||||||
let abortController: AbortController | null = null
|
let abortController: AbortController | null = null
|
||||||
const generatedImages = ref<PreviewImage[]>([])
|
const generatedImages = ref<PreviewImage[]>([])
|
||||||
|
const previewImageUrl = ref('')
|
||||||
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 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(() => {
|
const supportsGeminiImageTest = computed(() => {
|
||||||
const modelID = selectedModelId.value.toLowerCase()
|
const modelID = selectedModelId.value.toLowerCase()
|
||||||
@@ -258,6 +283,14 @@ const supportsGeminiImageTest = computed(() => {
|
|||||||
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
|
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const supportsOpenAIImageTest = computed(() => {
|
||||||
|
const modelID = selectedModelId.value.toLowerCase()
|
||||||
|
if (!modelID.startsWith('gpt-image-')) return false
|
||||||
|
return props.account?.platform === 'openai'
|
||||||
|
})
|
||||||
|
|
||||||
|
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
|
||||||
|
|
||||||
const sortTestModels = (models: ClaudeModel[]) => {
|
const sortTestModels = (models: ClaudeModel[]) => {
|
||||||
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
|
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
|
||||||
|
|
||||||
@@ -284,8 +317,8 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
watch(selectedModelId, () => {
|
watch(selectedModelId, () => {
|
||||||
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
|
if (supportsImageTest.value && !testPrompt.value.trim()) {
|
||||||
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
|
testPrompt.value = t('admin.accounts.imagePromptDefault')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -325,6 +358,7 @@ const resetState = () => {
|
|||||||
streamingContent.value = ''
|
streamingContent.value = ''
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
generatedImages.value = []
|
generatedImages.value = []
|
||||||
|
previewImageUrl.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -377,7 +411,7 @@ const startTest = async () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model_id: selectedModelId.value,
|
model_id: selectedModelId.value,
|
||||||
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
|
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
|
||||||
}),
|
}),
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
})
|
})
|
||||||
@@ -444,8 +478,8 @@ 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(
|
||||||
supportsGeminiImageTest.value
|
supportsImageTest.value
|
||||||
? t('admin.accounts.sendingGeminiImageRequest')
|
? t('admin.accounts.sendingImageRequest')
|
||||||
: t('admin.accounts.sendingTestMessage'),
|
: t('admin.accounts.sendingTestMessage'),
|
||||||
'text-gray-400'
|
'text-gray-400'
|
||||||
)
|
)
|
||||||
@@ -466,7 +500,7 @@ const handleEvent = (event: {
|
|||||||
url: event.image_url,
|
url: event.image_url,
|
||||||
mimeType: event.mime_type
|
mimeType: event.mime_type
|
||||||
})
|
})
|
||||||
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
|
addLine(t('admin.accounts.imageReceived', { count: generatedImages.value.length }), 'text-purple-300')
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -500,3 +534,14 @@ const copyOutput = () => {
|
|||||||
copyToClipboard(text, t('admin.accounts.outputCopied'))
|
copyToClipboard(text, t('admin.accounts.outputCopied'))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -55,12 +55,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="supportsGeminiImageTest" class="space-y-1.5">
|
<div v-if="supportsImageTest" class="space-y-1.5">
|
||||||
<TextArea
|
<TextArea
|
||||||
v-model="testPrompt"
|
v-model="testPrompt"
|
||||||
:label="t('admin.accounts.geminiImagePromptLabel')"
|
:label="t('admin.accounts.imagePromptLabel')"
|
||||||
:placeholder="t('admin.accounts.geminiImagePromptPlaceholder')"
|
:placeholder="t('admin.accounts.imagePromptPlaceholder')"
|
||||||
:hint="t('admin.accounts.geminiImageTestHint')"
|
:hint="t('admin.accounts.imageTestHint')"
|
||||||
:disabled="status === 'connecting'"
|
:disabled="status === 'connecting'"
|
||||||
rows="3"
|
rows="3"
|
||||||
/>
|
/>
|
||||||
@@ -122,25 +122,49 @@
|
|||||||
|
|
||||||
<div v-if="generatedImages.length > 0" class="space-y-2">
|
<div v-if="generatedImages.length > 0" class="space-y-2">
|
||||||
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
<div class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||||
{{ t('admin.accounts.geminiImagePreview') }}
|
{{ t('admin.accounts.imagePreview') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
<a
|
<div
|
||||||
v-for="(image, index) in generatedImages"
|
v-for="(image, index) in generatedImages"
|
||||||
:key="`${image.url}-${index}`"
|
:key="`${image.url}-${index}`"
|
||||||
:href="image.url"
|
class="group/img relative cursor-pointer 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"
|
||||||
target="_blank"
|
@click="previewImageUrl = image.url"
|
||||||
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" />
|
<img :src="image.url" :alt="`test-image-${index + 1}`" class="max-h-[360px] w-full object-contain" />
|
||||||
<div class="border-t border-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
|
<div class="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover/img:bg-black/20">
|
||||||
|
<Icon name="eye" size="lg" class="text-white opacity-0 drop-shadow-lg transition-opacity group-hover/img:opacity-100" :stroke-width="2" />
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-100 px-3 py-1.5 text-xs text-gray-500 dark:border-dark-500 dark:text-gray-300">
|
||||||
{{ image.mimeType || 'image/*' }}
|
{{ image.mimeType || 'image/*' }}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Lightbox -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="previewImageUrl"
|
||||||
|
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 p-4"
|
||||||
|
@click.self="previewImageUrl = ''"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
|
||||||
|
@click="previewImageUrl = ''"
|
||||||
|
>
|
||||||
|
<Icon name="x" size="lg" :stroke-width="2" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
:src="previewImageUrl"
|
||||||
|
alt="preview"
|
||||||
|
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
<!-- 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">
|
||||||
@@ -152,8 +176,8 @@
|
|||||||
<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" />
|
||||||
{{
|
{{
|
||||||
supportsGeminiImageTest
|
supportsImageTest
|
||||||
? t('admin.accounts.geminiImageTestMode')
|
? t('admin.accounts.imageTestMode')
|
||||||
: t('admin.accounts.testPrompt')
|
: t('admin.accounts.testPrompt')
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
@@ -250,6 +274,7 @@ const testPrompt = ref('')
|
|||||||
const loadingModels = ref(false)
|
const loadingModels = ref(false)
|
||||||
let abortController: AbortController | null = null
|
let abortController: AbortController | null = null
|
||||||
const generatedImages = ref<PreviewImage[]>([])
|
const generatedImages = ref<PreviewImage[]>([])
|
||||||
|
const previewImageUrl = ref('')
|
||||||
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 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(() => {
|
const supportsGeminiImageTest = computed(() => {
|
||||||
const modelID = selectedModelId.value.toLowerCase()
|
const modelID = selectedModelId.value.toLowerCase()
|
||||||
@@ -258,6 +283,14 @@ const supportsGeminiImageTest = computed(() => {
|
|||||||
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
|
return props.account?.platform === 'gemini' || (props.account?.platform === 'antigravity' && props.account?.type === 'apikey')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const supportsOpenAIImageTest = computed(() => {
|
||||||
|
const modelID = selectedModelId.value.toLowerCase()
|
||||||
|
if (!modelID.startsWith('gpt-image-')) return false
|
||||||
|
return props.account?.platform === 'openai'
|
||||||
|
})
|
||||||
|
|
||||||
|
const supportsImageTest = computed(() => supportsGeminiImageTest.value || supportsOpenAIImageTest.value)
|
||||||
|
|
||||||
const sortTestModels = (models: ClaudeModel[]) => {
|
const sortTestModels = (models: ClaudeModel[]) => {
|
||||||
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
|
const priorityMap = new Map(prioritizedGeminiModels.map((id, index) => [id, index]))
|
||||||
|
|
||||||
@@ -284,8 +317,8 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
watch(selectedModelId, () => {
|
watch(selectedModelId, () => {
|
||||||
if (supportsGeminiImageTest.value && !testPrompt.value.trim()) {
|
if (supportsImageTest.value && !testPrompt.value.trim()) {
|
||||||
testPrompt.value = t('admin.accounts.geminiImagePromptDefault')
|
testPrompt.value = t('admin.accounts.imagePromptDefault')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -325,6 +358,7 @@ const resetState = () => {
|
|||||||
streamingContent.value = ''
|
streamingContent.value = ''
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
generatedImages.value = []
|
generatedImages.value = []
|
||||||
|
previewImageUrl.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
@@ -377,7 +411,7 @@ const startTest = async () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model_id: selectedModelId.value,
|
model_id: selectedModelId.value,
|
||||||
prompt: supportsGeminiImageTest.value ? testPrompt.value.trim() : ''
|
prompt: supportsImageTest.value ? testPrompt.value.trim() : ''
|
||||||
}),
|
}),
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
})
|
})
|
||||||
@@ -444,8 +478,8 @@ 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(
|
||||||
supportsGeminiImageTest.value
|
supportsImageTest.value
|
||||||
? t('admin.accounts.sendingGeminiImageRequest')
|
? t('admin.accounts.sendingImageRequest')
|
||||||
: t('admin.accounts.sendingTestMessage'),
|
: t('admin.accounts.sendingTestMessage'),
|
||||||
'text-gray-400'
|
'text-gray-400'
|
||||||
)
|
)
|
||||||
@@ -466,7 +500,7 @@ const handleEvent = (event: {
|
|||||||
url: event.image_url,
|
url: event.image_url,
|
||||||
mimeType: event.mime_type
|
mimeType: event.mime_type
|
||||||
})
|
})
|
||||||
addLine(t('admin.accounts.geminiImageReceived', { count: generatedImages.value.length }), 'text-purple-300')
|
addLine(t('admin.accounts.imageReceived', { count: generatedImages.value.length }), 'text-purple-300')
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -500,3 +534,14 @@ const copyOutput = () => {
|
|||||||
copyToClipboard(text, t('admin.accounts.outputCopied'))
|
copyToClipboard(text, t('admin.accounts.outputCopied'))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ vi.mock('@/composables/useClipboard', () => ({
|
|||||||
vi.mock('vue-i18n', async () => {
|
vi.mock('vue-i18n', async () => {
|
||||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||||
const messages: Record<string, string> = {
|
const messages: Record<string, string> = {
|
||||||
'admin.accounts.geminiImagePromptDefault': 'Generate a cute orange cat astronaut sticker on a clean pastel background.'
|
'admin.accounts.imagePromptDefault': 'Generate a cute orange cat astronaut sticker on a clean pastel background.'
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
useI18n: () => ({
|
useI18n: () => ({
|
||||||
t: (key: string, params?: Record<string, string | number>) => {
|
t: (key: string, params?: Record<string, string | number>) => {
|
||||||
if (key === 'admin.accounts.geminiImageReceived' && params?.count) {
|
if (key === 'admin.accounts.imageReceived' && params?.count) {
|
||||||
return `received-${params.count}`
|
return `received-${params.count}`
|
||||||
}
|
}
|
||||||
return messages[key] || key
|
return messages[key] || key
|
||||||
@@ -140,7 +140,7 @@ describe('AccountTestModal', () => {
|
|||||||
prompt: 'draw a tiny orange cat astronaut'
|
prompt: 'draw a tiny orange cat astronaut'
|
||||||
})
|
})
|
||||||
|
|
||||||
const preview = wrapper.find('img[alt="gemini-test-image-1"]')
|
const preview = wrapper.find('img[alt="test-image-1"]')
|
||||||
expect(preview.exists()).toBe(true)
|
expect(preview.exists()).toBe(true)
|
||||||
expect(preview.attributes('src')).toBe('data:image/png;base64,QUJD')
|
expect(preview.attributes('src')).toBe('data:image/png;base64,QUJD')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ const openaiModels = [
|
|||||||
// GPT-5.3 系列
|
// GPT-5.3 系列
|
||||||
'gpt-5.3-codex', 'gpt-5.3-codex-spark',
|
'gpt-5.3-codex', 'gpt-5.3-codex-spark',
|
||||||
'chatgpt-4o-latest',
|
'chatgpt-4o-latest',
|
||||||
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview'
|
'gpt-4o-audio-preview', 'gpt-4o-realtime-preview',
|
||||||
|
// GPT Image 系列
|
||||||
|
'gpt-image-1', 'gpt-image-1.5', 'gpt-image-2'
|
||||||
]
|
]
|
||||||
|
|
||||||
// Anthropic Claude
|
// Anthropic Claude
|
||||||
|
|||||||
@@ -3023,7 +3023,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...',
|
sendingImageRequest: 'Sending image generation test request...',
|
||||||
response: 'Response:',
|
response: 'Response:',
|
||||||
startTest: 'Start Test',
|
startTest: 'Start Test',
|
||||||
testing: 'Testing...',
|
testing: 'Testing...',
|
||||||
@@ -3035,13 +3035,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',
|
imagePromptLabel: 'Image prompt',
|
||||||
geminiImagePromptPlaceholder: 'Example: Generate an orange cat astronaut sticker in pixel-art style on a solid background.',
|
imagePromptPlaceholder: '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.',
|
imagePromptDefault: '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.',
|
imageTestHint: 'When an image model is selected, this test sends a real image-generation request and previews the returned image below.',
|
||||||
geminiImageTestMode: 'Mode: Gemini image generation test',
|
imageTestMode: 'Mode: Image generation test',
|
||||||
geminiImagePreview: 'Generated images:',
|
imagePreview: 'Generated images:',
|
||||||
geminiImageReceived: 'Received test image #{count}',
|
imageReceived: 'Received test image #{count}',
|
||||||
// Stats Modal
|
// Stats Modal
|
||||||
viewStats: 'View Stats',
|
viewStats: 'View Stats',
|
||||||
usageStatistics: 'Usage Statistics',
|
usageStatistics: 'Usage Statistics',
|
||||||
|
|||||||
@@ -3154,7 +3154,7 @@ export default {
|
|||||||
connectedToApi: '已连接到 API',
|
connectedToApi: '已连接到 API',
|
||||||
usingModel: '使用模型:{model}',
|
usingModel: '使用模型:{model}',
|
||||||
sendingTestMessage: '发送测试消息:"hi"',
|
sendingTestMessage: '发送测试消息:"hi"',
|
||||||
sendingGeminiImageRequest: '发送 Gemini 生图测试请求...',
|
sendingImageRequest: '发送生图测试请求...',
|
||||||
response: '响应:',
|
response: '响应:',
|
||||||
startTest: '开始测试',
|
startTest: '开始测试',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
@@ -3165,13 +3165,13 @@ export default {
|
|||||||
selectTestModel: '选择测试模型',
|
selectTestModel: '选择测试模型',
|
||||||
testModel: '测试模型',
|
testModel: '测试模型',
|
||||||
testPrompt: '提示词:"hi"',
|
testPrompt: '提示词:"hi"',
|
||||||
geminiImagePromptLabel: '生图提示词',
|
imagePromptLabel: '生图提示词',
|
||||||
geminiImagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
|
imagePromptPlaceholder: '例如:生成一只戴宇航员头盔的橘猫,像素插画风格,纯色背景。',
|
||||||
geminiImagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
imagePromptDefault: 'Generate a cute orange cat astronaut sticker on a clean pastel background.',
|
||||||
geminiImageTestHint: '选择 Gemini 图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
|
imageTestHint: '选择图片模型后,这里会直接发起生图测试,并在下方展示返回图片。',
|
||||||
geminiImageTestMode: '模式:Gemini 生图测试',
|
imageTestMode: '模式:生图测试',
|
||||||
geminiImagePreview: '生成结果:',
|
imagePreview: '生成结果:',
|
||||||
geminiImageReceived: '已收到第 {count} 张测试图片',
|
imageReceived: '已收到第 {count} 张测试图片',
|
||||||
// Stats Modal
|
// Stats Modal
|
||||||
viewStats: '查看统计',
|
viewStats: '查看统计',
|
||||||
usageStatistics: '使用统计',
|
usageStatistics: '使用统计',
|
||||||
|
|||||||
@@ -628,11 +628,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片生成计费配置(antigravity 和 gemini 平台) -->
|
<!-- 图片生成计费配置 -->
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
createForm.platform === 'antigravity' ||
|
createForm.platform === 'antigravity' ||
|
||||||
createForm.platform === 'gemini'
|
createForm.platform === 'gemini' ||
|
||||||
|
createForm.platform === 'openai'
|
||||||
"
|
"
|
||||||
class="border-t pt-4"
|
class="border-t pt-4"
|
||||||
>
|
>
|
||||||
@@ -1750,11 +1751,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片生成计费配置(antigravity 和 gemini 平台) -->
|
<!-- 图片生成计费配置 -->
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
editForm.platform === 'antigravity' ||
|
editForm.platform === 'antigravity' ||
|
||||||
editForm.platform === 'gemini'
|
editForm.platform === 'gemini' ||
|
||||||
|
editForm.platform === 'openai'
|
||||||
"
|
"
|
||||||
class="border-t pt-4"
|
class="border-t pt-4"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user