From 554eb490001fae6bf392413ecc271a372a792792 Mon Sep 17 00:00:00 2001 From: Andrew Date: Sat, 8 Aug 2026 18:27:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20grok-image=20=E8=A1=A5=E9=BD=90=20aspect?= =?UTF-8?q?=5Fratio=20=E4=B8=8E=20resolution=20=E4=BC=A0=E5=8F=82=20(#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: grok-image 补齐 aspect_ratio 与 resolution 传参 - 能力配置开放比例与 1k/2k 分辨率 - 请求体映射 size→aspect_ratio、quality→resolution - 画布设置展示「分辨率」选项 - 补充单测 Closes #182 * fix(create): 创作页图片质量支持 1k/2k 分辨率选项 创作页原先硬编码 auto/low/medium/high,导致 grok 能力配置里的 1k/2k 无法显示;改为按模型 quality.values 动态渲染,有 1k/2k 时标题显示「分辨率」。 --- backend/internal/service/model_capability.go | 9 +-- backend/internal/service/provider.go | 67 ++++++++++++++++++- .../service/provider_request_types.go | 4 ++ backend/internal/service/provider_test.go | 34 ++++++++++ web/src/components/image-settings-panel.tsx | 13 +++- web/src/lib/model-capabilities.ts | 23 +++++-- web/src/pages/create/index.tsx | 8 ++- web/src/services/api/image.ts | 21 ++++++ 8 files changed, 164 insertions(+), 15 deletions(-) diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index bc7ae8e20..a67c6c6d9 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -109,8 +109,9 @@ func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapab case model.ChannelInterfaceGrokImage: image.References.MaxImages = 1 image.References.MaskSupported = false - image.Size = ImageSizeConfig{Parameter: "none", Values: []string{}, Default: "auto", AllowCustom: false} - image.Quality = ImageQualityConfig{Supported: false, Values: []string{}, Default: "auto"} + // grok2api / xAI Imagine:size→aspect_ratio,quality→resolution(1k/2k)。 + image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false} + image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false} image.ResponseFormat = ParameterSupport{Supported: true} image.OutputFormat = ParameterSupport{Supported: false} @@ -132,8 +133,8 @@ func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapab if model.ChannelInterfaceType(protocol) != model.ChannelInterfaceGrokImage && strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelName)), "grok-imagine-image") { image.References.MaxImages = 0 image.References.MaskSupported = false - image.Size = ImageSizeConfig{Parameter: "none", Values: []string{}, Default: "auto", AllowCustom: false} - image.Quality = ImageQualityConfig{Supported: false, Values: []string{}, Default: "auto"} + image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false} + image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false} image.ResponseFormat = ParameterSupport{Supported: true} image.OutputFormat = ParameterSupport{Supported: false} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index a2e54c031..19e5dd6fb 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -746,7 +746,15 @@ func grokImageRequestBody(input canvasGenerationInput) (grokImageRequest, string if input.Mask != nil { return grokImageRequest{}, "", errors.New("Grok 图片协议不支持蒙版编辑,请移除蒙版后重试") } - body := grokImageRequest{Model: input.Config.Model, Prompt: withSystemPrompt(input.Config, input.Prompt), N: 1, ResponseFormat: "url"} + body := grokImageRequest{ + Model: input.Config.Model, + Prompt: withSystemPrompt(input.Config, input.Prompt), + N: 1, + ResponseFormat: "url", + Size: strings.TrimSpace(input.Config.Size), + AspectRatio: normalizeGrokImageAspectRatio(input.Config.Size), + Resolution: normalizeGrokImageResolution(input.Config.Quality), + } if len(input.ReferenceImages) == 0 { return body, "/images/generations", nil } @@ -761,6 +769,63 @@ func grokImageRequestBody(input canvasGenerationInput) (grokImageRequest, string return body, "/images/edits", nil } +// normalizeGrokImageResolution 把画布 quality(1k/2k/high…)映射为 grok2api / xAI 的 resolution。 +func normalizeGrokImageResolution(quality string) string { + raw := strings.ToLower(strings.TrimSpace(quality)) + switch raw { + case "", "auto": + return "" + case "1k", "low", "standard": + return "1k" + case "2k", "medium", "hd", "high", "4k": + // xAI Imagine 图片通常最高 2k;超出则夹到 2k,避免上游拒参。 + return "2k" + default: + return "" + } +} + +// normalizeGrokImageAspectRatio 把画布 size(如 1280x720 / 9:16)转成 grok2api / xAI 接受的 aspect_ratio。 +func normalizeGrokImageAspectRatio(size string) string { + raw := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(size, "×", "x"))) + if raw == "" || raw == "auto" { + return "" + } + if strings.Contains(raw, ":") { + switch raw { + case "1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2", "9:19.5", "19.5:9", "1:2", "2:1": + return raw + } + } + parts := strings.Split(raw, "x") + if len(parts) != 2 { + return "" + } + w, wErr := strconv.Atoi(parts[0]) + h, hErr := strconv.Atoi(parts[1]) + if wErr != nil || hErr != nil || w <= 0 || h <= 0 { + return "" + } + if w == h { + return "1:1" + } + ratio := float64(w) / float64(h) + switch { + case w*9 == h*16 || (ratio >= 1.7 && ratio <= 1.8): + return "16:9" + case h*9 == w*16 || (ratio > 0 && ratio <= 1.0/1.7 && ratio >= 1.0/1.8): + return "9:16" + case w*3 == h*4 || (ratio > 1.2 && ratio < 1.4): + return "4:3" + case h*3 == w*4 || (ratio > 0.7 && ratio < 0.85): + return "3:4" + case w > h: + return "16:9" + default: + return "9:16" + } +} + func grokImageInputURL(media providerMedia) (string, error) { if isPublicMediaURL(strings.TrimSpace(media.URL)) { return strings.TrimSpace(media.URL), nil diff --git a/backend/internal/service/provider_request_types.go b/backend/internal/service/provider_request_types.go index 9956e95b9..c6be13f0c 100644 --- a/backend/internal/service/provider_request_types.go +++ b/backend/internal/service/provider_request_types.go @@ -58,6 +58,10 @@ type grokImageRequest struct { Image *grokImageInput `json:"image,omitempty"` N int `json:"n"` ResponseFormat string `json:"response_format"` + Size string `json:"size,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + // Resolution 对应 xAI / grok2api 的 resolution(常见 1k / 2k)。 + Resolution string `json:"resolution,omitempty"` } type grokImageInput struct { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 90bf341ce..321f4a5fd 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -196,6 +196,40 @@ func TestRunGrokImageTaskUsesJSONEditContract(t *testing.T) { } } +func TestGrokImageRequestBodyMapsAspectRatio(t *testing.T) { + body, path, err := grokImageRequestBody(canvasGenerationInput{ + Prompt: "a cat", + Config: providerConfig{Model: "grok-imagine-image", InterfaceType: "grok-image", Size: "9:16", Quality: "2k"}, + }) + if err != nil { + t.Fatalf("grokImageRequestBody() error = %v", err) + } + if path != "/images/generations" { + t.Fatalf("path = %q", path) + } + if body.AspectRatio != "9:16" || body.Size != "9:16" || body.Resolution != "2k" { + t.Fatalf("body = %#v", body) + } + if got := normalizeGrokImageAspectRatio("1280x720"); got != "16:9" { + t.Fatalf("normalize 1280x720 = %q", got) + } + if got := normalizeGrokImageAspectRatio("720x1280"); got != "9:16" { + t.Fatalf("normalize 720x1280 = %q", got) + } +} + +func TestNormalizeGrokImageResolution(t *testing.T) { + if got := normalizeGrokImageResolution("1k"); got != "1k" { + t.Fatalf("1k = %q", got) + } + if got := normalizeGrokImageResolution("high"); got != "2k" { + t.Fatalf("high = %q", got) + } + if got := normalizeGrokImageResolution("auto"); got != "" { + t.Fatalf("auto = %q", got) + } +} + func TestGrokImageRequestBodyRejectsMaskAndMultipleReferences(t *testing.T) { if _, _, err := grokImageRequestBody(canvasGenerationInput{Config: providerConfig{InterfaceType: "grok-image"}, Mask: &providerMedia{DataURL: testReferenceImageDataURL}}); err == nil || !strings.Contains(err.Error(), "不支持蒙版") { t.Fatalf("mask error = %v", err) diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index 032cdb697..0fb151a37 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -10,6 +10,8 @@ const qualityOptions = [ { value: "high", label: "高" }, { value: "medium", label: "中" }, { value: "low", label: "低" }, + { value: "1k", label: "1K" }, + { value: "2k", label: "2K" }, ]; const DIMENSION_STEP = 16; @@ -78,8 +80,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = > {showTitle ?
图像设置
: null} {profile.quality.supported ?
- 质量 -
+ {isGrokResolutionQuality(profile) ? "分辨率" : "质量"} +
{activeQualityOptions.map((item) => ( onConfigChange("quality", item.value)}> {item.label} @@ -181,7 +183,12 @@ export function ImageSettingsTheme({ theme, children }: { theme: CanvasTheme; ch } export function imageQualityLabel(value: string) { - return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record)[value] || "默认"; + return ({ auto: "自动", high: "高", medium: "中", low: "低", "1k": "1K", "2k": "2K" } as Record)[value] || value || "默认"; +} + +function isGrokResolutionQuality(profile: ImageCapabilityConfig) { + const values = profile.quality.values.map((item) => item.toLowerCase()); + return values.includes("1k") || values.includes("2k"); } export function imageSizeLabel(size: string) { diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 090bb41b2..5f847d18a 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -77,8 +77,14 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = " if (protocol === "grok-image") { image.references.maxImages = 1; image.references.maskSupported = false; - image.size = { parameter: "none", values: [], default: "auto", allowCustom: false }; - image.quality = { supported: false, values: [], default: "auto" }; + // grok2api / xAI Imagine:size→aspect_ratio,quality→resolution(1k/2k)。 + image.size = { + parameter: "aspect_ratio", + values: ["1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"], + default: "1:1", + allowCustom: false, + }; + image.quality = { supported: true, values: ["1k", "2k"], default: "2k" }; image.transparentBackground = { supported: false, default: false }; image.responseFormat = { supported: true }; image.outputFormat = { supported: false }; @@ -101,8 +107,13 @@ export function defaultImageCapabilityConfig(protocol?: ModelProtocol, model = " if (protocol !== "grok-image" && model.trim().toLowerCase().startsWith("grok-imagine-image")) { image.references.maxImages = 0; image.references.maskSupported = false; - image.size = { parameter: "none", values: [], default: "auto", allowCustom: false }; - image.quality = { supported: false, values: [], default: "auto" }; + image.size = { + parameter: "aspect_ratio", + values: ["1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"], + default: "1:1", + allowCustom: false, + }; + image.quality = { supported: true, values: ["1k", "2k"], default: "2k" }; image.transparentBackground = { supported: false, default: false }; image.responseFormat = { supported: true }; image.outputFormat = { supported: false }; @@ -166,7 +177,9 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string; export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) { const size = normalizeImageSizeSetting(profile, value.size); - const quality = profile.quality.supported && profile.quality.values.includes(value.quality || "") ? value.quality! : profile.quality.default || "auto"; + const quality = profile.quality.supported + ? (value.quality && profile.quality.values.includes(value.quality) ? value.quality : profile.quality.default || "auto") + : profile.quality.default || "auto"; const count = String(Math.max(1, Math.min(profile.maxOutputs, Math.floor(Math.abs(Number(value.count)) || 1)))); const transparentBackground = profile.transparentBackground.supported && value.transparentBackground === "true" ? "true" : "false"; return { size, quality, count, transparentBackground }; diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index a0ae970b6..93cb2ac26 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -61,6 +61,9 @@ const qualityOptions = [ { value: "low", label: "低", description: "更快生成" }, { value: "medium", label: "中", description: "均衡模式" }, { value: "high", label: "高", description: "优先细节" }, + // grok2api / xAI Imagine:quality 映射 resolution + { value: "1k", label: "1K", description: "标准清晰度" }, + { value: "2k", label: "2K", description: "更高清晰度" }, ]; const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) })); const countOptions = ["1", "2", "3", "4"]; @@ -777,7 +780,8 @@ function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange: function GenerationSettingsMenu(props: ComposerProps) { const [open, setOpen] = useState(false); const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio)); - const qualityLabel = qualityOptions.find((item) => item.value === props.quality)?.label || "自动"; + const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" }); + const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动"; const ratios = props.mode === "video" ? props.videoProfile.ratios : props.imageProfile.size.values.length ? props.imageProfile.size.values : ratioOptions.map((item) => item.value); const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions; const imageSummary = [ @@ -789,7 +793,7 @@ function GenerationSettingsMenu(props: ComposerProps) { const panel =
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ?
{ratios.map((value) => )}
{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? : )}
: null} {props.mode === "video" ?
{resolutions.map((option) => )}
: <> - {props.imageProfile.quality.supported ?
{qualityOptions.filter((option) => props.imageProfile.quality.values.includes(option.value)).map((option) => )}
: null} + {props.imageProfile.quality.supported ? item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}>
{activeQualityOptions.map((option) => )}
: null} {props.imageProfile.maxOutputs > 1 ?
{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => )}
: null} }
; diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index fe08b5fbd..a857c7cc9 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -124,6 +124,15 @@ function normalizeQuality(quality: string) { return QUALITY_BASE[normalized] ? normalized : undefined; } +/** grok2api / xAI Imagine:画布 quality 映射为 resolution(1k/2k)。 */ +function normalizeGrokImageResolution(quality: string | undefined) { + const value = (quality || "").trim().toLowerCase(); + if (!value || value === "auto") return undefined; + if (value === "1k" || value === "low" || value === "standard") return "1k"; + if (value === "2k" || value === "medium" || value === "hd" || value === "high" || value === "4k") return "2k"; + return undefined; +} + /** Map "quality + ratio" to an explicit pixel dimension like "3840x2160". */ function resolveSize(quality: string | undefined, ratio: string): string { const parsedRatio = parseImageRatio(ratio); @@ -814,6 +823,9 @@ export async function requestGeneration(config: AiConfig, prompt: string, option } if (requestConfig.interfaceType === "grok-image") { try { + const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined; + const aspectRatio = size?.includes(":") ? size : undefined; + const resolution = normalizeGrokImageResolution(normalizedImage.quality); const responseData = await postChannelJSON( requestConfig, aiApiUrl(requestConfig, "/images/generations"), @@ -822,6 +834,9 @@ export async function requestGeneration(config: AiConfig, prompt: string, option prompt: withSystemPrompt(requestConfig, prompt), n, response_format: "url", + ...(size ? { size } : {}), + ...(aspectRatio ? { aspect_ratio: aspectRatio } : {}), + ...(resolution ? { resolution } : {}), }, options, ); @@ -898,6 +913,9 @@ export async function requestEdit(config: AiConfig, prompt: string, references: if (references.length !== 1) throw new Error("Grok 图片编辑必须提供且仅支持 1 张参考图"); try { const imageUrl = await grokImageInputURL(references[0]); + const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined; + const aspectRatio = size?.includes(":") ? size : undefined; + const resolution = normalizeGrokImageResolution(normalizedImage.quality); const response = await postChannelJSON( requestConfig, aiApiUrl(requestConfig, "/images/edits"), @@ -907,6 +925,9 @@ export async function requestEdit(config: AiConfig, prompt: string, references: image: { url: imageUrl }, n, response_format: "url", + ...(size ? { size } : {}), + ...(aspectRatio ? { aspect_ratio: aspectRatio } : {}), + ...(resolution ? { resolution } : {}), }, options, );