fix: grok-image 补齐 aspect_ratio 与 resolution 传参 (#184)

* 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 时标题显示「分辨率」。
This commit is contained in:
Andrew
2026-08-08 18:27:36 +08:00
committed by GitHub
parent 99cc880eb8
commit 1b0fd7fd49
8 changed files with 164 additions and 15 deletions

View File

@@ -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}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 ? <div className="text-base font-semibold">图像设置</div> : null}
{profile.quality.supported ? <div className="space-y-2">
<SettingTitle color={theme.node.muted}>质量</SettingTitle>
<div className="grid grid-cols-4 gap-1.5">
<SettingTitle color={theme.node.muted}>{isGrokResolutionQuality(profile) ? "分辨率" : "质量"}</SettingTitle>
<div className={`grid gap-1.5 ${activeQualityOptions.length <= 2 ? "grid-cols-2" : "grid-cols-4"}`}>
{activeQualityOptions.map((item) => (
<OptionPill key={item.value} selected={quality === item.value} theme={theme} onClick={() => 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<string, string>)[value] || "默认";
return ({ auto: "自动", high: "高", medium: "中", low: "低", "1k": "1K", "2k": "2K" } as Record<string, string>)[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) {

View File

@@ -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 };

View File

@@ -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 = <div className="creation-parameter-menu">
{props.mode === "video" || props.imageProfile.size.parameter !== "none" ? <SettingSection title="画幅" value={props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratios.map((value) => <button key={value} type="button" aria-pressed={value === props.ratio} className={value === props.ratio ? "is-selected" : ""} onClick={() => { props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>)}</div>{props.mode !== "video" && props.imageProfile.size.allowCustom && (customRatioOpen ? <label className="creation-custom-value"><span>宽 : 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义画幅,支持宽x高或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义比例</button>)}</div></SettingSection> : null}
{props.mode === "video" ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : <>
{props.imageProfile.quality.supported ? <SettingSection title="图片质量" value={qualityLabel}><div className="creation-choice-grid is-quality">{qualityOptions.filter((option) => props.imageProfile.quality.values.includes(option.value)).map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}
{props.imageProfile.quality.supported ? <SettingSection title={activeQualityOptions.some((item) => item.value === "1k" || item.value === "2k") ? "分辨率" : "图片质量"} value={qualityLabel}><div className="creation-choice-grid is-quality">{activeQualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection> : null}
{props.imageProfile.maxOutputs > 1 ? <SettingSection title="生成数量" value={`${props.count} 张`}><div className="creation-parameter-content"><div className="creation-choice-grid is-count">{countOptions.filter((option) => Number(option) <= props.imageProfile.maxOutputs).map((option) => <button key={option} type="button" aria-pressed={option === props.count} className={option === props.count ? "is-selected" : ""} onClick={() => props.setCount(option)}>{option}</button>)}</div><label className="creation-custom-value"><span>自定义</span><input inputMode="numeric" pattern="[0-9]*" value={props.count} onChange={(event) => props.setCount(String(Math.max(1, Math.min(props.imageProfile.maxOutputs, Number(event.target.value) || 1))))} aria-label={`生成数量,范围 1 到 ${props.imageProfile.maxOutputs}`} /><em>张</em></label></div></SettingSection> : null}
</>}
</div>;

View File

@@ -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<ImageApiResponse>(
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<ImageApiResponse>(
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,
);