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 (
@@ -45,30 +70,51 @@ export function BatchSettings() { />
- - + + {useNativeSize ? ( + + ) : ( + + )}
-
- - -
+ {useNativeSize ? ( + useQuality ? ( +
+ + +
+ ) : null + ) : ( +
+ + +
+ )} ); -} \ No newline at end of file +} diff --git a/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx b/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx index 3f47335..0b07076 100644 --- a/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx +++ b/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx @@ -1,6 +1,6 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import { ImagePlus, X, Image as ImageIcon, ChevronDown, ChevronUp, Sparkles, Loader2 } from 'lucide-react'; -import { useConfigStore } from '../../store/configStore'; +import { useConfigStore, supportsReferenceImages } from '../../store/configStore'; import { useInternalDragStore, type InternalDragPayload } from '../../store/internalDragStore'; import { cn } from '../common/Button'; import { toast } from '../../store/toastStore'; @@ -105,6 +105,7 @@ const normalizeLocalPathInput = (value: string) => { export function ReferenceImageUpload() { const { t } = useTranslation(); const refFiles = useConfigStore((s) => s.refFiles); + const imageProvider = useConfigStore((s) => s.imageProvider); const addRefFiles = useConfigStore((s) => s.addRefFiles); const removeRefFile = useConfigStore((s) => s.removeRefFile); const setRefFiles = useConfigStore((s) => s.setRefFiles); @@ -155,6 +156,8 @@ export function ReferenceImageUpload() { // 图片反推提示词相关状态 const [isExtractingPrompt, setIsExtractingPrompt] = useState(false); const [extractingIndex, setExtractingIndex] = useState(null); + const allowReferenceImages = supportsReferenceImages(imageProvider); + const prevAllowReferenceImagesRef = useRef(allowReferenceImages); // 计算文件 MD5(使用工具函数) const calculateMd5Callback = useCallback(calculateMd5, []); @@ -170,6 +173,16 @@ export function ReferenceImageUpload() { }; }, []); + useEffect(() => { + const wasAllowed = prevAllowReferenceImagesRef.current; + prevAllowReferenceImagesRef.current = allowReferenceImages; + + if (allowReferenceImages || wasAllowed === allowReferenceImages) return; + if (refFiles.length === 0 && refImageEntries.length === 0) return; + + toast.info(t('refImage.unsupportedModel')); + }, [allowReferenceImages, refFiles.length, refImageEntries.length, t]); + const preloadDialog = useCallback(async () => { if (!window.__TAURI_INTERNALS__) return; if (dialogOpenRef.current || dialogLoadingRef.current) return; @@ -1592,6 +1605,23 @@ export function ReferenceImageUpload() { const showDragOver = isDraggingOver || (isInternalDragging && isOverDropTarget); + if (!allowReferenceImages) { + return ( +
+
+ + {t('refImage.title', { count: 0 })} +
+
+ +
+ {t('refImage.unsupportedModel')} +
+
+
+ ); + } + return (
0 ? t('refImage.addMore') : t('refImage.add')} {t('refImage.supportHint')} -
+ )} diff --git a/desktop/src/components/HistoryPanel/FailedTaskCard.tsx b/desktop/src/components/HistoryPanel/FailedTaskCard.tsx index 02d1e5c..6712e50 100644 --- a/desktop/src/components/HistoryPanel/FailedTaskCard.tsx +++ b/desktop/src/components/HistoryPanel/FailedTaskCard.tsx @@ -5,6 +5,7 @@ import { GenerationTask } from '../../types'; import { formatDateTime } from '../../utils/date'; import { useHistoryStore } from '../../store/historyStore'; import { localizeErrorSummary } from '../../utils/errorI18n'; +import { formatAspectRatioLabel } from '../../utils/aspectRatio'; interface FailedTaskCardProps { task: GenerationTask; @@ -129,17 +130,30 @@ export const FailedTaskCard = React.memo(function FailedTaskCard({ task, onClick parsed?.aspectRatio || parsed?.aspect_ratio || parsed?.aspect; + const nativeSize = parsed?.size; const imageSize = parsed?.imageSize || parsed?.resolution_level || parsed?.image_size; - const imageSizeLabel = typeof imageSize === 'string' && imageSize.trim() - ? imageSize.trim().toUpperCase() - : '—'; - const aspectRatioLabel = typeof aspectRatio === 'string' && aspectRatio.trim() - ? aspectRatio.trim() - : '—'; + const imageSizeLabel = + typeof nativeSize === 'string' && nativeSize.trim() + ? nativeSize.trim() + : typeof imageSize === 'string' && imageSize.trim() + ? imageSize.trim().toUpperCase() + : '—'; + const aspectRatioLabel = (() => { + if (typeof aspectRatio === 'string' && aspectRatio.trim()) { + return aspectRatio.trim(); + } + if (typeof nativeSize === 'string') { + const match = nativeSize.trim().match(/^(\d+)\s*x\s*(\d+)$/i); + if (match) { + return formatAspectRatioLabel(Number(match[1]), Number(match[2])); + } + } + return '—'; + })(); return { imageSizeLabel, aspectRatioLabel }; } catch { diff --git a/desktop/src/components/Settings/SettingsModal.tsx b/desktop/src/components/Settings/SettingsModal.tsx index 75b36f5..d91a2fe 100644 --- a/desktop/src/components/Settings/SettingsModal.tsx +++ b/desktop/src/components/Settings/SettingsModal.tsx @@ -14,7 +14,7 @@ import { useUpdaterStore } from '../../store/updaterStore'; import i18n, { DEFAULT_LANGUAGE } from '../../i18n'; import { getSystemLocale } from '../../i18n/systemLocale'; import appIcon from '../../assets/app-icon.png'; -import { IMAGE_MODEL_OPTIONS, VISION_MODEL_OPTIONS, CUSTOM_MODEL_VALUE } from '../../store/configStore'; +import { getDefaultImageModelForProvider, getImageModelOptions, VISION_MODEL_OPTIONS, CUSTOM_MODEL_VALUE } from '../../store/configStore'; import { getPromptOptimizeConfigIssue } from '../../utils/promptOptimizeConfig'; import { ensureNotificationPermission, sendTestSystemNotification } from '../../hooks/useGenerationNotifications'; @@ -215,9 +215,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const [updateHint, setUpdateHint] = useState<{ type: 'checking' | 'latest' | 'available' | 'error'; message: string } | null>(null); const [isCheckingUpdates, setIsCheckingUpdates] = useState(false); const [showOnboardingConfirm, setShowOnboardingConfirm] = useState(false); + const imageModelOptions = useMemo(() => getImageModelOptions(imageProvider), [imageProvider]); // Model Select state for UI - 'custom' when value not in preset const [imageModelSelect, setImageModelSelect] = useState(() => { - const isPreset = IMAGE_MODEL_OPTIONS.some(o => o.value === imageModel); + const isPreset = getImageModelOptions(imageProvider).some((o) => o.value === imageModel); return isPreset ? imageModel : CUSTOM_MODEL_VALUE; }); @@ -228,9 +229,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { // 同步 imageModelSelect:当 imageModel 被外部更新时(如 fetchConfigs、切换 Provider)保持下拉框一致 useEffect(() => { - const isPreset = IMAGE_MODEL_OPTIONS.some(o => o.value === imageModel); + const isPreset = imageModelOptions.some((o) => o.value === imageModel); setImageModelSelect(isPreset ? imageModel : CUSTOM_MODEL_VALUE); - }, [imageModel]); + }, [imageModel, imageModelOptions]); // 同步 visionModelSelect:当 visionModel 被外部更新时保持下拉框一致 useEffect(() => { @@ -325,10 +326,13 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const modelFromConfig = getDefaultModelId(imageConfig.models); if (modelFromConfig) { setImageModel(modelFromConfig); + } else { + setImageModel(getDefaultImageModelForProvider(imageProvider)); } setImageTimeoutSeconds(normalizeTimeout(imageConfig.timeout_seconds, 500)); setImageMaxRetries(normalizeRetryCount(imageConfig.max_retries, 1)); } else { + setImageModel(getDefaultImageModelForProvider(imageProvider)); setImageTimeoutSeconds(500); setImageMaxRetries(1); } @@ -550,10 +554,13 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const modelFromConfig = getDefaultModelId(config.models); if (modelFromConfig) { setImageModel(modelFromConfig); + } else { + setImageModel(getDefaultImageModelForProvider(newProvider)); } setImageTimeoutSeconds(normalizeTimeout(config.timeout_seconds, 500)); setImageMaxRetries(normalizeRetryCount(config.max_retries, 1)); } else { + setImageModel(getDefaultImageModelForProvider(newProvider)); setImageTimeoutSeconds(500); setImageMaxRetries(1); } @@ -1049,6 +1056,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { > + {/* 后续可扩展更多 provider */} @@ -1079,9 +1087,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { className="h-10 bg-slate-100 text-slate-900 font-medium rounded-2xl text-sm px-5 focus:bg-white border border-slate-200 transition-all shadow-none" /> {imageBaseWarn && } - {imageProvider === 'openai' && ( -

{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 = { } }; +export const supportsReferenceImages = (provider: string): boolean => provider !== 'openai-image'; +export const usesNativeImageSize = (provider: string): boolean => provider === 'openai-image'; +export const supportsQualityControl = (provider: string): boolean => provider === 'openai-image'; + // Helper function to get supported aspect ratios for a model export const getModelAspectRatios = (model: string): string[] => { const ratios = IMAGE_MODEL_CONFIG[model]?.aspectRatios; @@ -90,6 +134,8 @@ interface ConfigState { count: number; imageSize: string; aspectRatio: string; + imageNativeSize: string; + imageQuality: string; refFiles: File[]; refImageEntries: PersistedRefImage[]; @@ -125,6 +171,8 @@ interface ConfigState { setCount: (count: number) => void; setImageSize: (size: string) => void; setAspectRatio: (ratio: string) => void; + setImageNativeSize: (size: string) => void; + setImageQuality: (quality: string) => void; setRefFiles: (files: File[]) => void; addRefFiles: (files: File[]) => void; removeRefFile: (index: number) => void; @@ -169,6 +217,8 @@ export const useConfigStore = create()( count: 1, imageSize: '2K', aspectRatio: '1:1', + imageNativeSize: 'auto', + imageQuality: 'auto', refFiles: [], refImageEntries: [], @@ -204,6 +254,8 @@ export const useConfigStore = create()( setCount: (count) => set({ count }), setImageSize: (imageSize) => set({ imageSize }), setAspectRatio: (aspectRatio) => set({ aspectRatio }), + setImageNativeSize: (imageNativeSize) => set({ imageNativeSize }), + setImageQuality: (imageQuality) => set({ imageQuality }), setRefFiles: (refFiles) => set({ refFiles }), setRefImageEntries: (refImageEntries) => set({ refImageEntries }), @@ -245,6 +297,8 @@ export const useConfigStore = create()( count: 1, imageSize: '2K', aspectRatio: '1:1', + imageNativeSize: 'auto', + imageQuality: 'auto', refFiles: [], refImageEntries: [], }) @@ -252,7 +306,7 @@ export const useConfigStore = create()( { name: 'app-config-storage', storage: createJSONStorage(() => localStorage), - version: 15, + version: 19, // 关键:不要将 File 对象序列化到 localStorage(File 对象无法序列化) partialize: (state) => { const { refFiles, ...rest } = state; @@ -357,6 +411,36 @@ export const useConfigStore = create()( notifyOnFailure: next.notifyOnFailure ?? true }; } + if (version < 17) { + next = { + ...next, + imageModel: next.imageModel ?? DEFAULT_IMAGE_MODEL + }; + } + if (version < 18) { + next = { + ...next, + imageNativeSize: next.imageNativeSize ?? 'auto', + imageQuality: next.imageQuality ?? 'auto' + }; + } + if (version < 19) { + const imageProvider = String(next.imageProvider ?? 'gemini').trim() || 'gemini'; + const imageNativeSize = typeof next.imageNativeSize === 'string' && next.imageNativeSize.trim() + ? next.imageNativeSize.trim() + : 'auto'; + const imageQuality = typeof next.imageQuality === 'string' && next.imageQuality.trim() + ? next.imageQuality.trim() + : 'auto'; + + next = { + ...next, + imageProvider, + imageModel: normalizeImageModelForProvider(imageProvider, next.imageModel), + imageNativeSize, + imageQuality + }; + } return next; }, diff --git a/desktop/src/store/generateStore.ts b/desktop/src/store/generateStore.ts index b437b99..3302dd1 100644 --- a/desktop/src/store/generateStore.ts +++ b/desktop/src/store/generateStore.ts @@ -24,6 +24,15 @@ function roundToMultiple(n: number, multiple: number) { } function getExpectedDimensions(aspectRatio: string, imageSize: string): { width: number; height: number } | null { + const nativeSizeMatch = String(imageSize || '').trim().match(/^(\d+)\s*x\s*(\d+)$/i); + if (nativeSizeMatch) { + const width = Number(nativeSizeMatch[1]); + const height = Number(nativeSizeMatch[2]); + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + return { width, height }; + } + } + const ratio = parseAspectRatio(aspectRatio); const max = IMAGE_SIZE_MAX_PX[String(imageSize || '').toUpperCase()]; if (!ratio || !max) return null; @@ -107,7 +116,16 @@ interface GenerateState { setTab: (tab: 'generate' | 'history') => void; setSidebarOpen: (isOpen: boolean) => void; // 新增 Action - startTask: (taskId: string, totalCount: number, config: { prompt: string, aspectRatio: string, imageSize: string }) => void; + startTask: ( + taskId: string, + totalCount: number, + config: { + prompt: string; + aspectRatio?: string; + imageSize: string; + options?: GeneratedImage['options']; + } + ) => void; updateProgress: (completedCount: number, image?: GeneratedImage | null) => void; updateProgressBatch: (completedCount: number, images: GeneratedImage[]) => void; completeTask: () => void; @@ -154,7 +172,11 @@ export const useGenerateStore = create()( setSubmitting: (isSubmitting) => set({ isSubmitting }), startTask: (taskId, totalCount, config) => { - const expected = getExpectedDimensions(config.aspectRatio, config.imageSize); + const expected = getExpectedDimensions(config.aspectRatio || '', config.imageSize); + const placeholderOptions = config.options || { + aspectRatio: config.aspectRatio, + imageSize: config.imageSize + }; const placeholders: GeneratedImage[] = Array.from({ length: totalCount }).map((_, i) => ({ id: `temp-${Date.now()}-${i}`, taskId, @@ -168,10 +190,7 @@ export const useGenerateStore = create()( status: 'pending' as const, prompt: config.prompt, url: '', - options: { - aspectRatio: config.aspectRatio, - imageSize: config.imageSize - } + options: placeholderOptions })); set((state) => ({ diff --git a/desktop/src/types/index.ts b/desktop/src/types/index.ts index ac7f1b9..77b6ac8 100644 --- a/desktop/src/types/index.ts +++ b/desktop/src/types/index.ts @@ -38,8 +38,10 @@ export interface GeneratedImage { // 图片选项配置 export interface ImageOptions { - aspectRatio: string; - imageSize: string; + aspectRatio?: string; + imageSize?: string; + size?: string; + quality?: string; } // 任务模型 @@ -81,6 +83,8 @@ export interface BatchGenerateRequest { // 正式 API 参数 imageSize?: string; aspectRatio?: string; + size?: string; + quality?: string; } // 历史记录列表项(通常即为 Task)