From fcb38a5ef68a6077832248152335d89ceb4eac71 Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Tue, 21 Apr 2026 20:13:54 +0800 Subject: [PATCH 1/9] Add DALL-E 3 image generation support --- backend/internal/api/handlers.go | 13 ++ backend/internal/provider/model_resolver.go | 3 + backend/internal/provider/openai.go | 209 ++++++++++++++++++ desktop/package-lock.json | 4 +- .../components/ConfigPanel/BatchSettings.tsx | 106 ++++++--- .../ConfigPanel/ReferenceImageUpload.tsx | 52 +++-- .../src/components/Settings/SettingsModal.tsx | 14 +- desktop/src/hooks/useGenerate.ts | 51 +++-- desktop/src/i18n/locales/en-US.json | 8 +- desktop/src/i18n/locales/zh-CN.json | 8 +- desktop/src/store/configStore.ts | 47 +++- desktop/src/types/index.ts | 7 +- 12 files changed, 449 insertions(+), 73 deletions(-) diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 4fa7ec4..2b5e3a8 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -101,6 +101,15 @@ 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) + } + if v, ok := params["style"].(string); ok && strings.TrimSpace(v) != "" { + snapshot["style"] = strings.TrimSpace(v) + } // count 可能是 float64(JSON 解析)或 int(服务内部) if v, ok := params["count"].(int); ok && v > 0 { @@ -570,6 +579,10 @@ func GenerateWithImagesHandler(c *gin.Context) { RequestModel: req.ModelID, Config: fetchProviderConfig(req.Provider), }).ID + if req.Provider == "openai" && strings.EqualFold(strings.TrimSpace(modelID), "dall-e-3") { + Error(c, http.StatusBadRequest, 400, "DALL·E 3 暂不支持参考图,请移除参考图后重试") + return + } taskParams := map[string]interface{}{ "prompt": req.Prompt, "provider": req.Provider, diff --git a/backend/internal/provider/model_resolver.go b/backend/internal/provider/model_resolver.go index 83e0ddf..c4da9d7 100644 --- a/backend/internal/provider/model_resolver.go +++ b/backend/internal/provider/model_resolver.go @@ -87,5 +87,8 @@ func defaultModelForProvider(providerName string, purpose ModelPurpose) string { if purpose == PurposeChat || name == "openai-chat" { return "gemini-3-flash-preview" } + if name == "openai" { + return "" + } return "gemini-3-pro-image-preview" } diff --git a/backend/internal/provider/openai.go b/backend/internal/provider/openai.go index dbee212..5d15b2f 100644 --- a/backend/internal/provider/openai.go +++ b/backend/internal/provider/openai.go @@ -24,6 +24,17 @@ type OpenAIProvider struct { userAgent string } +type openAIImagesGenerationRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Size string `json:"size,omitempty"` + Quality string `json:"quality,omitempty"` + Style string `json:"style,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + User string `json:"user,omitempty"` + N int `json:"n,omitempty"` +} + func NewOpenAIProvider(config *model.ProviderConfig) (*OpenAIProvider, error) { if config == nil { return nil, fmt.Errorf("config 不能为空") @@ -90,6 +101,66 @@ func (p *OpenAIProvider) Generate(ctx context.Context, params map[string]interfa return nil, fmt.Errorf("缺少 model_id 参数") } + if isDalle3Model(modelID) { + 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 style=%q prompt_hash=%s prompt_preview=%q", + p.Name(), + modelID, + reqBody.Size, + reqBody.Quality, + reqBody.Style, + diagnostic.PromptHash(promptPreview), + diagnostic.Preview(promptPreview, 160), + ) + + respBytes, headers, err := p.doImagesGenerationRequest(ctx, reqBody, params) + if err != nil { + return nil, err + } + + var raw map[string]interface{} + if err := json.Unmarshal(respBytes, &raw); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + data, ok := raw["data"].([]interface{}) + if !ok || len(data) == 0 { + return nil, fmt.Errorf("响应中未找到图片数据") + } + images, err := p.extractImagesFromData(ctx, data) + if err != nil { + return nil, err + } + if len(images) == 0 { + return nil, fmt.Errorf("未在响应中找到图片数据") + } + + requestID := extractRequestIDFromHeaders(headers) + diagnostic.Logf(params, "response_summary", + "provider=%s model=%s data_count=%d image_count=%d request_id=%s", + p.Name(), + modelID, + len(data), + len(images), + requestID, + ) + + return &ProviderResult{ + Images: images, + Metadata: map[string]interface{}{ + "provider": "openai", + "model": modelID, + "type": "image", + "request_id": requestID, + "oneapi_request": strings.TrimSpace(headers.Get("X-Oneapi-Request-Id")), + }, + }, nil + } + reqBody, refCount, promptPreview, err := p.buildChatRequestBody(modelID, params) if err != nil { return nil, err @@ -148,9 +219,75 @@ func (p *OpenAIProvider) ValidateParams(params map[string]interface{}) error { if prompt == "" { return fmt.Errorf("prompt 不能为空") } + modelID, _ := params["model_id"].(string) + if isDalle3Model(modelID) { + if raw, ok := params["reference_images"].([]interface{}); ok && len(raw) > 0 { + return fmt.Errorf("DALL·E 3 暂不支持参考图") + } + if count, ok := toInt(params["count"]); ok && count > 1 { + return fmt.Errorf("DALL·E 3 单次请求仅支持生成 1 张图片") + } + } return nil } +func isDalle3Model(modelID string) bool { + return strings.EqualFold(strings.TrimSpace(modelID), "dall-e-3") +} + +func (p *OpenAIProvider) 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, + ResponseFormat: "b64_json", + N: 1, + } + + if size := resolveDalle3Size(params); size != "" { + body.Size = size + } + if quality, _ := params["quality"].(string); strings.TrimSpace(quality) != "" { + body.Quality = strings.TrimSpace(strings.ToLower(quality)) + } + if style, _ := params["style"].(string); strings.TrimSpace(style) != "" { + body.Style = strings.TrimSpace(strings.ToLower(style)) + } + if responseFormat, _ := params["response_format"].(string); strings.TrimSpace(responseFormat) != "" { + body.ResponseFormat = strings.TrimSpace(responseFormat) + } + if user, _ := params["user"].(string); strings.TrimSpace(user) != "" { + body.User = strings.TrimSpace(user) + } + if body.Size == "" { + body.Size = "1024x1024" + } + return body, prompt, nil +} + +func resolveDalle3Size(params map[string]interface{}) string { + if size, _ := params["size"].(string); strings.TrimSpace(size) != "" { + return strings.TrimSpace(size) + } + ar, _ := params["aspect_ratio"].(string) + if ar == "" { + ar, _ = params["aspectRatio"].(string) + } + switch strings.TrimSpace(ar) { + case "9:16", "2:3", "3:4", "4:5": + return "1024x1792" + case "16:9", "3:2", "4:3", "5:4", "21:9": + return "1792x1024" + default: + return "1024x1024" + } +} + func (p *OpenAIProvider) buildChatRequestBody(modelID string, params map[string]interface{}) (map[string]interface{}, int, string, error) { rawMessages, hasMessages := params["messages"] reqBody := map[string]interface{}{ @@ -214,6 +351,78 @@ func (p *OpenAIProvider) buildChatRequestBody(modelID string, params map[string] return reqBody, refCount, promptPreview, nil } +func (p *OpenAIProvider) 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 +} + func (p *OpenAIProvider) doChatRequest(ctx context.Context, body map[string]interface{}, params map[string]interface{}) ([]byte, http.Header, error) { payloadBytes, err := json.Marshal(body) if err != nil { 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..2342fa0 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, + DALLE3_SIZE_OPTIONS, + DALLE3_QUALITY_OPTIONS, + DALLE3_STYLE_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, + aspectRatio, + setAspectRatio, + imageModel, + imageNativeSize, + setImageNativeSize, + imageQuality, + setImageQuality, + imageStyle, + setImageStyle + } = useConfigStore(); const supportedRatios = useMemo(() => getModelAspectRatios(imageModel), [imageModel]); + const useDalle3Controls = usesNativeImageSize(imageModel); useEffect(() => { + if (useDalle3Controls) { + 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]); + }, [useDalle3Controls, imageModel, aspectRatio, setAspectRatio, supportedRatios, t]); return (
@@ -45,30 +70,61 @@ export function BatchSettings() { />
- - + + {useDalle3Controls ? ( + + ) : ( + + )}
-
- - -
+ {useDalle3Controls ? ( +
+
+ + +
+
+ + +
+
+ ) : ( +
+ + +
+ )} ); -} \ No newline at end of file +} diff --git a/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx b/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx index 3f47335..d44e5c3 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 imageModel = useConfigStore((s) => s.imageModel); const addRefFiles = useConfigStore((s) => s.addRefFiles); const removeRefFile = useConfigStore((s) => s.removeRefFile); const setRefFiles = useConfigStore((s) => s.setRefFiles); @@ -155,6 +156,7 @@ export function ReferenceImageUpload() { // 图片反推提示词相关状态 const [isExtractingPrompt, setIsExtractingPrompt] = useState(false); const [extractingIndex, setExtractingIndex] = useState(null); + const allowReferenceImages = supportsReferenceImages(imageModel); // 计算文件 MD5(使用工具函数) const calculateMd5Callback = useCallback(calculateMd5, []); @@ -170,6 +172,15 @@ export function ReferenceImageUpload() { }; }, []); + useEffect(() => { + if (allowReferenceImages || refFiles.length === 0) { + return; + } + setRefFiles([]); + setRefImageEntries([]); + toast.info(t('refImage.unsupportedModelCleared')); + }, [allowReferenceImages, refFiles.length, setRefFiles, setRefImageEntries, t]); + const preloadDialog = useCallback(async () => { if (!window.__TAURI_INTERNALS__) return; if (dialogOpenRef.current || dialogLoadingRef.current) return; @@ -1591,25 +1602,26 @@ export function ReferenceImageUpload() { }; const showDragOver = isDraggingOver || (isInternalDragging && isOverDropTarget); + const interactive = allowReferenceImages; return (
{/* 标题行 + 折叠按钮 */}
- {showDragOver && ( + {!interactive && ( + + {t('refImage.unsupportedModel')} + + )} + {interactive && showDragOver && ( {t('refImage.dropHint')} )} - {refFiles.length > 0 && !showDragOver && ( + {interactive && refFiles.length > 0 && !showDragOver && ( {t('refImage.modeActive')} @@ -1646,7 +1663,7 @@ export function ReferenceImageUpload() { {/* 收起状态提示 */} {!isExpanded && refFiles.length === 0 && (
- {t('refImage.collapsedHint')} + {interactive ? t('refImage.collapsedHint') : t('refImage.unsupportedHint')}
)} @@ -1736,7 +1753,7 @@ export function ReferenceImageUpload() { )} {/* 上传按钮/区域 */} - {refFiles.length === 0 && refFiles.length < 10 && ( + {interactive && refFiles.length === 0 && refFiles.length < 10 && ( )} + {!interactive && refFiles.length === 0 && ( +
+ +
+ {t('refImage.unsupportedModel')} + {t('refImage.unsupportedHint')} +
+
+ )} )} { }; }; +const getDefaultImageModelForProvider = (provider: string) => ( + provider === 'openai' ? 'dall-e-3' : IMAGE_MODEL_OPTIONS[0].value +); + const resolveSystemLanguage = (locale: string | null) => { if (!locale) return DEFAULT_LANGUAGE; const lower = locale.toLowerCase(); @@ -325,10 +329,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 +557,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); } @@ -1079,7 +1089,7 @@ 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' && ( + {imageProvider === 'openai' && isDalle3Model(imageModel) && (

{t('settings.provider.openaiImageLimit')}

)}
diff --git a/desktop/src/hooks/useGenerate.ts b/desktop/src/hooks/useGenerate.ts index 583f9ea..130316d 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 { isDalle3Model } from '../store/configStore'; // 流式连接建立超时时间(毫秒)- 超过此时间未建立连接则启动轮询 // 本地后端通常不会推实时进度,过长会导致用户"卡住"的观感 @@ -408,6 +409,26 @@ export function useGenerate() { const requestedCount = Math.max(1, Number(config.count) || 1); const promptOptimizeMode = config.defaultPromptOptimizeMode || 'off'; const shouldAutoOptimizePrompt = promptOptimizeMode !== 'off'; + const useDalle3Params = isDalle3Model(config.imageModel); + const requestImageSize = useDalle3Params ? config.imageNativeSize : config.imageSize; + const buildImageParams = (count: number) => ({ + prompt: config.prompt, + count, + ...(useDalle3Params ? { + size: config.imageNativeSize, + quality: config.imageQuality, + style: config.imageStyle, + } : { + 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) { @@ -440,18 +461,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); }; @@ -490,7 +500,7 @@ export function useGenerate() { startTask(batchTaskId, requestedCount, { prompt: config.prompt, aspectRatio: config.aspectRatio, - imageSize: config.imageSize + imageSize: requestImageSize }); setConnectionMode('none'); expectedTaskIdRef.current = batchTaskId; @@ -628,18 +638,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); } @@ -657,7 +656,7 @@ export function useGenerate() { startTask(newTaskId, requestedCount, { prompt: config.prompt, aspectRatio: config.aspectRatio, - imageSize: config.imageSize + imageSize: requestImageSize }); // 生成区与历史区同步:先写入一条本地任务占位,避免历史列表不刷新导致状态不同步 diff --git a/desktop/src/i18n/locales/en-US.json b/desktop/src/i18n/locales/en-US.json index e032a85..f34d912 100644 --- a/desktop/src/i18n/locales/en-US.json +++ b/desktop/src/i18n/locales/en-US.json @@ -137,6 +137,9 @@ "add": "Add reference images", "addMore": "Add more", "supportHint": "(supports multi-select or drag & drop)", + "unsupportedModel": "Current model does not support reference images", + "unsupportedHint": "DALL·E 3 currently supports text-to-image only. Remove reference images and try again.", + "unsupportedModelCleared": "Reference images unsupported by the current model were cleared", "toast": { "full": "Reference images are full", "exists": "Image already exists", @@ -184,7 +187,7 @@ "label": "Provider", "recommended": "Recommended:", "yunwu": "Yunwu API", - "openaiImageLimit": "OpenAI providers currently support only 1K images", + "openaiImageLimit": "DALL·E 3 uses fixed size presets and does not support 1K/2K/4K or reference 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 +428,9 @@ "title": "Generation Settings", "count": "Count (1-10)", "resolution": "Resolution", + "nativeSize": "Size", + "quality": "Quality", + "style": "Style", "resolution1k": "1K (1024px)", "resolution2k": "2K (2048px)", "resolution4k": "4K (3840px)", diff --git a/desktop/src/i18n/locales/zh-CN.json b/desktop/src/i18n/locales/zh-CN.json index 2e9057a..2b92634 100644 --- a/desktop/src/i18n/locales/zh-CN.json +++ b/desktop/src/i18n/locales/zh-CN.json @@ -137,6 +137,9 @@ "add": "添加参考图", "addMore": "继续添加", "supportHint": "(支持多选或拖拽)", + "unsupportedModel": "当前模型不支持参考图", + "unsupportedHint": "DALL·E 3 当前仅支持文本生图,请移除参考图后再生成", + "unsupportedModelCleared": "已自动清空当前模型不支持的参考图", "toast": { "full": "参考图已满", "exists": "图片已存在", @@ -184,7 +187,7 @@ "label": "AI对接方式", "recommended": "推荐平台:", "yunwu": "云雾API", - "openaiImageLimit": "OpenAI 类型当前仅支持生成 1K 图片", + "openaiImageLimit": "DALL·E 3 使用固定尺寸档位,不支持 1K/2K/4K 与参考图", "geminiBasePathHint": "Gemini 类型建议 Base URL 仅填写域名,不要带 /v1 或具体接口路径", "yunwuMissingV1Hint": "云雾 API 的 Base URL 建议包含 /v1", "yunwuExtraPathHint": "云雾 API 的 Base URL 建议只保留到 /v1,不要包含更深路径" @@ -425,6 +428,9 @@ "title": "生成设置", "count": "数量 (1-10)", "resolution": "分辨率", + "nativeSize": "尺寸", + "quality": "质量", + "style": "风格", "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..a4f915f 100644 --- a/desktop/src/store/configStore.ts +++ b/desktop/src/store/configStore.ts @@ -5,6 +5,7 @@ import type { PersistedRefImage } from '../types'; const IMAGE_MODELS = { FLASH: { value: 'gemini-3.1-flash-image-preview', label: 'Flash' }, PRO: { value: 'gemini-3-pro-image-preview', label: 'Pro' }, + DALLE3: { value: 'dall-e-3', label: 'DALL·E 3' }, } as const; // 默认生图模型名称 @@ -14,6 +15,7 @@ export const DEFAULT_IMAGE_MODEL = IMAGE_MODELS.FLASH.value; export const 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})` }, + { value: IMAGE_MODELS.DALLE3.value, label: `${IMAGE_MODELS.DALLE3.label} (${IMAGE_MODELS.DALLE3.value})` }, ] as const; export const VISION_MODEL_OPTIONS = [ @@ -32,6 +34,26 @@ export const IMAGE_MODEL_CONFIG: Record = { } }; +export const DALLE3_SIZE_OPTIONS = [ + { value: '1024x1024', label: '1024 x 1024' }, + { value: '1024x1792', label: '1024 x 1792' }, + { value: '1792x1024', label: '1792 x 1024' } +] as const; + +export const DALLE3_QUALITY_OPTIONS = [ + { value: 'standard', label: 'Standard' }, + { value: 'hd', label: 'HD' } +] as const; + +export const DALLE3_STYLE_OPTIONS = [ + { value: 'vivid', label: 'Vivid' }, + { value: 'natural', label: 'Natural' } +] as const; + +export const isDalle3Model = (model: string): boolean => String(model || '').trim().toLowerCase() === IMAGE_MODELS.DALLE3.value; +export const supportsReferenceImages = (model: string): boolean => !isDalle3Model(model); +export const usesNativeImageSize = (model: string): boolean => isDalle3Model(model); + // 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 +112,9 @@ interface ConfigState { count: number; imageSize: string; aspectRatio: string; + imageNativeSize: string; + imageQuality: string; + imageStyle: string; refFiles: File[]; refImageEntries: PersistedRefImage[]; @@ -125,6 +150,9 @@ interface ConfigState { setCount: (count: number) => void; setImageSize: (size: string) => void; setAspectRatio: (ratio: string) => void; + setImageNativeSize: (size: string) => void; + setImageQuality: (quality: string) => void; + setImageStyle: (style: string) => void; setRefFiles: (files: File[]) => void; addRefFiles: (files: File[]) => void; removeRefFile: (index: number) => void; @@ -169,6 +197,9 @@ export const useConfigStore = create()( count: 1, imageSize: '2K', aspectRatio: '1:1', + imageNativeSize: '1024x1024', + imageQuality: 'standard', + imageStyle: 'vivid', refFiles: [], refImageEntries: [], @@ -204,6 +235,9 @@ export const useConfigStore = create()( setCount: (count) => set({ count }), setImageSize: (imageSize) => set({ imageSize }), setAspectRatio: (aspectRatio) => set({ aspectRatio }), + setImageNativeSize: (imageNativeSize) => set({ imageNativeSize }), + setImageQuality: (imageQuality) => set({ imageQuality }), + setImageStyle: (imageStyle) => set({ imageStyle }), setRefFiles: (refFiles) => set({ refFiles }), setRefImageEntries: (refImageEntries) => set({ refImageEntries }), @@ -245,6 +279,9 @@ export const useConfigStore = create()( count: 1, imageSize: '2K', aspectRatio: '1:1', + imageNativeSize: '1024x1024', + imageQuality: 'standard', + imageStyle: 'vivid', refFiles: [], refImageEntries: [], }) @@ -252,7 +289,7 @@ export const useConfigStore = create()( { name: 'app-config-storage', storage: createJSONStorage(() => localStorage), - version: 15, + version: 16, // 关键:不要将 File 对象序列化到 localStorage(File 对象无法序列化) partialize: (state) => { const { refFiles, ...rest } = state; @@ -357,6 +394,14 @@ export const useConfigStore = create()( notifyOnFailure: next.notifyOnFailure ?? true }; } + if (version < 16) { + next = { + ...next, + imageNativeSize: next.imageNativeSize ?? '1024x1024', + imageQuality: next.imageQuality ?? 'standard', + imageStyle: next.imageStyle ?? 'vivid' + }; + } return next; }, diff --git a/desktop/src/types/index.ts b/desktop/src/types/index.ts index ac7f1b9..3a04dbe 100644 --- a/desktop/src/types/index.ts +++ b/desktop/src/types/index.ts @@ -38,8 +38,11 @@ export interface GeneratedImage { // 图片选项配置 export interface ImageOptions { - aspectRatio: string; - imageSize: string; + aspectRatio?: string; + imageSize?: string; + size?: string; + quality?: string; + style?: string; } // 任务模型 From 363c0cd756a9ddf7fef62e126d16a9d115fd3123 Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Tue, 21 Apr 2026 20:21:04 +0800 Subject: [PATCH 2/9] Fix DALL-E 3 request validation and task metadata --- backend/internal/provider/openai.go | 7 +++-- .../HistoryPanel/FailedTaskCard.tsx | 28 +++++++++++++---- desktop/src/hooks/useGenerate.ts | 20 +++++++++--- desktop/src/store/generateStore.ts | 31 +++++++++++++++---- 4 files changed, 68 insertions(+), 18 deletions(-) diff --git a/backend/internal/provider/openai.go b/backend/internal/provider/openai.go index 5d15b2f..469c868 100644 --- a/backend/internal/provider/openai.go +++ b/backend/internal/provider/openai.go @@ -224,8 +224,8 @@ func (p *OpenAIProvider) ValidateParams(params map[string]interface{}) error { if raw, ok := params["reference_images"].([]interface{}); ok && len(raw) > 0 { return fmt.Errorf("DALL·E 3 暂不支持参考图") } - if count, ok := toInt(params["count"]); ok && count > 1 { - return fmt.Errorf("DALL·E 3 单次请求仅支持生成 1 张图片") + if count, ok := toInt(params["count"]); ok && (count < 1 || count > 10) { + return fmt.Errorf("DALL·E 3 的 count/n 必须介于 1 和 10 之间") } } return nil @@ -264,6 +264,9 @@ func (p *OpenAIProvider) buildImagesGenerationRequestBody(modelID string, params if user, _ := params["user"].(string); strings.TrimSpace(user) != "" { body.User = strings.TrimSpace(user) } + if count, ok := toInt(params["count"]); ok && count >= 1 && count <= 10 { + body.N = count + } if body.Size == "" { body.Size = "1024x1024" } diff --git a/desktop/src/components/HistoryPanel/FailedTaskCard.tsx b/desktop/src/components/HistoryPanel/FailedTaskCard.tsx index 02d1e5c..f7de01d 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,32 @@ export const FailedTaskCard = React.memo(function FailedTaskCard({ task, onClick parsed?.aspectRatio || parsed?.aspect_ratio || parsed?.aspect; + const nativeSize = + parsed?.size || + parsed?.image_native_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 nativeSizeLabel = typeof nativeSize === 'string' && nativeSize.trim() + ? nativeSize.trim() + : ''; + const imageSizeLabel = nativeSizeLabel || ( + typeof imageSize === 'string' && imageSize.trim() + ? imageSize.trim().toUpperCase() + : '—' + ); + const aspectRatioLabel = (() => { + if (typeof aspectRatio === 'string' && aspectRatio.trim()) { + return aspectRatio.trim(); + } + const match = nativeSizeLabel.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/hooks/useGenerate.ts b/desktop/src/hooks/useGenerate.ts index 130316d..331e15d 100644 --- a/desktop/src/hooks/useGenerate.ts +++ b/desktop/src/hooks/useGenerate.ts @@ -411,6 +411,16 @@ export function useGenerate() { const shouldAutoOptimizePrompt = promptOptimizeMode !== 'off'; const useDalle3Params = isDalle3Model(config.imageModel); const requestImageSize = useDalle3Params ? config.imageNativeSize : config.imageSize; + const taskOptions = useDalle3Params + ? { + size: config.imageNativeSize, + quality: config.imageQuality, + style: config.imageStyle, + } + : { + aspectRatio: config.aspectRatio, + imageSize: config.imageSize, + }; const buildImageParams = (count: number) => ({ prompt: config.prompt, count, @@ -499,8 +509,9 @@ export function useGenerate() { const batchTaskId = `${BATCH_TASK_PREFIX}${Date.now()}`; startTask(batchTaskId, requestedCount, { prompt: config.prompt, - aspectRatio: config.aspectRatio, - imageSize: requestImageSize + aspectRatio: useDalle3Params ? undefined : config.aspectRatio, + imageSize: requestImageSize, + options: taskOptions }); setConnectionMode('none'); expectedTaskIdRef.current = batchTaskId; @@ -655,8 +666,9 @@ export function useGenerate() { // 启动任务 startTask(newTaskId, requestedCount, { prompt: config.prompt, - aspectRatio: config.aspectRatio, - imageSize: requestImageSize + aspectRatio: useDalle3Params ? undefined : config.aspectRatio, + imageSize: requestImageSize, + options: taskOptions }); // 生成区与历史区同步:先写入一条本地任务占位,避免历史列表不刷新导致状态不同步 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) => ({ From f1545ad3a851c0c38348073e3493825cc228f17b Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Tue, 21 Apr 2026 22:35:13 +0800 Subject: [PATCH 3/9] Remove DALL-E image workflow --- backend/internal/api/handlers.go | 13 -- backend/internal/provider/openai.go | 212 ------------------ .../components/ConfigPanel/BatchSettings.tsx | 96 ++------ .../ConfigPanel/ReferenceImageUpload.tsx | 33 +-- .../HistoryPanel/FailedTaskCard.tsx | 15 +- .../src/components/Settings/SettingsModal.tsx | 9 +- desktop/src/hooks/useGenerate.ts | 35 +-- desktop/src/i18n/locales/en-US.json | 6 - desktop/src/i18n/locales/ja-JP.json | 1 - desktop/src/i18n/locales/ko-KR.json | 1 - desktop/src/i18n/locales/zh-CN.json | 6 - desktop/src/store/configStore.ts | 49 +--- desktop/src/types/index.ts | 3 - 13 files changed, 49 insertions(+), 430 deletions(-) diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index 2b5e3a8..4fa7ec4 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -101,15 +101,6 @@ 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) - } - if v, ok := params["style"].(string); ok && strings.TrimSpace(v) != "" { - snapshot["style"] = strings.TrimSpace(v) - } // count 可能是 float64(JSON 解析)或 int(服务内部) if v, ok := params["count"].(int); ok && v > 0 { @@ -579,10 +570,6 @@ func GenerateWithImagesHandler(c *gin.Context) { RequestModel: req.ModelID, Config: fetchProviderConfig(req.Provider), }).ID - if req.Provider == "openai" && strings.EqualFold(strings.TrimSpace(modelID), "dall-e-3") { - Error(c, http.StatusBadRequest, 400, "DALL·E 3 暂不支持参考图,请移除参考图后重试") - return - } taskParams := map[string]interface{}{ "prompt": req.Prompt, "provider": req.Provider, diff --git a/backend/internal/provider/openai.go b/backend/internal/provider/openai.go index 469c868..dbee212 100644 --- a/backend/internal/provider/openai.go +++ b/backend/internal/provider/openai.go @@ -24,17 +24,6 @@ type OpenAIProvider struct { userAgent string } -type openAIImagesGenerationRequest struct { - Model string `json:"model"` - Prompt string `json:"prompt"` - Size string `json:"size,omitempty"` - Quality string `json:"quality,omitempty"` - Style string `json:"style,omitempty"` - ResponseFormat string `json:"response_format,omitempty"` - User string `json:"user,omitempty"` - N int `json:"n,omitempty"` -} - func NewOpenAIProvider(config *model.ProviderConfig) (*OpenAIProvider, error) { if config == nil { return nil, fmt.Errorf("config 不能为空") @@ -101,66 +90,6 @@ func (p *OpenAIProvider) Generate(ctx context.Context, params map[string]interfa return nil, fmt.Errorf("缺少 model_id 参数") } - if isDalle3Model(modelID) { - 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 style=%q prompt_hash=%s prompt_preview=%q", - p.Name(), - modelID, - reqBody.Size, - reqBody.Quality, - reqBody.Style, - diagnostic.PromptHash(promptPreview), - diagnostic.Preview(promptPreview, 160), - ) - - respBytes, headers, err := p.doImagesGenerationRequest(ctx, reqBody, params) - if err != nil { - return nil, err - } - - var raw map[string]interface{} - if err := json.Unmarshal(respBytes, &raw); err != nil { - return nil, fmt.Errorf("解析响应失败: %w", err) - } - data, ok := raw["data"].([]interface{}) - if !ok || len(data) == 0 { - return nil, fmt.Errorf("响应中未找到图片数据") - } - images, err := p.extractImagesFromData(ctx, data) - if err != nil { - return nil, err - } - if len(images) == 0 { - return nil, fmt.Errorf("未在响应中找到图片数据") - } - - requestID := extractRequestIDFromHeaders(headers) - diagnostic.Logf(params, "response_summary", - "provider=%s model=%s data_count=%d image_count=%d request_id=%s", - p.Name(), - modelID, - len(data), - len(images), - requestID, - ) - - return &ProviderResult{ - Images: images, - Metadata: map[string]interface{}{ - "provider": "openai", - "model": modelID, - "type": "image", - "request_id": requestID, - "oneapi_request": strings.TrimSpace(headers.Get("X-Oneapi-Request-Id")), - }, - }, nil - } - reqBody, refCount, promptPreview, err := p.buildChatRequestBody(modelID, params) if err != nil { return nil, err @@ -219,78 +148,9 @@ func (p *OpenAIProvider) ValidateParams(params map[string]interface{}) error { if prompt == "" { return fmt.Errorf("prompt 不能为空") } - modelID, _ := params["model_id"].(string) - if isDalle3Model(modelID) { - if raw, ok := params["reference_images"].([]interface{}); ok && len(raw) > 0 { - return fmt.Errorf("DALL·E 3 暂不支持参考图") - } - if count, ok := toInt(params["count"]); ok && (count < 1 || count > 10) { - return fmt.Errorf("DALL·E 3 的 count/n 必须介于 1 和 10 之间") - } - } return nil } -func isDalle3Model(modelID string) bool { - return strings.EqualFold(strings.TrimSpace(modelID), "dall-e-3") -} - -func (p *OpenAIProvider) 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, - ResponseFormat: "b64_json", - N: 1, - } - - if size := resolveDalle3Size(params); size != "" { - body.Size = size - } - if quality, _ := params["quality"].(string); strings.TrimSpace(quality) != "" { - body.Quality = strings.TrimSpace(strings.ToLower(quality)) - } - if style, _ := params["style"].(string); strings.TrimSpace(style) != "" { - body.Style = strings.TrimSpace(strings.ToLower(style)) - } - if responseFormat, _ := params["response_format"].(string); strings.TrimSpace(responseFormat) != "" { - body.ResponseFormat = strings.TrimSpace(responseFormat) - } - if user, _ := params["user"].(string); strings.TrimSpace(user) != "" { - body.User = strings.TrimSpace(user) - } - if count, ok := toInt(params["count"]); ok && count >= 1 && count <= 10 { - body.N = count - } - if body.Size == "" { - body.Size = "1024x1024" - } - return body, prompt, nil -} - -func resolveDalle3Size(params map[string]interface{}) string { - if size, _ := params["size"].(string); strings.TrimSpace(size) != "" { - return strings.TrimSpace(size) - } - ar, _ := params["aspect_ratio"].(string) - if ar == "" { - ar, _ = params["aspectRatio"].(string) - } - switch strings.TrimSpace(ar) { - case "9:16", "2:3", "3:4", "4:5": - return "1024x1792" - case "16:9", "3:2", "4:3", "5:4", "21:9": - return "1792x1024" - default: - return "1024x1024" - } -} - func (p *OpenAIProvider) buildChatRequestBody(modelID string, params map[string]interface{}) (map[string]interface{}, int, string, error) { rawMessages, hasMessages := params["messages"] reqBody := map[string]interface{}{ @@ -354,78 +214,6 @@ func (p *OpenAIProvider) buildChatRequestBody(modelID string, params map[string] return reqBody, refCount, promptPreview, nil } -func (p *OpenAIProvider) 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 -} - func (p *OpenAIProvider) doChatRequest(ctx context.Context, body map[string]interface{}, params map[string]interface{}) ([]byte, http.Header, error) { payloadBytes, err := json.Marshal(body) if err != nil { diff --git a/desktop/src/components/ConfigPanel/BatchSettings.tsx b/desktop/src/components/ConfigPanel/BatchSettings.tsx index 2342fa0..69e0bae 100644 --- a/desktop/src/components/ConfigPanel/BatchSettings.tsx +++ b/desktop/src/components/ConfigPanel/BatchSettings.tsx @@ -1,14 +1,7 @@ import { useMemo, useEffect } from 'react'; import { Settings2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { - useConfigStore, - getModelAspectRatios, - usesNativeImageSize, - DALLE3_SIZE_OPTIONS, - DALLE3_QUALITY_OPTIONS, - DALLE3_STYLE_OPTIONS -} from '../../store/configStore'; +import { useConfigStore, getModelAspectRatios } from '../../store/configStore'; import { Select } from '../common/Select'; import { Input } from '../common/Input'; import { toast } from '../../store/toastStore'; @@ -22,28 +15,18 @@ export function BatchSettings() { setImageSize, aspectRatio, setAspectRatio, - imageModel, - imageNativeSize, - setImageNativeSize, - imageQuality, - setImageQuality, - imageStyle, - setImageStyle + imageModel } = useConfigStore(); const supportedRatios = useMemo(() => getModelAspectRatios(imageModel), [imageModel]); - const useDalle3Controls = usesNativeImageSize(imageModel); useEffect(() => { - if (useDalle3Controls) { - return; - } if (supportedRatios.length > 0 && !supportedRatios.includes(aspectRatio)) { const newRatio = supportedRatios[0]; setAspectRatio(newRatio); toast.info(t('config.batch.ratioAutoAdjusted', { from: aspectRatio, to: newRatio })); } - }, [useDalle3Controls, imageModel, aspectRatio, setAspectRatio, supportedRatios, t]); + }, [imageModel, aspectRatio, setAspectRatio, supportedRatios, t]); return (
@@ -70,61 +53,30 @@ export function BatchSettings() { />
- - {useDalle3Controls ? ( - - ) : ( - - )} + +
- {useDalle3Controls ? ( -
-
- - -
-
- - -
-
- ) : ( -
- - -
- )} +
+ + +
); } diff --git a/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx b/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx index d44e5c3..7c8dbbc 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, supportsReferenceImages } from '../../store/configStore'; +import { useConfigStore } from '../../store/configStore'; import { useInternalDragStore, type InternalDragPayload } from '../../store/internalDragStore'; import { cn } from '../common/Button'; import { toast } from '../../store/toastStore'; @@ -105,7 +105,6 @@ const normalizeLocalPathInput = (value: string) => { export function ReferenceImageUpload() { const { t } = useTranslation(); const refFiles = useConfigStore((s) => s.refFiles); - const imageModel = useConfigStore((s) => s.imageModel); const addRefFiles = useConfigStore((s) => s.addRefFiles); const removeRefFile = useConfigStore((s) => s.removeRefFile); const setRefFiles = useConfigStore((s) => s.setRefFiles); @@ -156,7 +155,6 @@ export function ReferenceImageUpload() { // 图片反推提示词相关状态 const [isExtractingPrompt, setIsExtractingPrompt] = useState(false); const [extractingIndex, setExtractingIndex] = useState(null); - const allowReferenceImages = supportsReferenceImages(imageModel); // 计算文件 MD5(使用工具函数) const calculateMd5Callback = useCallback(calculateMd5, []); @@ -172,15 +170,6 @@ export function ReferenceImageUpload() { }; }, []); - useEffect(() => { - if (allowReferenceImages || refFiles.length === 0) { - return; - } - setRefFiles([]); - setRefImageEntries([]); - toast.info(t('refImage.unsupportedModelCleared')); - }, [allowReferenceImages, refFiles.length, setRefFiles, setRefImageEntries, t]); - const preloadDialog = useCallback(async () => { if (!window.__TAURI_INTERNALS__) return; if (dialogOpenRef.current || dialogLoadingRef.current) return; @@ -1602,7 +1591,7 @@ export function ReferenceImageUpload() { }; const showDragOver = isDraggingOver || (isInternalDragging && isOverDropTarget); - const interactive = allowReferenceImages; + const interactive = true; return (
- {!interactive && ( - - {t('refImage.unsupportedModel')} - - )} {interactive && showDragOver && ( {t('refImage.dropHint')} @@ -1663,7 +1647,7 @@ export function ReferenceImageUpload() { {/* 收起状态提示 */} {!isExpanded && refFiles.length === 0 && (
- {interactive ? t('refImage.collapsedHint') : t('refImage.unsupportedHint')} + {t('refImage.collapsedHint')}
)} @@ -1770,18 +1754,9 @@ export function ReferenceImageUpload() { {refFiles.length > 0 ? t('refImage.addMore') : t('refImage.add')}
{t('refImage.supportHint')} -
+ )} - {!interactive && refFiles.length === 0 && ( -
- -
- {t('refImage.unsupportedModel')} - {t('refImage.unsupportedHint')} -
-
- )} )} { if (typeof aspectRatio === 'string' && aspectRatio.trim()) { return aspectRatio.trim(); } - const match = nativeSizeLabel.match(/^(\d+)\s*x\s*(\d+)$/i); - if (match) { - return formatAspectRatioLabel(Number(match[1]), Number(match[2])); - } return '—'; })(); diff --git a/desktop/src/components/Settings/SettingsModal.tsx b/desktop/src/components/Settings/SettingsModal.tsx index 4b27e7c..abad668 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, isDalle3Model } from '../../store/configStore'; +import { IMAGE_MODEL_OPTIONS, VISION_MODEL_OPTIONS, CUSTOM_MODEL_VALUE } from '../../store/configStore'; import { getPromptOptimizeConfigIssue } from '../../utils/promptOptimizeConfig'; import { ensureNotificationPermission, sendTestSystemNotification } from '../../hooks/useGenerationNotifications'; @@ -54,9 +54,7 @@ const getChatProviderDefaults = (provider: string) => { }; }; -const getDefaultImageModelForProvider = (provider: string) => ( - provider === 'openai' ? 'dall-e-3' : IMAGE_MODEL_OPTIONS[0].value -); +const getDefaultImageModelForProvider = (_provider: string) => IMAGE_MODEL_OPTIONS[0].value; const resolveSystemLanguage = (locale: string | null) => { if (!locale) return DEFAULT_LANGUAGE; @@ -1089,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' && isDalle3Model(imageModel) && ( -

{t('settings.provider.openaiImageLimit')}

- )} {/* API Key */} diff --git a/desktop/src/hooks/useGenerate.ts b/desktop/src/hooks/useGenerate.ts index 331e15d..d35656d 100644 --- a/desktop/src/hooks/useGenerate.ts +++ b/desktop/src/hooks/useGenerate.ts @@ -10,7 +10,6 @@ import { useHistoryStore } from '../store/historyStore'; import i18n from '../i18n'; import { getDiagnosticVerbose } from '../utils/diagnosticLogger'; import { getPromptOptimizeConfigIssue } from '../utils/promptOptimizeConfig'; -import { isDalle3Model } from '../store/configStore'; // 流式连接建立超时时间(毫秒)- 超过此时间未建立连接则启动轮询 // 本地后端通常不会推实时进度,过长会导致用户"卡住"的观感 @@ -409,29 +408,15 @@ export function useGenerate() { const requestedCount = Math.max(1, Number(config.count) || 1); const promptOptimizeMode = config.defaultPromptOptimizeMode || 'off'; const shouldAutoOptimizePrompt = promptOptimizeMode !== 'off'; - const useDalle3Params = isDalle3Model(config.imageModel); - const requestImageSize = useDalle3Params ? config.imageNativeSize : config.imageSize; - const taskOptions = useDalle3Params - ? { - size: config.imageNativeSize, - quality: config.imageQuality, - style: config.imageStyle, - } - : { - aspectRatio: config.aspectRatio, - imageSize: config.imageSize, - }; + const taskOptions = { + aspectRatio: config.aspectRatio, + imageSize: config.imageSize, + }; const buildImageParams = (count: number) => ({ prompt: config.prompt, count, - ...(useDalle3Params ? { - size: config.imageNativeSize, - quality: config.imageQuality, - style: config.imageStyle, - } : { - aspectRatio: config.aspectRatio, - imageSize: config.imageSize, - }), + aspectRatio: config.aspectRatio, + imageSize: config.imageSize, verbose_logging: verboseLogging, ...(shouldAutoOptimizePrompt ? { prompt_optimize_mode: promptOptimizeMode, @@ -509,8 +494,8 @@ export function useGenerate() { const batchTaskId = `${BATCH_TASK_PREFIX}${Date.now()}`; startTask(batchTaskId, requestedCount, { prompt: config.prompt, - aspectRatio: useDalle3Params ? undefined : config.aspectRatio, - imageSize: requestImageSize, + aspectRatio: config.aspectRatio, + imageSize: config.imageSize, options: taskOptions }); setConnectionMode('none'); @@ -666,8 +651,8 @@ export function useGenerate() { // 启动任务 startTask(newTaskId, requestedCount, { prompt: config.prompt, - aspectRatio: useDalle3Params ? undefined : config.aspectRatio, - imageSize: requestImageSize, + aspectRatio: config.aspectRatio, + imageSize: config.imageSize, options: taskOptions }); diff --git a/desktop/src/i18n/locales/en-US.json b/desktop/src/i18n/locales/en-US.json index f34d912..0f3e21d 100644 --- a/desktop/src/i18n/locales/en-US.json +++ b/desktop/src/i18n/locales/en-US.json @@ -138,8 +138,6 @@ "addMore": "Add more", "supportHint": "(supports multi-select or drag & drop)", "unsupportedModel": "Current model does not support reference images", - "unsupportedHint": "DALL·E 3 currently supports text-to-image only. Remove reference images and try again.", - "unsupportedModelCleared": "Reference images unsupported by the current model were cleared", "toast": { "full": "Reference images are full", "exists": "Image already exists", @@ -187,7 +185,6 @@ "label": "Provider", "recommended": "Recommended:", "yunwu": "Yunwu API", - "openaiImageLimit": "DALL·E 3 uses fixed size presets and does not support 1K/2K/4K or reference 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)" @@ -428,9 +425,6 @@ "title": "Generation Settings", "count": "Count (1-10)", "resolution": "Resolution", - "nativeSize": "Size", - "quality": "Quality", - "style": "Style", "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 2b92634..3ab8c36 100644 --- a/desktop/src/i18n/locales/zh-CN.json +++ b/desktop/src/i18n/locales/zh-CN.json @@ -138,8 +138,6 @@ "addMore": "继续添加", "supportHint": "(支持多选或拖拽)", "unsupportedModel": "当前模型不支持参考图", - "unsupportedHint": "DALL·E 3 当前仅支持文本生图,请移除参考图后再生成", - "unsupportedModelCleared": "已自动清空当前模型不支持的参考图", "toast": { "full": "参考图已满", "exists": "图片已存在", @@ -187,7 +185,6 @@ "label": "AI对接方式", "recommended": "推荐平台:", "yunwu": "云雾API", - "openaiImageLimit": "DALL·E 3 使用固定尺寸档位,不支持 1K/2K/4K 与参考图", "geminiBasePathHint": "Gemini 类型建议 Base URL 仅填写域名,不要带 /v1 或具体接口路径", "yunwuMissingV1Hint": "云雾 API 的 Base URL 建议包含 /v1", "yunwuExtraPathHint": "云雾 API 的 Base URL 建议只保留到 /v1,不要包含更深路径" @@ -428,9 +425,6 @@ "title": "生成设置", "count": "数量 (1-10)", "resolution": "分辨率", - "nativeSize": "尺寸", - "quality": "质量", - "style": "风格", "resolution1k": "1K (1024px)", "resolution2k": "2K (2048px)", "resolution4k": "4K (3840px)", diff --git a/desktop/src/store/configStore.ts b/desktop/src/store/configStore.ts index a4f915f..5eca181 100644 --- a/desktop/src/store/configStore.ts +++ b/desktop/src/store/configStore.ts @@ -5,7 +5,6 @@ import type { PersistedRefImage } from '../types'; const IMAGE_MODELS = { FLASH: { value: 'gemini-3.1-flash-image-preview', label: 'Flash' }, PRO: { value: 'gemini-3-pro-image-preview', label: 'Pro' }, - DALLE3: { value: 'dall-e-3', label: 'DALL·E 3' }, } as const; // 默认生图模型名称 @@ -15,7 +14,6 @@ export const DEFAULT_IMAGE_MODEL = IMAGE_MODELS.FLASH.value; export const 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})` }, - { value: IMAGE_MODELS.DALLE3.value, label: `${IMAGE_MODELS.DALLE3.label} (${IMAGE_MODELS.DALLE3.value})` }, ] as const; export const VISION_MODEL_OPTIONS = [ @@ -34,26 +32,6 @@ export const IMAGE_MODEL_CONFIG: Record = { } }; -export const DALLE3_SIZE_OPTIONS = [ - { value: '1024x1024', label: '1024 x 1024' }, - { value: '1024x1792', label: '1024 x 1792' }, - { value: '1792x1024', label: '1792 x 1024' } -] as const; - -export const DALLE3_QUALITY_OPTIONS = [ - { value: 'standard', label: 'Standard' }, - { value: 'hd', label: 'HD' } -] as const; - -export const DALLE3_STYLE_OPTIONS = [ - { value: 'vivid', label: 'Vivid' }, - { value: 'natural', label: 'Natural' } -] as const; - -export const isDalle3Model = (model: string): boolean => String(model || '').trim().toLowerCase() === IMAGE_MODELS.DALLE3.value; -export const supportsReferenceImages = (model: string): boolean => !isDalle3Model(model); -export const usesNativeImageSize = (model: string): boolean => isDalle3Model(model); - // Helper function to get supported aspect ratios for a model export const getModelAspectRatios = (model: string): string[] => { const ratios = IMAGE_MODEL_CONFIG[model]?.aspectRatios; @@ -112,9 +90,6 @@ interface ConfigState { count: number; imageSize: string; aspectRatio: string; - imageNativeSize: string; - imageQuality: string; - imageStyle: string; refFiles: File[]; refImageEntries: PersistedRefImage[]; @@ -150,9 +125,6 @@ interface ConfigState { setCount: (count: number) => void; setImageSize: (size: string) => void; setAspectRatio: (ratio: string) => void; - setImageNativeSize: (size: string) => void; - setImageQuality: (quality: string) => void; - setImageStyle: (style: string) => void; setRefFiles: (files: File[]) => void; addRefFiles: (files: File[]) => void; removeRefFile: (index: number) => void; @@ -197,9 +169,6 @@ export const useConfigStore = create()( count: 1, imageSize: '2K', aspectRatio: '1:1', - imageNativeSize: '1024x1024', - imageQuality: 'standard', - imageStyle: 'vivid', refFiles: [], refImageEntries: [], @@ -235,9 +204,6 @@ export const useConfigStore = create()( setCount: (count) => set({ count }), setImageSize: (imageSize) => set({ imageSize }), setAspectRatio: (aspectRatio) => set({ aspectRatio }), - setImageNativeSize: (imageNativeSize) => set({ imageNativeSize }), - setImageQuality: (imageQuality) => set({ imageQuality }), - setImageStyle: (imageStyle) => set({ imageStyle }), setRefFiles: (refFiles) => set({ refFiles }), setRefImageEntries: (refImageEntries) => set({ refImageEntries }), @@ -279,9 +245,6 @@ export const useConfigStore = create()( count: 1, imageSize: '2K', aspectRatio: '1:1', - imageNativeSize: '1024x1024', - imageQuality: 'standard', - imageStyle: 'vivid', refFiles: [], refImageEntries: [], }) @@ -289,7 +252,7 @@ export const useConfigStore = create()( { name: 'app-config-storage', storage: createJSONStorage(() => localStorage), - version: 16, + version: 17, // 关键:不要将 File 对象序列化到 localStorage(File 对象无法序列化) partialize: (state) => { const { refFiles, ...rest } = state; @@ -298,6 +261,7 @@ export const useConfigStore = create()( migrate: (persistedState, version) => { const state = persistedState as any; let next = state; + const imageModelValues = new Set(IMAGE_MODEL_OPTIONS.map((option) => option.value)); if (version < 2) { next = { ...state, @@ -394,12 +358,13 @@ export const useConfigStore = create()( notifyOnFailure: next.notifyOnFailure ?? true }; } - if (version < 16) { + if (version < 17) { + const currentImageModel = String(next.imageModel ?? '').trim(); next = { ...next, - imageNativeSize: next.imageNativeSize ?? '1024x1024', - imageQuality: next.imageQuality ?? 'standard', - imageStyle: next.imageStyle ?? 'vivid' + imageModel: !currentImageModel || !imageModelValues.has(currentImageModel) + ? DEFAULT_IMAGE_MODEL + : currentImageModel }; } diff --git a/desktop/src/types/index.ts b/desktop/src/types/index.ts index 3a04dbe..db04658 100644 --- a/desktop/src/types/index.ts +++ b/desktop/src/types/index.ts @@ -40,9 +40,6 @@ export interface GeneratedImage { export interface ImageOptions { aspectRatio?: string; imageSize?: string; - size?: string; - quality?: string; - style?: string; } // 任务模型 From 2569b56d116ac45140c0984135f29e1b47aedeca Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Tue, 21 Apr 2026 22:49:40 +0800 Subject: [PATCH 4/9] Add OpenAI Images provider support --- backend/internal/api/handlers.go | 10 +- backend/internal/model/db.go | 6 +- backend/internal/provider/model_resolver.go | 3 + backend/internal/provider/openai_image.go | 233 ++++++++++++++++++ backend/internal/provider/provider.go | 6 +- backend/scripts/seed.go | 17 +- .../components/ConfigPanel/BatchSettings.tsx | 86 +++++-- .../ConfigPanel/ReferenceImageUpload.tsx | 32 ++- .../HistoryPanel/FailedTaskCard.tsx | 5 +- .../src/components/Settings/SettingsModal.tsx | 14 +- desktop/src/hooks/useGenerate.ts | 55 +++-- desktop/src/i18n/locales/en-US.json | 2 + desktop/src/i18n/locales/zh-CN.json | 2 + desktop/src/store/configStore.ts | 68 ++++- desktop/src/types/index.ts | 4 + 15 files changed, 476 insertions(+), 67 deletions(-) create mode 100644 backend/internal/provider/openai_image.go 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/model_resolver.go b/backend/internal/provider/model_resolver.go index c4da9d7..049e2db 100644 --- a/backend/internal/provider/model_resolver.go +++ b/backend/internal/provider/model_resolver.go @@ -87,6 +87,9 @@ 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 "" } 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/scripts/seed.go b/backend/scripts/seed.go index 5513945..24ebe58 100644 --- a/backend/scripts/seed.go +++ b/backend/scripts/seed.go @@ -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: "YOUR_API_KEY_HERE", + 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/src/components/ConfigPanel/BatchSettings.tsx b/desktop/src/components/ConfigPanel/BatchSettings.tsx index 69e0bae..298bc18 100644 --- a/desktop/src/components/ConfigPanel/BatchSettings.tsx +++ b/desktop/src/components/ConfigPanel/BatchSettings.tsx @@ -1,7 +1,14 @@ 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'; @@ -13,20 +20,30 @@ export function BatchSettings() { setCount, imageSize, setImageSize, + imageNativeSize, + setImageNativeSize, + imageQuality, + setImageQuality, aspectRatio, setAspectRatio, - imageModel + 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 (
@@ -53,30 +70,51 @@ export function BatchSettings() { />
- - + + {useNativeSize ? ( + + ) : ( + + )}
-
- - -
+ {useNativeSize ? ( + useQuality ? ( +
+ + +
+ ) : null + ) : ( +
+ + +
+ )} ); } diff --git a/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx b/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx index 7c8dbbc..8035333 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,7 @@ export function ReferenceImageUpload() { // 图片反推提示词相关状态 const [isExtractingPrompt, setIsExtractingPrompt] = useState(false); const [extractingIndex, setExtractingIndex] = useState(null); + const allowReferenceImages = supportsReferenceImages(imageProvider); // 计算文件 MD5(使用工具函数) const calculateMd5Callback = useCallback(calculateMd5, []); @@ -170,6 +172,15 @@ export function ReferenceImageUpload() { }; }, []); + useEffect(() => { + if (allowReferenceImages || refFiles.length === 0) { + return; + } + setRefFiles([]); + setRefImageEntries([]); + toast.info(t('refImage.unsupportedModel')); + }, [allowReferenceImages, refFiles.length, setRefFiles, setRefImageEntries, t]); + const preloadDialog = useCallback(async () => { if (!window.__TAURI_INTERNALS__) return; if (dialogOpenRef.current || dialogLoadingRef.current) return; @@ -1591,7 +1602,24 @@ export function ReferenceImageUpload() { }; const showDragOver = isDraggingOver || (isInternalDragging && isOverDropTarget); - const interactive = true; + const interactive = allowReferenceImages; + + if (!allowReferenceImages) { + return ( +
+
+ + {t('refImage.title', { count: 0 })} +
+
+ +
+ {t('refImage.unsupportedModel')} +
+
+
+ ); + } return (
{ diff --git a/desktop/src/components/Settings/SettingsModal.tsx b/desktop/src/components/Settings/SettingsModal.tsx index abad668..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'; @@ -54,8 +54,6 @@ const getChatProviderDefaults = (provider: string) => { }; }; -const getDefaultImageModelForProvider = (_provider: string) => IMAGE_MODEL_OPTIONS[0].value; - const resolveSystemLanguage = (locale: string | null) => { if (!locale) return DEFAULT_LANGUAGE; const lower = locale.toLowerCase(); @@ -217,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; }); @@ -230,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(() => { @@ -1057,6 +1056,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { > + {/* 后续可扩展更多 provider */}
@@ -1124,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 d35656d..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,27 @@ export function useGenerate() { const requestedCount = Math.max(1, Number(config.count) || 1); const promptOptimizeMode = config.defaultPromptOptimizeMode || 'off'; const shouldAutoOptimizePrompt = promptOptimizeMode !== 'off'; - const taskOptions = { - aspectRatio: config.aspectRatio, - imageSize: config.imageSize, - }; + 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, - aspectRatio: config.aspectRatio, - imageSize: config.imageSize, + ...(useNativeSize ? { + size: config.imageNativeSize, + quality: config.imageQuality, + } : { + aspectRatio: config.aspectRatio, + imageSize: config.imageSize, + }), verbose_logging: verboseLogging, ...(shouldAutoOptimizePrompt ? { prompt_optimize_mode: promptOptimizeMode, @@ -426,13 +439,18 @@ export function useGenerate() { }); 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) { @@ -494,8 +512,8 @@ 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'); @@ -592,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) { @@ -651,8 +674,8 @@ 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 0f3e21d..775cb3a 100644 --- a/desktop/src/i18n/locales/en-US.json +++ b/desktop/src/i18n/locales/en-US.json @@ -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/zh-CN.json b/desktop/src/i18n/locales/zh-CN.json index 3ab8c36..0198aad 100644 --- a/desktop/src/i18n/locales/zh-CN.json +++ b/desktop/src/i18n/locales/zh-CN.json @@ -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 5eca181..791074a 100644 --- a/desktop/src/store/configStore.ts +++ b/desktop/src/store/configStore.ts @@ -7,15 +7,48 @@ 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 || IMAGE_MODELS.FLASH.value; +}; + +// 默认生图模型名称 +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 +65,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 +127,8 @@ interface ConfigState { count: number; imageSize: string; aspectRatio: string; + imageNativeSize: string; + imageQuality: string; refFiles: File[]; refImageEntries: PersistedRefImage[]; @@ -125,6 +164,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 +210,8 @@ export const useConfigStore = create()( count: 1, imageSize: '2K', aspectRatio: '1:1', + imageNativeSize: 'auto', + imageQuality: 'auto', refFiles: [], refImageEntries: [], @@ -204,6 +247,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 +290,8 @@ export const useConfigStore = create()( count: 1, imageSize: '2K', aspectRatio: '1:1', + imageNativeSize: 'auto', + imageQuality: 'auto', refFiles: [], refImageEntries: [], }) @@ -252,7 +299,7 @@ export const useConfigStore = create()( { name: 'app-config-storage', storage: createJSONStorage(() => localStorage), - version: 17, + version: 18, // 关键:不要将 File 对象序列化到 localStorage(File 对象无法序列化) partialize: (state) => { const { refFiles, ...rest } = state; @@ -261,7 +308,6 @@ export const useConfigStore = create()( migrate: (persistedState, version) => { const state = persistedState as any; let next = state; - const imageModelValues = new Set(IMAGE_MODEL_OPTIONS.map((option) => option.value)); if (version < 2) { next = { ...state, @@ -359,12 +405,16 @@ export const useConfigStore = create()( }; } if (version < 17) { - const currentImageModel = String(next.imageModel ?? '').trim(); next = { ...next, - imageModel: !currentImageModel || !imageModelValues.has(currentImageModel) - ? DEFAULT_IMAGE_MODEL - : currentImageModel + imageModel: next.imageModel ?? DEFAULT_IMAGE_MODEL + }; + } + if (version < 18) { + next = { + ...next, + imageNativeSize: next.imageNativeSize ?? 'auto', + imageQuality: next.imageQuality ?? 'auto' }; } diff --git a/desktop/src/types/index.ts b/desktop/src/types/index.ts index db04658..77b6ac8 100644 --- a/desktop/src/types/index.ts +++ b/desktop/src/types/index.ts @@ -40,6 +40,8 @@ export interface GeneratedImage { export interface ImageOptions { 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) From 75dd7d12f2db5a13ab14359f1e8d8a35ba495f60 Mon Sep 17 00:00:00 2001 From: ShellMonster Date: Tue, 21 Apr 2026 22:57:43 +0800 Subject: [PATCH 5/9] Fix PR review and backend formatting --- backend/internal/provider/gemini.go | 2 +- backend/internal/worker/pool.go | 26 +++++++++---------- backend/scripts/seed.go | 6 ++--- .../ConfigPanel/ReferenceImageUpload.tsx | 23 ++++++++-------- 4 files changed, 28 insertions(+), 29 deletions(-) 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/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 24ebe58..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, @@ -43,7 +43,7 @@ func main() { ProviderName: "openai-image", DisplayName: "OpenAI Images", 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, diff --git a/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx b/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx index 8035333..97745dd 100644 --- a/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx +++ b/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx @@ -1602,7 +1602,6 @@ export function ReferenceImageUpload() { }; const showDragOver = isDraggingOver || (isInternalDragging && isOverDropTarget); - const interactive = allowReferenceImages; if (!allowReferenceImages) { return ( @@ -1625,20 +1624,20 @@ export function ReferenceImageUpload() {
{/* 标题行 + 折叠按钮 */}
- {interactive && showDragOver && ( + {showDragOver && ( {t('refImage.dropHint')} )} - {interactive && refFiles.length > 0 && !showDragOver && ( + {refFiles.length > 0 && !showDragOver && ( {t('refImage.modeActive')} @@ -1765,7 +1764,7 @@ export function ReferenceImageUpload() { )} {/* 上传按钮/区域 */} - {interactive && refFiles.length === 0 && refFiles.length < 10 && ( + {refFiles.length === 0 && refFiles.length < 10 && (