diff --git a/backend/internal/app/task_creation_test.go b/backend/internal/app/task_creation_test.go index fb3217bd2..0f31e67a1 100644 --- a/backend/internal/app/task_creation_test.go +++ b/backend/internal/app/task_creation_test.go @@ -516,6 +516,57 @@ func TestParameterizedOpenAIAspectPresetSystemChannelPersistsPixelExecutionSize( } } +func TestParameterizedOpenAIPixelSizeSelectionAcceptsDeclaredSizeAndResolution(t *testing.T) { + profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceOpenAIImage), "GPT Image 2.5") + minCount, maxCount := 1.0, 5.0 + profile.Image.Parameters = []protocol.ImageParameter{ + { + Name: "size", Label: "画幅 / 尺寸", Type: "string", + Values: []any{"auto", "1824x1024", "2752x1536", "3840x2160"}, Default: "1824x1024", + Pricing: true, Binding: "aspectRatio", Encoding: "pixels", + }, + { + Name: "imageResolution", Label: "分辨率", Type: "string", + Values: []any{"1k", "2k", "4k"}, Default: "1k", Pricing: true, Binding: "resolution", + }, + {Name: "count", Label: "生成数量", Type: "integer", Min: &minCount, Max: &maxCount, Default: 1, Pricing: true, Binding: "imageCount"}, + {Name: "transparentBackground", Label: "透明背景", Type: "boolean", Default: false, Binding: "transparentBackground"}, + } + profile.Image.Size = ImageSizeConfig{ + Parameter: "size", Values: []string{"auto", "1824x1024", "2752x1536", "3840x2160"}, Default: "1824x1024", + Presets: []ImageSizePreset{ + {Tier: "1k", Ratio: "16:9", Size: "1824x1024", Width: 1824, Height: 1024}, + {Tier: "2k", Ratio: "16:9", Size: "2752x1536", Width: 2752, Height: 1536}, + {Tier: "4k", Ratio: "16:9", Size: "3840x2160", Width: 3840, Height: 2160}, + }, + } + if err := projectImageParameters(profile.Image); err != nil { + t.Fatal(err) + } + svc, _, channel, channelModel := createSystemChannelSelectionFixture(t, "image", model.ChannelInterfaceOpenAIImage, profile, []model.ChannelModelPriceTier{ + newSelectionPriceTier("tier-1k", `{"imageResolution":"1k"}`, "provider-1k", "fixed_request"), + newSelectionPriceTier("tier-2k", `{"imageResolution":"2k"}`, "provider-2k", "fixed_request"), + newSelectionPriceTier("tier-4k", `{"imageResolution":"4k"}`, "provider-4k", "fixed_request"), + }) + input := map[string]any{ + "mode": "image", + "config": map[string]any{ + "channelId": channel.ID, "model": channelModel.ModelKey, + "size": "1824x1024", "imageResolution": "1k", + "imageParameters": map[string]any{"size": "1824x1024", "imageResolution": "1k", "count": 1, "transparentBackground": false}, + }, + } + resolved, err := svc.resolveSystemChannelModelSelection(input, "canvas_image", "") + if err != nil { + t.Fatalf("resolve error = %v", err) + } + config := resolved["config"].(map[string]any) + parameters := config["imageParameters"].(map[string]any) + if parameters["size"] != "1824x1024" || parameters["imageResolution"] != "1k" || config["priceTierId"] != "tier-1k" { + t.Fatalf("resolved parameters = %#v, config = %#v", parameters, config) + } +} + func TestOpenAIAspectPresetSystemChannelPersistsPixelExecutionSize(t *testing.T) { profile := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceOpenAIImage), "gpt-image-2.5-4k") profile.Image.Size = ImageSizeConfig{ diff --git a/backend/internal/app/task_output.go b/backend/internal/app/task_output.go index 3f5ff9c1c..2ffecb499 100644 --- a/backend/internal/app/task_output.go +++ b/backend/internal/app/task_output.go @@ -356,12 +356,16 @@ func publicWorkbenchParameters(kind string, snapshot, input map[string]any) map[ if kind == model.WorkbenchKindImage { copyWorkbenchTextParameter(result, "size", parameters["size"]) copyWorkbenchTextParameter(result, "quality", parameters["quality"]) + copyWorkbenchTextParameter(result, "imageResolution", parameters["imageResolution"]) if result["size"] == nil { copyWorkbenchTextParameter(result, "size", config["size"]) } if result["quality"] == nil { copyWorkbenchTextParameter(result, "quality", config["quality"]) } + if result["imageResolution"] == nil { + copyWorkbenchTextParameter(result, "imageResolution", config["imageResolution"]) + } if value, ok := workbenchInteger(parameters["count"], 20); ok { result["count"] = value } else if value, ok := workbenchInteger(config["count"], 20); ok { diff --git a/backend/internal/app/task_output_test.go b/backend/internal/app/task_output_test.go index 3ebb486e7..161d60c13 100644 --- a/backend/internal/app/task_output_test.go +++ b/backend/internal/app/task_output_test.go @@ -59,6 +59,22 @@ func TestTaskOutputIncludesOnlySafeWorkbenchRestoreData(t *testing.T) { } } +func TestTaskOutputRestoresWorkbenchImageResolution(t *testing.T) { + task := model.Task{InputJSON: `{"mode":"image","metadata":{"source":"workbench","workbenchKind":"image","workbench":{"kind":"image","parameters":{"size":"1824x1024","imageResolution":"2k","quality":"auto","count":1}}},"config":{"size":"1824x1024","imageResolution":"2k","quality":"auto","count":"1"}}`} + var output map[string]any + if err := json.Unmarshal([]byte(publicTaskInputJSON(task.InputJSON)), &output); err != nil { + t.Fatal(err) + } + workbench, ok := output["workbench"].(map[string]any) + if !ok { + t.Fatalf("workbench restore data missing: %#v", output) + } + parameters, ok := workbench["parameters"].(map[string]any) + if !ok || parameters["imageResolution"] != "2k" { + t.Fatalf("image resolution not preserved for restore: %#v", workbench) + } +} + func TestTaskMediaPreviewUsesSafeMediaURLs(t *testing.T) { previewURL, previewKind := taskMediaPreview(`{"images":["data:image/png;base64,AAAA","/api/resources/resource-1/file"],"video":"https://cdn.example.com/output.mp4"}`, "video") if previewURL != "/api/resources/resource-1/file" || previewKind != "image" { diff --git a/backend/internal/app/workbench_bridge.go b/backend/internal/app/workbench_bridge.go index e71f9f123..f69e74a33 100644 --- a/backend/internal/app/workbench_bridge.go +++ b/backend/internal/app/workbench_bridge.go @@ -152,7 +152,7 @@ func (s *Service) WorkbenchTasks(userID, kind string, activeOnly bool, limit int func (s *Service) resolveWorkbenchProviderConfig(kind, modelName, logicalModelID, channelID string, parameters map[string]any, input map[string]any) (map[string]any, error) { config := map[string]any{"mode": kind} if kind == WorkbenchKindImage { - for _, key := range []string{"size", "quality", "count"} { + for _, key := range []string{"size", "quality", "imageResolution", "count"} { if value, exists := parameters[key]; exists { config[key] = providerConfigOptionValue(value) } diff --git a/backend/internal/app/workbench_bridge_test.go b/backend/internal/app/workbench_bridge_test.go index 57ca00ac4..d38d2909c 100644 --- a/backend/internal/app/workbench_bridge_test.go +++ b/backend/internal/app/workbench_bridge_test.go @@ -6,12 +6,12 @@ func TestResolveWorkbenchProviderConfigMapsWorkbenchParameters(t *testing.T) { service := &Service{} image, err := service.resolveWorkbenchProviderConfig(WorkbenchKindImage, "image-model", "logical-image", "", map[string]any{ - "size": "1024x1024", "quality": "high", "count": 2, + "size": "1024x1024", "quality": "high", "imageResolution": "2k", "count": 2, }, map[string]any{}) if err != nil { t.Fatal(err) } - for key, want := range map[string]any{"model": "image-model", "size": "1024x1024", "quality": "high", "count": "2"} { + for key, want := range map[string]any{"model": "image-model", "size": "1024x1024", "quality": "high", "imageResolution": "2k", "count": "2"} { if image[key] != want { t.Errorf("image config[%q] = %#v, want %#v", key, image[key], want) } diff --git a/backend/internal/workbench/service_test.go b/backend/internal/workbench/service_test.go index cb40a2ed9..44404a28b 100644 --- a/backend/internal/workbench/service_test.go +++ b/backend/internal/workbench/service_test.go @@ -158,7 +158,7 @@ func TestWorkbenchTaskRejectsUnknownParametersAndClientConfig(t *testing.T) { func TestWorkbenchTaskCanBeCreatedWithoutTool(t *testing.T) { s, _, _ := newWorkbenchTest(t) - request := TaskInput{Kind: KindImage, Prompt: "A glass greenhouse at sunrise", Model: "image-model", LogicalModelID: "logical", Parameters: map[string]any{"size": "1024x1024", "count": 1}} + request := TaskInput{Kind: KindImage, Prompt: "A glass greenhouse at sunrise", Model: "image-model", LogicalModelID: "logical", Parameters: map[string]any{"size": "1024x1024", "imageResolution": "2k", "count": 1}} resolved, err := s.ResolveTask("user", request) if err != nil { t.Fatal(err) @@ -174,7 +174,8 @@ func TestWorkbenchTaskCanBeCreatedWithoutTool(t *testing.T) { t.Fatalf("tool-less task contains a tool id: %#v", metadata) } snapshot := metadata["workbench"].(map[string]any) - if snapshot["kind"] != KindImage || snapshot["parameters"].(map[string]any)["count"] != 1 { + parameters := snapshot["parameters"].(map[string]any) + if snapshot["kind"] != KindImage || parameters["count"] != 1 || parameters["imageResolution"] != "2k" { t.Fatalf("workbench parameters not snapshotted: %#v", snapshot) } if resolved.TaskType != "canvas_image" || resolved.Operation != "image" { diff --git a/backend/internal/workbench/tasks.go b/backend/internal/workbench/tasks.go index f98a56d64..5d7d20bd8 100644 --- a/backend/internal/workbench/tasks.go +++ b/backend/internal/workbench/tasks.go @@ -194,7 +194,7 @@ func (s *Service) ResolveTask(userID string, req TaskInput) (*TaskResolution, er func normalizeTaskParameters(kind string, params map[string]any) (map[string]any, error) { allowed := map[string]map[string]bool{ - KindImage: {"size": true, "quality": true, "count": true}, + KindImage: {"size": true, "quality": true, "imageResolution": true, "count": true}, KindVideo: {"seconds": true, "ratio": true, "resolution": true, "generateAudio": true, "watermark": true}, }[kind] if allowed == nil { @@ -206,7 +206,7 @@ func normalizeTaskParameters(kind string, params map[string]any) (map[string]any return nil, kernel.BadAuthRequest("生成参数包含不支持字段: " + key) } switch key { - case "size", "quality", "ratio", "resolution": + case "size", "quality", "imageResolution", "ratio", "resolution": text, ok := value.(string) text = strings.TrimSpace(text) if !ok || len(text) > 64 || strings.Contains(text, "://") || strings.HasPrefix(strings.ToLower(text), "data:") { diff --git a/web/src/pages/workbench/image-workbench-page.tsx b/web/src/pages/workbench/image-workbench-page.tsx index c7433c83d..668640923 100644 --- a/web/src/pages/workbench/image-workbench-page.tsx +++ b/web/src/pages/workbench/image-workbench-page.tsx @@ -210,6 +210,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { const [settingsReady, setSettingsReady] = useState(false); const [ratio, setRatio] = useState(kind === "image" ? "1:1" : "16:9"); const [quality, setQuality] = useState("auto"); + const [imageResolution, setImageResolution] = useState("auto"); const [count, setCount] = useState("1"); const [seconds, setSeconds] = useState("6"); const [videoQuality, setVideoQuality] = useState("720"); @@ -246,8 +247,8 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { [references], ); const composerConfig = useMemo( - () => (kind === "video" && selectedModel ? creationVideoConfig(config, selectedModel, { ratio, seconds, videoQuality }) : { ...config, model: selectedModel, imageModel: selectedModel, size: ratio, quality, count }), - [config, count, kind, quality, ratio, seconds, selectedModel, videoQuality], + () => (kind === "video" && selectedModel ? creationVideoConfig(config, selectedModel, { ratio, seconds, videoQuality }) : { ...config, model: selectedModel, imageModel: selectedModel, size: ratio, quality, imageResolution, count }), + [config, count, imageResolution, kind, quality, ratio, seconds, selectedModel, videoQuality], ); useEffect(() => { @@ -281,10 +282,12 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { const normalized = normalizeImageValue(imageProfile, { size: saved.image?.ratio || imageProfile.size.default, quality: saved.image?.quality || imageProfile.quality.default, + imageResolution: saved.image?.imageResolution || imageProfile.resolution.default, count: saved.image?.count || "1", }); setRatio(normalized.size); setQuality(normalized.quality); + setImageResolution(normalized.imageResolution || imageProfile.resolution.default || "auto"); setCount(normalized.count); } else { const normalized = normalizeVideoValue(videoProfile, { @@ -297,7 +300,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { setVideoQuality(normalized.resolution.replace(/p$/i, "")); } setSettingsReady(true); - }, [imageProfile, kind, preferencesHydrated, settingsReady, videoProfile]); + }, [imageProfile, kind, preferencesHydrated, selectedModel, settingsReady, videoProfile]); useEffect(() => { if (!settingsReady || kind !== "image" || !imageProfile) return; @@ -305,10 +308,12 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { const normalized = normalizeImageValue(imageProfile, { size: saved?.ratio || imageProfile.size.default, quality: saved?.quality || imageProfile.quality.default, + imageResolution: saved?.imageResolution || imageProfile.resolution.default, count: saved?.count || count, }); setRatio(normalized.size); setQuality(normalized.quality); + setImageResolution(normalized.imageResolution || imageProfile.resolution.default || "auto"); setCount(normalized.count); }, [imageProfile, kind, selectedModel, settingsReady]); @@ -335,9 +340,9 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { audioCount: references.filter((reference) => reference.kind === "audio").length, characterCount: 0, }, - ...(kind === "image" ? { imageSize: ratio, options: { size: ratio, quality, count: selectedCount } } : { videoSeconds: seconds, options: { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality } }), + ...(kind === "image" ? { imageSize: ratio, options: { size: ratio, quality, ...(imageProfile?.resolution.supported ? { imageResolution } : {}), count: selectedCount } } : { videoSeconds: seconds, options: { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality } }), }), - [capability, kind, prompt, quality, ratio, references, seconds, selectedCount, videoQuality], + [capability, imageProfile, imageResolution, kind, prompt, quality, ratio, references, seconds, selectedCount, videoQuality], ); useEffect(() => { @@ -447,6 +452,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { setCount("1"); setRatio(kind === "image" ? imageProfile?.size.default || "1:1" : videoProfile?.defaultRatio || "16:9"); setQuality(imageProfile?.quality.default || "auto"); + setImageResolution(imageProfile?.resolution.default || "auto"); setSeconds(String(videoProfile?.duration.default || 6)); setVideoQuality((videoProfile?.defaultResolution || "720p").replace(/p$/i, "")); const params = new URLSearchParams(searchParams); @@ -496,13 +502,17 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { setPresetPending(null); }; - const updateImageSettings = (size: string, nextQuality?: string) => { + const updateImageSettings = (size: string, nextQuality?: string, nextResolution?: string) => { setRatio(size); rememberImageSettings({ ratio: size }); if (nextQuality) { setQuality(nextQuality); rememberImageSettings({ quality: nextQuality }); } + if (nextResolution) { + setImageResolution(nextResolution); + rememberImageSettings({ imageResolution: nextResolution }); + } }; const addFiles = async (files: FileList | File[], targetSlotKey = uploadSlotKey) => { @@ -582,10 +592,10 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { } setBusy(true); try { - const imageRequest = kind === "image" && imageProfile ? normalizeImageValue(imageProfile, { size: ratio, quality, count, transparentBackground: config.transparentBackground }) : null; + const imageRequest = kind === "image" && imageProfile ? normalizeImageValue(imageProfile, { size: ratio, quality, imageResolution, imageParameters: config.imageParameters, count, transparentBackground: config.transparentBackground }) : null; const videoRequest = kind === "video" && videoProfile ? normalizeVideoValue(videoProfile, { seconds, ratio, resolution: videoQuality }) : null; const parameters = imageRequest - ? { size: imageRequest.size, quality: imageRequest.quality, count: Number(imageRequest.count) } + ? { size: imageRequest.size, quality: imageRequest.quality, imageResolution: imageRequest.imageResolution, count: Number(imageRequest.count) } : { seconds: Number(videoRequest?.seconds || seconds), ratio: videoRequest?.ratio || ratio, @@ -596,7 +606,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { const generationConfig = { ...composerConfig, model: selectedModel, - ...(imageRequest ? { imageModel: selectedModel, size: imageRequest.size, quality: imageRequest.quality, count: imageRequest.count } : {}), + ...(imageRequest ? { imageModel: selectedModel, size: imageRequest.size, quality: imageRequest.quality, imageResolution: imageRequest.imageResolution, count: imageRequest.count } : {}), ...(videoRequest ? { videoModel: selectedModel, size: videoRequest.ratio, videoSeconds: videoRequest.seconds, vquality: videoRequest.resolution.replace(/p$/i, "") } : {}), }; const readyReferences = references.filter((reference) => reference.resourceId); @@ -698,11 +708,13 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { if (kind === "image") { const restoredRatio = typeof parameters.size === "string" ? parameters.size : ratio; const restoredQuality = typeof parameters.quality === "string" ? parameters.quality : quality; + const restoredResolution = typeof parameters.imageResolution === "string" ? parameters.imageResolution : imageResolution; const restoredCount = parameters.count != null ? String(parameters.count) : count; setRatio(restoredRatio); setQuality(restoredQuality); + setImageResolution(restoredResolution); setCount(restoredCount); - rememberImageSettings({ ratio: restoredRatio, quality: restoredQuality, count: restoredCount }); + rememberImageSettings({ ratio: restoredRatio, quality: restoredQuality, imageResolution: restoredResolution, count: restoredCount }); } else { const restoredSeconds = parameters.seconds != null ? String(parameters.seconds) : seconds; const restoredRatio = typeof parameters.ratio === "string" ? parameters.ratio : ratio; @@ -812,7 +824,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) { const settingsPanel = kind === "image" && imageProfile ? (