fix:修复图像参数映射

This commit is contained in:
qian committed 2026-06-13 09:27:05 +08:00
1 parent 6809081f37
commit 8f3fe333c7
4 files changed
+416 -39

No files matched your search

+33 -7
View File
@@ -20,6 +20,18 @@ func convertImageRequestToChat(requestBody []byte, info *common.RelayInfo) (io.R
return nil, fmt.Errorf("parse image request: %w", err)
}
generationConfig := &dto.GeminiGenerationConfig{
ResponseModalities: []string{"TEXT", "IMAGE"},
}
// 将 OpenAI 的 size 参数映射为 Gemini 的 imageConfig(宽高比 + 分辨率)
if imgReq.Size != "" || imgReq.Quality != "" {
generationConfig.ImageConfig = &dto.GeminiImageConfig{
AspectRatio: imageSizeToAspectRatio(imgReq.Size),
ImageSize: qualityToImageSize(imgReq.Quality),
}
}
chatReq := dto.GeminiChatRequest{
Contents: []dto.GeminiContent{
{
@@ -29,9 +41,7 @@ func convertImageRequestToChat(requestBody []byte, info *common.RelayInfo) (io.R
},
},
},
GenerationConfig: &dto.GeminiGenerationConfig{
ResponseModalities: []string{"TEXT", "IMAGE"},
},
GenerationConfig: generationConfig,
}
result, err := json.Marshal(chatReq)
@@ -177,8 +187,13 @@ func handleImagenResponse(_ context.Context, resp *http.Response, info *common.R
return &common.Usage{}, nil
}
// imageSizeToAspectRatio 将 OpenAI 图片尺寸映射为 Imagen 宽高比
// imageSizeToAspectRatio 将图片尺寸映射为 Gemini 宽高比
// 支持 OpenAI 格式(1024x1024)和 Gemini 原生比例格式(1:1/16:9 等)直接透传
func imageSizeToAspectRatio(size string) string {
if size == "" {
return ""
}
// OpenAI 格式映射
switch size {
case "1024x1024":
return "1:1"
@@ -198,7 +213,7 @@ func imageSizeToAspectRatio(size string) string {
return "21:9"
}
// 支持原生比例格式(如 "1:1"、"16:9")
// Gemini 原生比例格式(如 "1:1"、"16:9"、"3:4")直接透传
if strings.Contains(size, ":") {
return size
}
@@ -206,9 +221,20 @@ func imageSizeToAspectRatio(size string) string {
return "1:1"
}
// qualityToImageSize 将 OpenAI quality 映射为 Imagen imageSize
// qualityToImageSize 将 OpenAI quality 映射为 Gemini imageSize
// 支持 OpenAI 格式(hd/standard)和 Gemini 原生格式(256/512/1K/2K/4K)直接透传
func qualityToImageSize(quality string) string {
switch strings.ToLower(quality) {
if quality == "" {
return ""
}
// Gemini 原生格式直接透传(256/512/1K/2K/4K)
lower := strings.ToLower(quality)
switch lower {
case "256", "512", "1k", "2k", "4k":
return quality // 保持用户原始大小写
}
// OpenAI 兼容格式映射
switch lower {
case "hd", "high":
return "2K"
default:
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { ref, reactive, computed, watch } from 'vue'
import { createPlaygroundApi } from '@/utils/playgroundApi'
import { calculateCost } from './calculateCost'
import ParamRefModal from './ParamRefModal.vue'
import Icon from '@/components/common/Icon.vue'
import BaseSelect from '../../../components/common/BaseSelect.vue'
@@ -22,21 +23,106 @@ const selectedModelItem = computed(() =>
props.models.find(m => m.model_id === selectedModel.value),
)
const prompt = ref('')
const size = ref('1920x1080')
const quality = ref('standard')
const modelOptions = computed(() => props.models.map(m => ({ value: m.model_id, label: m.model_name || m.model_id })))
const sizePresets = [
{ value: '1280x720', label: '720p' },
{ value: '1920x1080', label: '1080p' },
{ value: '2560x1440', label: '2K' },
{ value: '3840x2160', label: '4K' },
]
// ── 自定义参数系统 ──
function applySizePreset(val: string) {
size.value = val
interface CustomParam {
id: number
key: string
value: string
}
const customParams = ref<CustomParam[]>([])
let nextParamId = 1
const showParamRef = ref(false)
// 模型预设模板
const MODEL_PRESETS: Record<string, Record<string, string>> = {
'gpt-image-1': {
size: '1024x1024',
quality: 'auto',
response_format: 'b64_json',
output_format: 'png',
},
'dall-e-3': {
size: '1024x1024',
quality: 'standard',
style: 'vivid',
response_format: 'url',
},
'dall-e-2': {
size: '1024x1024',
response_format: 'url',
},
}
// 当前模型的预设(支持子串匹配,如 openai/gpt-image-1)
const currentPreset = computed(() => {
const id = selectedModel.value
if (MODEL_PRESETS[id]) return MODEL_PRESETS[id]
for (const key of Object.keys(MODEL_PRESETS)) {
if (id.includes(key)) return MODEL_PRESETS[key]
}
return null
})
// 预设标签名(用于按钮显示)
const currentPresetLabel = computed(() => {
const id = selectedModel.value
for (const key of Object.keys(MODEL_PRESETS)) {
if (id === key || id.includes(key)) {
// 美化显示:gpt-image-1 → GPT Image 1
return key.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
}
return ''
})
// 有效参数数量
const validParamsCount = computed(() =>
customParams.value.filter(p => p.key.trim() && p.value.trim()).length,
)
function addParam(key = '', value = '') {
customParams.value.push({ id: nextParamId++, key, value })
}
function removeParam(id: number) {
const idx = customParams.value.findIndex(p => p.id === id)
if (idx !== -1) customParams.value.splice(idx, 1)
}
function applyPreset() {
const preset = currentPreset.value
if (!preset) return
customParams.value = []
for (const [key, value] of Object.entries(preset)) {
addParam(key, value)
}
}
// 智能类型推断:字符串 → boolean / number / string
function parseValue(raw: string): string | number | boolean {
if (raw.toLowerCase() === 'true') return true
if (raw.toLowerCase() === 'false') return false
if (/^-?\d+(\.\d+)?$/.test(raw)) {
const num = Number(raw)
if (!isNaN(num) && isFinite(num)) return num
}
return raw
}
// 切换模型时自动应用预设
watch(selectedModel, () => {
const preset = currentPreset.value
if (preset) {
applyPreset()
}
}, { immediate: true })
// ── 图片生成 ──
interface ImageResult { b64_json?: string; url?: string; revised_prompt?: string }
const images = ref<ImageResult[]>([])
const tokenUsage = reactive({ promptTokens: 0, totalTokens: 0, cost: '' })
@@ -48,12 +134,21 @@ async function generate() {
try {
const api = createPlaygroundApi(props.apiKey)
const res = await api.post('/v1/images/generations', {
// 组装请求体:固定字段 + 自定义参数
const body: Record<string, any> = {
model: selectedModel.value,
prompt: prompt.value,
size: size.value,
quality: quality.value,
}, { timeout: 300_000 })
}
for (const param of customParams.value) {
const key = param.key.trim()
const val = param.value.trim()
if (key && val) {
body[key] = parseValue(val)
}
}
const res = await api.post('/v1/images/generations', body, { timeout: 300_000 })
const data = res.data
images.value = data.data || []
@@ -96,35 +191,95 @@ function downloadImage(img: ImageResult, idx: number) {
<h3 class="text-sm font-semibold text-gray-900">图片生成参数</h3>
</div>
<div class="card-body space-y-4">
<!-- 模型 -->
<div>
<label class="input-label">模型</label>
<BaseSelect v-model="selectedModel" :options="modelOptions" />
</div>
<!-- 提示词 -->
<div>
<label class="input-label">提示词</label>
<textarea v-model="prompt" class="input" rows="4" placeholder="描述你想生成的图片..." />
</div>
<div>
<label class="input-label">尺寸</label>
<input v-model="size" class="input" placeholder="如 1920x1080" />
<div class="flex flex-wrap gap-1.5 mt-2">
<button
v-for="p in sizePresets"
:key="p.value"
class="text-xs px-2 py-1 rounded-lg border transition-colors duration-150 cursor-pointer"
:class="size === p.value
? 'bg-primary-50 border-primary-300 text-primary-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-primary-200 hover:text-primary-600'"
@click="applySizePreset(p.value)"
<!-- 自定义参数 -->
<div class="border-t border-gray-100 pt-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<h4 class="text-sm font-semibold text-gray-900">自定义参数</h4>
<span v-if="validParamsCount > 0" class="badge badge-primary">{{ validParamsCount }}</span>
</div>
<div class="flex items-center gap-1.5">
<button
class="text-xs px-2.5 py-1.5 rounded-lg text-gray-600 hover:border-primary-300 hover:text-primary-600 transition-colors duration-150 cursor-pointer flex items-center gap-1.5 border border-gray-200"
@click="showParamRef = true"
>
<Icon name="bookOpen" size="xs" />
参数参考
</button>
<button
v-if="currentPreset && customParams.length > 0"
class="text-xs px-2 py-1 rounded-lg text-primary-600 hover:bg-primary-50 transition-colors duration-150 cursor-pointer"
@click="applyPreset"
>
重置预设
</button>
<button
class="btn btn-sm btn-secondary"
@click="addParam()"
>
<Icon name="plus" size="xs" />
添加
</button>
</div>
</div>
<!-- 空状态 -->
<div
v-if="customParams.length === 0"
class="rounded-xl border-2 border-dashed border-gray-200 py-6 text-center cursor-pointer hover:border-primary-300 hover:bg-primary-50/30 transition-colors duration-200"
@click="currentPreset ? applyPreset() : addParam()"
>
<Icon name="plus" size="sm" class="text-gray-300 mx-auto mb-2" />
<p class="text-xs text-gray-400">
{{ currentPreset ? '点击应用 ' + currentPresetLabel + ' 预设' : '点击添加自定义参数' }}
</p>
</div>
<!-- 参数行 -->
<div v-else class="space-y-2">
<div
v-for="param in customParams"
:key="param.id"
class="flex items-center gap-2 group"
>
{{ p.label }}
<input
v-model="param.key"
class="input flex-1"
placeholder="参数名"
/>
<input
v-model="param.value"
class="input flex-1"
placeholder="值"
/>
<button
class="h-9 w-9 rounded-xl flex items-center justify-center text-gray-300 hover:text-red-500 hover:bg-red-50 transition-colors duration-150 flex-shrink-0 cursor-pointer"
@click="removeParam(param.id)"
>
<Icon name="x" size="sm" />
</button>
</div>
<button
class="w-full text-xs py-1.5 rounded-lg border border-dashed border-gray-200 text-gray-400 hover:border-primary-300 hover:text-primary-600 transition-colors duration-150 cursor-pointer"
@click="addParam()"
>
+ 添加参数
</button>
</div>
</div>
<div>
<label class="input-label">质量</label>
<BaseSelect v-model="quality" :options="[{value:'standard',label:'标准'},{value:'hd',label:'高清'}]" />
</div>
<button class="btn btn-primary w-full" :disabled="sending || !prompt.trim()" @click="generate">
{{ sending ? '生成中...' : '生成图片' }}
</button>
@@ -169,4 +324,7 @@ function downloadImage(img: ImageResult, idx: number) {
</div>
</div>
</div>
<!-- 参数参考弹窗 -->
<ParamRefModal :show="showParamRef" mode="image" @close="showParamRef = false" />
</template>
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import Icon from '@/components/common/Icon.vue'
export interface ParamDef {
name: string
type: string
required: boolean
default?: string
values?: string[]
description: string
}
export interface ModelParamGroup {
key: string
label: string
params: ParamDef[]
}
const props = defineProps<{
show: boolean
mode: 'image' | 'video'
}>()
const emit = defineEmits<{
close: []
}>()
const activeGroup = ref('')
// ── 图像模型参数参考 ──
const imageGroups: ModelParamGroup[] = [
{
key: 'gemini-image',
label: 'Gemini',
params: [
{ name: 'prompt', type: 'string', required: true, description: '图像描述文本,支持中英文,可描述画面内容、风格、构图等' },
{ name: 'model', type: 'string', required: true, description: '模型 ID,如 gemini-2.5-flash-image、gemini-3-pro-image-preview' },
{ name: 'n', type: 'integer', required: false, default: '1', values: ['1'], description: '生成图像数量,目前仅支持 1' },
{ name: 'size', type: 'string', required: false, default: '1024x1024', values: ['1024x1024', '1536x1024', '1024x1536'], description: '图像尺寸(宽x高)。支持正方形和横/纵向' },
{ name: 'response_format', type: 'string', required: false, default: 'b64_json', values: ['b64_json', 'url'], description: '返回格式。b64_json 返回 Base64 编码;url 返回临时下载链接' },
],
},
{
key: 'gpt-image',
label: 'GPT Image',
params: [
{ name: 'prompt', type: 'string', required: true, description: '图像描述文本,最长 32000 字符' },
{ name: 'model', type: 'string', required: true, description: '模型 ID:gpt-image-1、gpt-image-1-mini、gpt-image-1.5、gpt-image-2' },
{ name: 'n', type: 'integer', required: false, default: '1', values: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'], description: '生成图像数量,1-10' },
{ name: 'size', type: 'string', required: false, default: '1024x1024', values: ['1024x1024', '1536x1024', '1024x1536', 'auto'], description: '图像尺寸。gpt-image-2 支持灵活分辨率(最大边 ≤ 3840px,16 的倍数)' },
{ name: 'quality', type: 'string', required: false, default: 'auto', values: ['auto', 'high', 'medium', 'low'], description: '图像质量' },
{ name: 'response_format', type: 'string', required: false, default: 'b64_json', values: ['b64_json', 'url'], description: '返回格式' },
{ name: 'background', type: 'string', required: false, default: 'auto', values: ['auto', 'opaque', 'transparent'], description: '背景透明度。仅支持 PNG/WebP。gpt-image-2 不支持 transparent' },
{ name: 'output_format', type: 'string', required: false, default: 'png', values: ['png', 'jpeg', 'webp'], description: '输出图像格式' },
{ name: 'output_compression', type: 'integer', required: false, description: '输出压缩率 0-100,仅 jpeg/webp 有效。100 = 无压缩' },
{ name: 'moderation', type: 'string', required: false, default: 'auto', values: ['auto', 'low'], description: '内容审核严格度' },
{ name: 'partial_images', type: 'integer', required: false, default: '0', values: ['0', '1', '2', '3'], description: '流式中间图数量 0-3,设为非 0 自动启用流式' },
],
},
{
key: 'dall-e',
label: 'DALL-E',
params: [
{ name: 'prompt', type: 'string', required: true, description: '图像描述文本。dall-e-2 最长 1000 字符,dall-e-3 最长 4000 字符' },
{ name: 'model', type: 'string', required: true, description: '模型 ID:dall-e-2 或 dall-e-3' },
{ name: 'n', type: 'integer', required: false, default: '1', values: ['1'], description: '生成数量,目前仅支持 1' },
{ name: 'size', type: 'string', required: false, default: '1024x1024', values: ['256x256', '512x512', '1024x1024', '1024x1792', '1792x1024'], description: 'dall-e-2: 256/512/1024 正方形;dall-e-3: 1024 正方形 + 1792 横纵' },
{ name: 'quality', type: 'string', required: false, default: 'standard', values: ['standard', 'hd'], description: '仅 dall-e-3 支持。standard 标准,hd 高清' },
{ name: 'response_format', type: 'string', required: false, default: 'url', values: ['url', 'b64_json'], description: '返回格式' },
{ name: 'style', type: 'string', required: false, default: 'vivid', values: ['vivid', 'natural'], description: '仅 dall-e-3 支持。vivid 生动,natural 自然' },
],
},
]
// ── 视频模型参数参考(预留) ──
const videoGroups: ModelParamGroup[] = [
{
key: 'gemini-video',
label: 'Gemini Veo',
params: [
{ name: 'prompt', type: 'string', required: true, description: '视频描述文本' },
{ name: 'model', type: 'string', required: true, description: '模型 ID,如 veo-3.1-generate-preview' },
{ name: 'resolution', type: 'string', required: false, default: '1080p', values: ['720p', '1080p', '4K'], description: '输出分辨率' },
{ name: 'duration_seconds', type: 'integer', required: false, values: ['4', '6', '8'], description: '视频时长(秒)' },
{ name: 'aspect_ratio', type: 'string', required: false, description: '视频宽高比' },
{ name: 'negative_prompt', type: 'string', required: false, description: '排除的内容描述' },
{ name: 'style', type: 'string', required: false, values: ['cinematic', 'creative'], description: '视觉风格' },
{ name: 'seed', type: 'integer', required: false, description: '确定性生成的种子值' },
],
},
]
const groups = computed(() => props.mode === 'image' ? imageGroups : videoGroups)
// 自动选中第一个 group
watch(() => props.show, (val) => {
if (val && !activeGroup.value) {
activeGroup.value = groups.value[0]?.key || ''
}
})
watch(groups, (val) => {
if (val.length > 0 && !val.find(g => g.key === activeGroup.value)) {
activeGroup.value = val[0].key
}
})
const currentGroup = computed(() => groups.value.find(g => g.key === activeGroup.value))
</script>
<template>
<BaseModal :show="show" title="参数参考" width="extra-wide" @close="emit('close')">
<!-- 模型 Tab 栏 -->
<div class="tabs mb-4">
<button
v-for="g in groups"
:key="g.key"
class="tab"
:class="{ 'tab-active': activeGroup === g.key }"
@click="activeGroup = g.key"
>
{{ g.label }}
</button>
</div>
<!-- 参数表格 -->
<div v-if="currentGroup" class="table-container">
<table class="table">
<thead>
<tr>
<th class="w-36">参数名</th>
<th class="w-20">类型</th>
<th class="w-14">必填</th>
<th class="w-24">默认值</th>
<th>可选值</th>
<th>说明</th>
</tr>
</thead>
<tbody>
<tr v-for="p in currentGroup.params" :key="p.name">
<td>
<code class="text-xs font-mono text-primary-600 bg-primary-50 px-1.5 py-0.5 rounded">{{ p.name }}</code>
</td>
<td class="text-xs text-gray-500">{{ p.type }}</td>
<td>
<span v-if="p.required" class="text-xs font-medium text-red-500">是</span>
<span v-else class="text-xs text-gray-400">否</span>
</td>
<td class="text-xs text-gray-500">{{ p.default || '—' }}</td>
<td>
<template v-if="p.values?.length">
<div class="flex flex-wrap gap-1">
<span v-for="v in p.values" :key="v" class="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded">{{ v }}</span>
</div>
</template>
<span v-else class="text-xs text-gray-400">—</span>
</td>
<td class="text-xs text-gray-600">{{ p.description }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 提示信息 -->
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50/60 px-4 py-3">
<div class="flex items-start gap-2">
<Icon name="infoCircle" size="sm" class="text-amber-500 mt-0.5 flex-shrink-0" />
<div class="text-xs text-amber-700 space-y-1">
<p>以上参数通过 <code class="bg-amber-100 px-1 rounded">/v1/images/generations</code> 端点发送,使用自定义参数区域逐行添加。</p>
<p>参数名为 JSON 键名,值为对应格式的字符串(系统会自动识别布尔值和数字)。</p>
</div>
</div>
</div>
</BaseModal>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { createPlaygroundApi } from '@/utils/playgroundApi'
import ParamRefModal from './ParamRefModal.vue'
import Icon from '@/components/common/Icon.vue'
import BaseSelect from '../../../components/common/BaseSelect.vue'
@@ -14,6 +15,7 @@ const props = defineProps<{ models: ModelItem[]; apiKey: string }>()
const submitting = ref(false)
const selectedModel = ref(props.models[0]?.model_id || '')
const prompt = ref('')
const showParamRef = ref(false)
const resolution = ref('1280x720')
const duration = ref(5)
@@ -212,6 +214,15 @@ function downloadVideo() {
<label class="input-label">时长({{ duration }} 秒)</label>
<input v-model.number="duration" type="number" class="input" min="5" max="15" step="1" />
</div>
<div class="flex justify-end">
<button
class="text-xs px-2.5 py-1.5 rounded-lg text-gray-600 hover:border-primary-300 hover:text-primary-600 transition-colors duration-150 cursor-pointer flex items-center gap-1.5 border border-gray-200"
@click="showParamRef = true"
>
<Icon name="bookOpen" size="xs" />
参数参考
</button>
</div>
<button class="btn btn-primary w-full" :disabled="submitting || polling || !prompt.trim()" @click="submitTask">
{{ submitting ? '提交中...' : polling ? '生成中...' : '生成视频' }}
</button>
@@ -292,4 +303,7 @@ function downloadVideo() {
</div>
</div>
</div>
<!-- 参数参考弹窗 -->
<ParamRefModal :show="showParamRef" mode="video" @close="showParamRef = false" />
</template>