diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 4fa7ec4..07cf2e8 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -101,6 +101,12 @@ func buildConfigSnapshot(providerName, modelID string, params map[string]interfa } else if v, ok := params["image_size"].(string); ok && v != "" { snapshot["imageSize"] = v } + if v, ok := params["size"].(string); ok && strings.TrimSpace(v) != "" { + snapshot["size"] = strings.TrimSpace(v) + } + if v, ok := params["quality"].(string); ok && strings.TrimSpace(v) != "" { + snapshot["quality"] = strings.TrimSpace(v) + } // count 可能是 float64(JSON 解析)或 int(服务内部) if v, ok := params["count"].(int); ok && v > 0 { @@ -132,7 +138,7 @@ func fetchProviderConfig(providerName string) *model.ProviderConfig { func defaultTimeoutSecondsForProvider(providerName string) int { switch providerName { - case "gemini", "openai": + case "gemini", "openai", "openai-image": return 500 default: return 150 @@ -141,7 +147,7 @@ func defaultTimeoutSecondsForProvider(providerName string) int { func providerDefaultMaxRetries(providerName string) int { switch providerName { - case "gemini", "openai": + case "gemini", "openai", "openai-image": return 1 default: return 1 diff --git a/backend/internal/model/db.go b/backend/internal/model/db.go index bdc8ecb..c0ab3b7 100644 --- a/backend/internal/model/db.go +++ b/backend/internal/model/db.go @@ -49,12 +49,12 @@ func InitDB(dbPath string) { // 兼容旧版本默认超时(0/60s)记录:按 Provider 类型修复到对应默认值 if err := DB.Model(&ProviderConfig{}). - Where("provider_name IN ? AND (timeout_seconds <= 0 OR timeout_seconds = ?)", []string{"gemini", "openai"}, 60). + Where("provider_name IN ? AND (timeout_seconds <= 0 OR timeout_seconds = ?)", []string{"gemini", "openai", "openai-image"}, 60). Update("timeout_seconds", 500).Error; err != nil { log.Printf("更新生图默认超时失败: %v", err) } if err := DB.Model(&ProviderConfig{}). - Where("provider_name NOT IN ? AND (timeout_seconds <= 0 OR timeout_seconds = ?)", []string{"gemini", "openai"}, 60). + Where("provider_name NOT IN ? AND (timeout_seconds <= 0 OR timeout_seconds = ?)", []string{"gemini", "openai", "openai-image"}, 60). Update("timeout_seconds", 150).Error; err != nil { log.Printf("更新对话默认超时失败: %v", err) } @@ -75,7 +75,7 @@ func InitDB(dbPath string) { func defaultTimeoutForProvider(providerName string) time.Duration { switch providerName { - case "gemini", "openai": + case "gemini", "openai", "openai-image": return 500 * time.Second default: return 150 * time.Second diff --git a/backend/internal/provider/gemini.go b/backend/internal/provider/gemini.go index 00bb4f5..3205cb3 100644 --- a/backend/internal/provider/gemini.go +++ b/backend/internal/provider/gemini.go @@ -94,7 +94,7 @@ func NewGeminiProvider(config *model.ProviderConfig) (*GeminiProvider, error) { func (p *GeminiProvider) newHTTPClient() *http.Client { return &http.Client{ Transport: &http.Transport{ - ForceAttemptHTTP2: false, + ForceAttemptHTTP2: false, TLSClientConfig: &tls.Config{ InsecureSkipVerify: false, MinVersion: tls.VersionTLS12, diff --git a/backend/internal/provider/model_resolver.go b/backend/internal/provider/model_resolver.go index 83e0ddf..fc34f45 100644 --- a/backend/internal/provider/model_resolver.go +++ b/backend/internal/provider/model_resolver.go @@ -87,5 +87,11 @@ func defaultModelForProvider(providerName string, purpose ModelPurpose) string { if purpose == PurposeChat || name == "openai-chat" { return "gemini-3-flash-preview" } + if name == "openai-image" { + return "gpt-image-1" + } + if name == "openai" { + return "gemini-3-pro-image-preview" + } return "gemini-3-pro-image-preview" } diff --git a/backend/internal/provider/openai_image.go b/backend/internal/provider/openai_image.go new file mode 100644 index 0000000..4b4eaec --- /dev/null +++ b/backend/internal/provider/openai_image.go @@ -0,0 +1,233 @@ +package provider + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image-gen-service/internal/diagnostic" + "image-gen-service/internal/model" + "io" + "net/http" + "strings" + "time" +) + +type OpenAIImageProvider struct { + *OpenAIProvider +} + +type openAIImagesGenerationRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Size string `json:"size"` + Quality string `json:"quality,omitempty"` + N int `json:"n,omitempty"` +} + +func NewOpenAIImageProvider(config *model.ProviderConfig) (*OpenAIImageProvider, error) { + base, err := NewOpenAIProvider(config) + if err != nil { + return nil, err + } + return &OpenAIImageProvider{OpenAIProvider: base}, nil +} + +func (p *OpenAIImageProvider) Name() string { + return "openai-image" +} + +func (p *OpenAIImageProvider) ValidateParams(params map[string]interface{}) error { + prompt, _ := params["prompt"].(string) + if strings.TrimSpace(prompt) == "" { + return fmt.Errorf("prompt 不能为空") + } + if raw, ok := params["reference_images"].([]interface{}); ok && len(raw) > 0 { + return fmt.Errorf("OpenAI Images 当前仅支持文本生图") + } + + count, ok := toInt(params["count"]) + if !ok { + count = 1 + } + if count < 1 || count > 10 { + return fmt.Errorf("count/n 必须介于 1 和 10 之间") + } + + size, _ := params["size"].(string) + switch strings.TrimSpace(strings.ToLower(size)) { + case "", "auto", "1024x1024", "1024x1536", "1536x1024": + default: + return fmt.Errorf("size 仅支持 auto、1024x1024、1024x1536、1536x1024") + } + + quality, _ := params["quality"].(string) + switch strings.TrimSpace(strings.ToLower(quality)) { + case "", "auto", "low", "medium", "high": + default: + return fmt.Errorf("quality 仅支持 auto、low、medium、high") + } + + return nil +} + +func (p *OpenAIImageProvider) Generate(ctx context.Context, params map[string]interface{}) (*ProviderResult, error) { + modelID := ResolveModelID(ModelResolveOptions{ + ProviderName: p.Name(), + Purpose: PurposeImage, + Params: params, + Config: p.config, + }).ID + if modelID == "" { + return nil, fmt.Errorf("缺少 model_id 参数") + } + + reqBody, promptPreview, err := p.buildImagesGenerationRequestBody(modelID, params) + if err != nil { + return nil, err + } + + diagnostic.Logf(params, "request_prepare", + "provider=%s model=%s size=%q quality=%q count=%d prompt_hash=%s prompt_preview=%q", + p.Name(), + modelID, + reqBody.Size, + reqBody.Quality, + reqBody.N, + diagnostic.PromptHash(promptPreview), + diagnostic.Preview(promptPreview, 160), + ) + + respBytes, headers, err := p.doImagesGenerationRequest(ctx, reqBody, params) + if err != nil { + return nil, err + } + + images, summary, err := p.extractImages(ctx, respBytes) + if err != nil { + return nil, err + } + + requestID := extractRequestIDFromHeaders(headers) + diagnostic.Logf(params, "response_summary", + "provider=%s model=%s data_count=%d choice_count=%d image_count=%d request_id=%s", + p.Name(), + modelID, + summary.DataCount, + summary.ChoiceCount, + len(images), + requestID, + ) + + return &ProviderResult{ + Images: images, + Metadata: map[string]interface{}{ + "provider": p.Name(), + "model": modelID, + "type": "image", + "request_id": requestID, + "oneapi_request": strings.TrimSpace(headers.Get("X-Oneapi-Request-Id")), + }, + }, nil +} + +func (p *OpenAIImageProvider) buildImagesGenerationRequestBody(modelID string, params map[string]interface{}) (*openAIImagesGenerationRequest, string, error) { + prompt, _ := params["prompt"].(string) + prompt = strings.TrimSpace(prompt) + if prompt == "" { + return nil, "", fmt.Errorf("缺少 prompt 参数") + } + + body := &openAIImagesGenerationRequest{ + Model: modelID, + Prompt: prompt, + Size: "auto", + N: 1, + } + if size, _ := params["size"].(string); strings.TrimSpace(size) != "" { + body.Size = strings.TrimSpace(strings.ToLower(size)) + } + if quality, _ := params["quality"].(string); strings.TrimSpace(quality) != "" { + body.Quality = strings.TrimSpace(strings.ToLower(quality)) + } + if count, ok := toInt(params["count"]); ok && count >= 1 && count <= 10 { + body.N = count + } + + return body, prompt, nil +} + +func (p *OpenAIImageProvider) doImagesGenerationRequest(ctx context.Context, body *openAIImagesGenerationRequest, params map[string]interface{}) ([]byte, http.Header, error) { + payloadBytes, err := json.Marshal(body) + if err != nil { + return nil, nil, fmt.Errorf("序列化 OpenAI Images 请求失败: %w", err) + } + + requestURL := strings.TrimRight(strings.TrimSpace(p.apiBase), "/") + "/images/generations" + diagnostic.Logf(params, "request_payload", + "url=%s body=%q", + diagnostic.RedactSensitive(requestURL), + diagnostic.RedactSensitive(string(payloadBytes)), + ) + + maxRetries := providerMaxRetries(p.config) + var elapsed time.Duration + resp, _, err := doRequestWithRetry(ctx, params, p.Name(), maxRetries, func(attempt int) (*http.Response, error) { + req, buildErr := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(payloadBytes)) + if buildErr != nil { + return nil, fmt.Errorf("构建 OpenAI Images 请求失败: %w", buildErr) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(p.config.APIKey)) + req.Header.Set("Connection", "close") + if strings.TrimSpace(p.userAgent) != "" { + req.Header.Set("User-Agent", p.userAgent) + } + + startedAt := time.Now() + resp, doErr := p.httpClient.Do(req) + elapsed = time.Since(startedAt) + return resp, doErr + }) + if err != nil { + return nil, nil, fmt.Errorf("doRequest: error sending request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.Header.Clone(), fmt.Errorf("读取 OpenAI Images 响应失败: %w", err) + } + + requestID := extractRequestIDFromHeaders(resp.Header) + diagnostic.Logf(params, "response_headers", + "status=%s elapsed=%s request_id=%s headers=%q", + resp.Status, + elapsed, + requestID, + diagnostic.Preview(strings.Join(headerLines(resp.Header), " | "), 1000), + ) + diagnostic.Logf(params, "response_body", + "status=%s elapsed=%s request_id=%s body=%q", + resp.Status, + elapsed, + requestID, + diagnostic.RedactSensitive(string(respBody)), + ) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyPreview := diagnostic.Preview(parseOpenAIError(respBody), 1200) + if requestID == "" { + requestID = diagnostic.ExtractRequestID(string(respBody)) + } + return nil, resp.Header.Clone(), fmt.Errorf("OpenAI HTTP %d request_id=%s body=%s", resp.StatusCode, requestID, bodyPreview) + } + + if len(respBody) == 0 { + return nil, resp.Header.Clone(), fmt.Errorf("接口未返回内容") + } + + return respBody, resp.Header.Clone(), nil +} diff --git a/backend/internal/provider/provider.go b/backend/internal/provider/provider.go index b7f505b..b31b22b 100644 --- a/backend/internal/provider/provider.go +++ b/backend/internal/provider/provider.go @@ -30,7 +30,7 @@ var ( func defaultTimeoutSeconds(providerName string) int { switch providerName { - case "gemini", "openai": + case "gemini", "openai", "openai-image": return 500 default: return 150 @@ -66,7 +66,7 @@ func InitProviders() error { defer initMu.Unlock() // 0. 确保基础 Provider 至少存在于数据库中(即使没有配置文件) - defaultProviders := []string{"gemini", "openai"} + defaultProviders := []string{"gemini", "openai", "openai-image"} for _, name := range defaultProviders { var count int64 model.DB.Model(&model.ProviderConfig{}).Where("provider_name = ?", name).Count(&count) @@ -135,6 +135,8 @@ func InitProviders() error { p, err = NewGeminiProvider(&cfg) case "openai": p, err = NewOpenAIProvider(&cfg) + case "openai-image": + p, err = NewOpenAIImageProvider(&cfg) default: log.Printf("未知的 Provider 类型: %s", cfg.ProviderName) continue diff --git a/backend/internal/worker/pool.go b/backend/internal/worker/pool.go index 7ffe7be..f34f272 100644 --- a/backend/internal/worker/pool.go +++ b/backend/internal/worker/pool.go @@ -199,19 +199,19 @@ func (wp *WorkerPool) processTask(task *Task) { len([]rune(task.TaskModel.Prompt)), ) - if err := wp.optimizePromptForTask(ctx, task); err != nil { - log.Printf("任务 %s 自动优化提示词失败,终止生图: %v", task.TaskModel.TaskID, err) - diagnostic.Logf(task.Params, "prompt_optimize_failed", - "mode=%s provider=%s model=%s err=%q fallback=%t", - task.TaskModel.PromptOptimizeMode, - promptopt.ExtractProvider(task.Params), - promptopt.ExtractModel(task.Params), - err.Error(), - false, - ) - wp.failTask(task, fmt.Errorf("提示词优化失败: %w", err)) - return - } + if err := wp.optimizePromptForTask(ctx, task); err != nil { + log.Printf("任务 %s 自动优化提示词失败,终止生图: %v", task.TaskModel.TaskID, err) + diagnostic.Logf(task.Params, "prompt_optimize_failed", + "mode=%s provider=%s model=%s err=%q fallback=%t", + task.TaskModel.PromptOptimizeMode, + promptopt.ExtractProvider(task.Params), + promptopt.ExtractModel(task.Params), + err.Error(), + false, + ) + wp.failTask(task, fmt.Errorf("提示词优化失败: %w", err)) + return + } done := make(chan generateResult, 1) go func() { diff --git a/backend/scripts/seed.go b/backend/scripts/seed.go index 5513945..677022c 100644 --- a/backend/scripts/seed.go +++ b/backend/scripts/seed.go @@ -12,7 +12,7 @@ func main() { ProviderName: "gemini", DisplayName: "Google Gemini", APIBase: "https://generativelanguage.googleapis.com", - APIKey: "YOUR_API_KEY_HERE", // 用户需要替换为自己的 API Key + APIKey: "", Models: `[{"id": "gemini-2.0-flash-exp", "name": "Gemini 2.0 Flash (Native)", "default": true}, {"id": "imagen-3.0-generate-001", "name": "Imagen 3.0"}]`, Enabled: true, TimeoutSeconds: 500, @@ -28,7 +28,7 @@ func main() { ProviderName: "openai", DisplayName: "OpenAI Compatible", APIBase: "https://api.openai.com/v1", - APIKey: "YOUR_API_KEY_HERE", + APIKey: "", Models: `[{"id": "gpt-image-1", "name": "gpt-image-1", "default": true}]`, Enabled: true, TimeoutSeconds: 500, @@ -39,5 +39,20 @@ func main() { log.Fatalf("初始化 OpenAI 配置失败: %v", err) } - log.Println("默认 Gemini/OpenAI 配置已初始化") + openAIImageConfig := model.ProviderConfig{ + ProviderName: "openai-image", + DisplayName: "OpenAI Images", + APIBase: "https://api.openai.com/v1", + APIKey: "", + Models: `[{"id": "gpt-image-1", "name": "gpt-image-1", "default": true}]`, + Enabled: true, + TimeoutSeconds: 500, + MaxRetries: 3, + } + + if err := model.DB.Where(model.ProviderConfig{ProviderName: "openai-image"}).FirstOrCreate(&openAIImageConfig).Error; err != nil { + log.Fatalf("初始化 OpenAI Images 配置失败: %v", err) + } + + log.Println("默认 Gemini/OpenAI/OpenAI Images 配置已初始化") } diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 62d6c46..b7e125a 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "nano-banana-pro-frontend", - "version": "2.7.4", + "version": "2.7.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nano-banana-pro-frontend", - "version": "2.7.4", + "version": "2.7.5", "license": "MIT", "dependencies": { "@tanstack/react-query": "^5.59.20", diff --git a/desktop/src/components/ConfigPanel/BatchSettings.tsx b/desktop/src/components/ConfigPanel/BatchSettings.tsx index dc51079..298bc18 100644 --- a/desktop/src/components/ConfigPanel/BatchSettings.tsx +++ b/desktop/src/components/ConfigPanel/BatchSettings.tsx @@ -1,24 +1,49 @@ import { useMemo, useEffect } from 'react'; import { Settings2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { useConfigStore, getModelAspectRatios } from '../../store/configStore'; +import { + useConfigStore, + getModelAspectRatios, + usesNativeImageSize, + supportsQualityControl, + OPENAI_IMAGE_SIZE_OPTIONS, + OPENAI_IMAGE_QUALITY_OPTIONS +} from '../../store/configStore'; import { Select } from '../common/Select'; import { Input } from '../common/Input'; import { toast } from '../../store/toastStore'; export function BatchSettings() { const { t } = useTranslation(); - const { count, setCount, imageSize, setImageSize, aspectRatio, setAspectRatio, imageModel } = useConfigStore(); + const { + count, + setCount, + imageSize, + setImageSize, + imageNativeSize, + setImageNativeSize, + imageQuality, + setImageQuality, + aspectRatio, + setAspectRatio, + imageModel, + imageProvider + } = useConfigStore(); const supportedRatios = useMemo(() => getModelAspectRatios(imageModel), [imageModel]); + const useNativeSize = usesNativeImageSize(imageProvider); + const useQuality = supportsQualityControl(imageProvider); useEffect(() => { + if (useNativeSize) { + return; + } if (supportedRatios.length > 0 && !supportedRatios.includes(aspectRatio)) { const newRatio = supportedRatios[0]; setAspectRatio(newRatio); toast.info(t('config.batch.ratioAutoAdjusted', { from: aspectRatio, to: newRatio })); } - }, [imageModel, aspectRatio, setAspectRatio, supportedRatios, t]); + }, [useNativeSize, imageModel, aspectRatio, setAspectRatio, supportedRatios, t]); return (
{t('settings.provider.openaiImageLimit')}
- )} {/* API Key */} @@ -1119,7 +1124,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { onChange={handleImageModelSelectChange} className="h-10 bg-slate-100 text-slate-900 font-bold rounded-2xl text-sm px-5 focus:bg-white border border-slate-200 transition-all shadow-none" > - {IMAGE_MODEL_OPTIONS.map(opt => ( + {imageModelOptions.map(opt => ( ))} diff --git a/desktop/src/hooks/useGenerate.ts b/desktop/src/hooks/useGenerate.ts index 583f9ea..1cfb7a8 100644 --- a/desktop/src/hooks/useGenerate.ts +++ b/desktop/src/hooks/useGenerate.ts @@ -10,6 +10,7 @@ import { useHistoryStore } from '../store/historyStore'; import i18n from '../i18n'; import { getDiagnosticVerbose } from '../utils/diagnosticLogger'; import { getPromptOptimizeConfigIssue } from '../utils/promptOptimizeConfig'; +import { supportsReferenceImages, usesNativeImageSize } from '../store/configStore'; // 流式连接建立超时时间(毫秒)- 超过此时间未建立连接则启动轮询 // 本地后端通常不会推实时进度,过长会导致用户"卡住"的观感 @@ -408,15 +409,48 @@ export function useGenerate() { const requestedCount = Math.max(1, Number(config.count) || 1); const promptOptimizeMode = config.defaultPromptOptimizeMode || 'off'; const shouldAutoOptimizePrompt = promptOptimizeMode !== 'off'; + const useNativeSize = usesNativeImageSize(config.imageProvider); + const allowReferenceImages = supportsReferenceImages(config.imageProvider); + const taskOptions = useNativeSize + ? { + size: config.imageNativeSize, + quality: config.imageQuality, + } + : { + aspectRatio: config.aspectRatio, + imageSize: config.imageSize, + }; + const buildImageParams = (count: number) => ({ + prompt: config.prompt, + count, + ...(useNativeSize ? { + size: config.imageNativeSize, + quality: config.imageQuality, + } : { + aspectRatio: config.aspectRatio, + imageSize: config.imageSize, + }), + verbose_logging: verboseLogging, + ...(shouldAutoOptimizePrompt ? { + prompt_optimize_mode: promptOptimizeMode, + prompt_optimize_provider: config.chatProvider, + prompt_optimize_model: config.chatModel, + } : {}), + }); const submitSingleGenerate = async () => { - if (config.refFiles.length > 0) { + if (allowReferenceImages && config.refFiles.length > 0) { const formData = new FormData(); formData.append('prompt', config.prompt); formData.append('provider', config.imageProvider); formData.append('model_id', config.imageModel); - formData.append('aspectRatio', config.aspectRatio); - formData.append('imageSize', config.imageSize); + if (useNativeSize) { + formData.append('size', config.imageNativeSize); + formData.append('quality', config.imageQuality); + } else { + formData.append('aspectRatio', config.aspectRatio); + formData.append('imageSize', config.imageSize); + } formData.append('count', '1'); formData.append('verbose_logging', String(verboseLogging)); if (shouldAutoOptimizePrompt) { @@ -440,18 +474,7 @@ export function useGenerate() { return generateBatch({ provider: config.imageProvider, model_id: config.imageModel, - params: { - prompt: config.prompt, - count: 1, - aspectRatio: config.aspectRatio, - imageSize: config.imageSize, - verbose_logging: verboseLogging, - ...(shouldAutoOptimizePrompt ? { - prompt_optimize_mode: promptOptimizeMode, - prompt_optimize_provider: config.chatProvider, - prompt_optimize_model: config.chatModel, - } : {}), - } + params: buildImageParams(1) } as any); }; @@ -489,8 +512,9 @@ export function useGenerate() { const batchTaskId = `${BATCH_TASK_PREFIX}${Date.now()}`; startTask(batchTaskId, requestedCount, { prompt: config.prompt, - aspectRatio: config.aspectRatio, - imageSize: config.imageSize + aspectRatio: useNativeSize ? undefined : config.aspectRatio, + imageSize: useNativeSize ? config.imageNativeSize : config.imageSize, + options: taskOptions }); setConnectionMode('none'); expectedTaskIdRef.current = batchTaskId; @@ -586,14 +610,19 @@ export function useGenerate() { let response; - if (config.refFiles.length > 0) { + if (allowReferenceImages && config.refFiles.length > 0) { // --- 场景 A: 图生图 (multipart/form-data) --- const formData = new FormData(); formData.append('prompt', config.prompt); formData.append('provider', config.imageProvider); formData.append('model_id', config.imageModel); - formData.append('aspectRatio', config.aspectRatio); - formData.append('imageSize', config.imageSize); + if (useNativeSize) { + formData.append('size', config.imageNativeSize); + formData.append('quality', config.imageQuality); + } else { + formData.append('aspectRatio', config.aspectRatio); + formData.append('imageSize', config.imageSize); + } formData.append('count', requestedCount.toString()); formData.append('verbose_logging', String(verboseLogging)); if (shouldAutoOptimizePrompt) { @@ -628,18 +657,7 @@ export function useGenerate() { response = await generateBatch({ provider: config.imageProvider, model_id: config.imageModel, - params: { - prompt: config.prompt, - count: requestedCount, - aspectRatio: config.aspectRatio, - imageSize: config.imageSize, - verbose_logging: verboseLogging, - ...(shouldAutoOptimizePrompt ? { - prompt_optimize_mode: promptOptimizeMode, - prompt_optimize_provider: config.chatProvider, - prompt_optimize_model: config.chatModel, - } : {}), - } + params: buildImageParams(requestedCount) } as any); } @@ -656,8 +674,9 @@ export function useGenerate() { // 启动任务 startTask(newTaskId, requestedCount, { prompt: config.prompt, - aspectRatio: config.aspectRatio, - imageSize: config.imageSize + aspectRatio: useNativeSize ? undefined : config.aspectRatio, + imageSize: useNativeSize ? config.imageNativeSize : config.imageSize, + options: taskOptions }); // 生成区与历史区同步:先写入一条本地任务占位,避免历史列表不刷新导致状态不同步 diff --git a/desktop/src/i18n/locales/en-US.json b/desktop/src/i18n/locales/en-US.json index e032a85..775cb3a 100644 --- a/desktop/src/i18n/locales/en-US.json +++ b/desktop/src/i18n/locales/en-US.json @@ -137,6 +137,7 @@ "add": "Add reference images", "addMore": "Add more", "supportHint": "(supports multi-select or drag & drop)", + "unsupportedModel": "Current model does not support reference images", "toast": { "full": "Reference images are full", "exists": "Image already exists", @@ -184,7 +185,6 @@ "label": "Provider", "recommended": "Recommended:", "yunwu": "Yunwu API", - "openaiImageLimit": "OpenAI providers currently support only 1K images", "geminiBasePathHint": "For Gemini, use only the host as Base URL (avoid /v1 or endpoint paths).", "yunwuMissingV1Hint": "For Yunwu API, Base URL should include /v1", "yunwuExtraPathHint": "For Yunwu API, keep Base URL at /v1 only (no deeper path)" @@ -425,6 +425,8 @@ "title": "Generation Settings", "count": "Count (1-10)", "resolution": "Resolution", + "nativeSize": "Size", + "quality": "Quality", "resolution1k": "1K (1024px)", "resolution2k": "2K (2048px)", "resolution4k": "4K (3840px)", diff --git a/desktop/src/i18n/locales/ja-JP.json b/desktop/src/i18n/locales/ja-JP.json index e83232d..0eee926 100644 --- a/desktop/src/i18n/locales/ja-JP.json +++ b/desktop/src/i18n/locales/ja-JP.json @@ -172,7 +172,6 @@ "label": "プロバイダー", "recommended": "推奨:", "yunwu": "Yunwu API", - "openaiImageLimit": "OpenAI プロバイダーは現在 1K 画像のみ対応しています", "geminiBasePathHint": "Gemini タイプの Base URL はドメインのみを推奨します(/v1 や具体的なエンドポイントは不要)。", "yunwuMissingV1Hint": "Yunwu API の Base URL は /v1 を含めることを推奨します", "yunwuExtraPathHint": "Yunwu API の Base URL は /v1 までにしてください(下位パス不要)" diff --git a/desktop/src/i18n/locales/ko-KR.json b/desktop/src/i18n/locales/ko-KR.json index ae8894f..c84f4c7 100644 --- a/desktop/src/i18n/locales/ko-KR.json +++ b/desktop/src/i18n/locales/ko-KR.json @@ -172,7 +172,6 @@ "label": "프로바이더", "recommended": "추천:", "yunwu": "Yunwu API", - "openaiImageLimit": "OpenAI 프로바이더는 현재 1K 이미지만 지원합니다", "geminiBasePathHint": "Gemini 유형은 Base URL에 도메인만 입력하고 /v1 또는 엔드포인트 경로는 제외하는 것을 권장합니다.", "yunwuMissingV1Hint": "Yunwu API Base URL에는 /v1 포함을 권장합니다", "yunwuExtraPathHint": "Yunwu API Base URL은 /v1까지만 유지하세요(하위 경로 제거)" diff --git a/desktop/src/i18n/locales/zh-CN.json b/desktop/src/i18n/locales/zh-CN.json index 2e9057a..0198aad 100644 --- a/desktop/src/i18n/locales/zh-CN.json +++ b/desktop/src/i18n/locales/zh-CN.json @@ -137,6 +137,7 @@ "add": "添加参考图", "addMore": "继续添加", "supportHint": "(支持多选或拖拽)", + "unsupportedModel": "当前模型不支持参考图", "toast": { "full": "参考图已满", "exists": "图片已存在", @@ -184,7 +185,6 @@ "label": "AI对接方式", "recommended": "推荐平台:", "yunwu": "云雾API", - "openaiImageLimit": "OpenAI 类型当前仅支持生成 1K 图片", "geminiBasePathHint": "Gemini 类型建议 Base URL 仅填写域名,不要带 /v1 或具体接口路径", "yunwuMissingV1Hint": "云雾 API 的 Base URL 建议包含 /v1", "yunwuExtraPathHint": "云雾 API 的 Base URL 建议只保留到 /v1,不要包含更深路径" @@ -425,6 +425,8 @@ "title": "生成设置", "count": "数量 (1-10)", "resolution": "分辨率", + "nativeSize": "尺寸", + "quality": "质量", "resolution1k": "1K (1024px)", "resolution2k": "2K (2048px)", "resolution4k": "4K (3840px)", diff --git a/desktop/src/store/configStore.ts b/desktop/src/store/configStore.ts index 37b771a..7668598 100644 --- a/desktop/src/store/configStore.ts +++ b/desktop/src/store/configStore.ts @@ -7,15 +7,55 @@ const IMAGE_MODELS = { PRO: { value: 'gemini-3-pro-image-preview', label: 'Pro' }, } as const; -// 默认生图模型名称 -export const DEFAULT_IMAGE_MODEL = IMAGE_MODELS.FLASH.value; +const OPENAI_IMAGE_MODELS = { + GPT_IMAGE_1: { value: 'gpt-image-1', label: 'GPT Image 1' }, +} as const; + +export const OPENAI_IMAGE_SIZE_OPTIONS = [ + { value: 'auto', label: 'Auto' }, + { value: '1024x1024', label: '1024 x 1024' }, + { value: '1024x1536', label: '1024 x 1536' }, + { value: '1536x1024', label: '1536 x 1024' } +] as const; + +export const OPENAI_IMAGE_QUALITY_OPTIONS = [ + { value: 'auto', label: 'Auto' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } +] as const; // Model options for the dropdown selectors -export const IMAGE_MODEL_OPTIONS = [ +export const GEMINI_IMAGE_MODEL_OPTIONS = [ { value: IMAGE_MODELS.FLASH.value, label: `${IMAGE_MODELS.FLASH.label} (${IMAGE_MODELS.FLASH.value})` }, { value: IMAGE_MODELS.PRO.value, label: `${IMAGE_MODELS.PRO.label} (${IMAGE_MODELS.PRO.value})` }, ] as const; +export const OPENAI_IMAGE_MODEL_OPTIONS = [ + { value: OPENAI_IMAGE_MODELS.GPT_IMAGE_1.value, label: `${OPENAI_IMAGE_MODELS.GPT_IMAGE_1.label} (${OPENAI_IMAGE_MODELS.GPT_IMAGE_1.value})` }, +] as const; + +export const IMAGE_MODEL_OPTIONS = GEMINI_IMAGE_MODEL_OPTIONS; + +export const getImageModelOptions = (provider: string) => ( + provider === 'openai-image' ? OPENAI_IMAGE_MODEL_OPTIONS : GEMINI_IMAGE_MODEL_OPTIONS +); + +export const getDefaultImageModelForProvider = (provider: string) => { + const options = getImageModelOptions(provider); + return options[0].value; +}; + +const normalizeImageModelForProvider = (provider: string, rawModel: unknown) => { + const model = typeof rawModel === 'string' ? rawModel.trim() : ''; + const options = getImageModelOptions(provider); + const isValid = options.some((option) => option.value === model); + return isValid ? model : getDefaultImageModelForProvider(provider); +}; + +// 默认生图模型名称 +export const DEFAULT_IMAGE_MODEL = getDefaultImageModelForProvider('gemini'); + export const VISION_MODEL_OPTIONS = [ { value: 'gemini-3-flash-preview', label: 'Flash (gemini-3-flash-preview)' }, ] as const; @@ -32,6 +72,10 @@ export const IMAGE_MODEL_CONFIG: Record