feat(bom-costing): 支持可切换的抠图接口配置

This commit is contained in:
WuYongJie 2026-09-03 18:27:22 +08:00
parent 394927d843
commit 6b3760c6e7
7 changed files with 307 additions and 37 deletions

View File

@ -87,4 +87,59 @@ describe('AI 服务配置模型选择', () => {
model: 'gpt-5.6',
})
})
it('商品抠图配置可选择接口协议并随配置保存', async () => {
const fetchMock = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
success: true,
data: {
ok: true,
status: 200,
latency: 120,
models: [{ value: 'image-edit-v3', label: 'Image Edit V3' }],
endpointPath: '/models',
error: null,
},
}),
})
global.fetch = fetchMock as typeof fetch
const onSave = jest.fn()
render(
<ConfigForm
serviceType="image-background-removal"
onSave={onSave}
onCancel={jest.fn()}
saving={false}
/>,
)
fireEvent.change(screen.getByPlaceholderText('如:生产环境、测试 Key'), { target: { value: '通用抠图' } })
fireEvent.change(screen.getByPlaceholderText('https://ai.yydd.ai'), { target: { value: 'https://api.example.com/v1' } })
fireEvent.change(screen.getByPlaceholderText('extimg_xxx... / sk-...'), { target: { value: 'secret' } })
fireEvent.change(screen.getByRole('combobox', { name: '接口协议' }), {
target: { value: 'openai-compatible-image-edit' },
})
fireEvent.click(screen.getByRole('button', { name: '测试并获取模型' }))
const modelSelect = screen.getByRole('combobox', { name: '模型' })
await waitFor(() => expect(modelSelect).toBeEnabled())
fireEvent.change(modelSelect, { target: { value: 'image-edit-v3' } })
fireEvent.click(screen.getByRole('button', { name: '保存' }))
expect(fetchMock).toHaveBeenCalledWith('/api/admin/test-connection', expect.objectContaining({
body: JSON.stringify({
baseUrl: 'https://api.example.com/v1',
serviceType: 'image-background-removal',
apiMode: 'openai-compatible-image-edit',
}),
}))
expect(onSave).toHaveBeenCalledWith({
name: '通用抠图',
baseUrl: 'https://api.example.com/v1',
apiKey: 'secret',
model: 'image-edit-v3',
extra: { apiMode: 'openai-compatible-image-edit' },
})
})
})

View File

@ -36,6 +36,7 @@ interface ProviderConfig {
baseUrl: string
apiKey: string
model: string | null
extra: Record<string, unknown> | null
isActive: boolean
createdAt: string
}
@ -45,6 +46,27 @@ type ActiveTab = 'ai-render' | 'prompt-optimizer' | 'image-background-removal'
interface TestResult { status: 'idle' | 'testing' | 'success' | 'error'; message: string }
interface ModelOption { value: string; label: string }
type BackgroundRemovalApiMode = 'external-image-gateway' | 'openai-compatible-image-edit'
const BACKGROUND_REMOVAL_API_MODE_META: Record<BackgroundRemovalApiMode, { label: string; desc: string }> = {
'openai-compatible-image-edit': {
label: 'OpenAI 兼容图片编辑',
desc: '使用配置地址下的 /images/edits,适合兼容 OpenAI 图片编辑协议的服务。',
},
'external-image-gateway': {
label: '图片生成网关',
desc: '使用 /api/external/image/generate,兼容当前图片网关。',
},
}
function readBackgroundRemovalApiMode(config?: ProviderConfig | null): BackgroundRemovalApiMode {
if (!config) return 'openai-compatible-image-edit'
const value = config?.extra?.apiMode
return value === 'openai-compatible-image-edit' || value === 'external-image-gateway'
? value
: 'external-image-gateway'
}
export const TAB_META: Record<ActiveTab, { label: string; icon: typeof Sparkles; desc: string; serviceType: string }> = {
'ai-render': { label: '生图服务', icon: Sparkles, desc: '辅助装配 AI 效果图的外部生图接口', serviceType: 'ai-render' },
'prompt-optimizer': { label: '提示词优化', icon: Wand2, desc: '用 AI 优化用户的粗糙描述为高质量生图 prompt', serviceType: 'prompt-optimizer' },
@ -77,6 +99,11 @@ function ConfigCard({ config, onActivate, onEdit, onDelete }: {
</div>
<p className="mt-1 truncate text-[10px] text-[#5a6a6d]">{config.baseUrl}</p>
{config.model && <p className="mt-0.5 text-[10px] text-[#6a7a7d]">模型:{config.model}</p>}
{config.serviceType === 'image-background-removal' && (
<p className="mt-0.5 text-[10px] text-[#6a7a7d]">
协议:{BACKGROUND_REMOVAL_API_MODE_META[readBackgroundRemovalApiMode(config)].label}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{!config.isActive && (
@ -101,7 +128,7 @@ function ConfigCard({ config, onActivate, onEdit, onDelete }: {
export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
config?: ProviderConfig | null
serviceType: string
onSave: (data: { name: string; baseUrl: string; apiKey: string; model: string }) => void
onSave: (data: { name: string; baseUrl: string; apiKey: string; model: string; extra?: Record<string, unknown> }) => void
onCancel: () => void
saving: boolean
}) {
@ -114,6 +141,9 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
const [testResult, setTestResult] = useState<TestResult>({ status: 'idle', message: '' })
const [models, setModels] = useState<ModelOption[]>([])
const [modelsLoaded, setModelsLoaded] = useState(false)
const [backgroundRemovalApiMode, setBackgroundRemovalApiMode] = useState<BackgroundRemovalApiMode>(
readBackgroundRemovalApiMode(config),
)
async function handleTest() {
setTesting(true)
@ -124,7 +154,11 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
const res = await fetch('/api/admin/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-test-api-key': apiKey },
body: JSON.stringify({ baseUrl, serviceType }),
body: JSON.stringify({
baseUrl,
serviceType,
...(serviceType === 'image-background-removal' ? { apiMode: backgroundRemovalApiMode } : {}),
}),
})
const data = await res.json()
if (data.success && data.data?.ok) {
@ -166,6 +200,29 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]">名称</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:生产环境、测试 Key" className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] placeholder:text-[#4a5a5d] focus:ring-[#47646b]/40" />
</div>
{serviceType === 'image-background-removal' && (
<div>
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]" htmlFor="background-removal-api-mode">接口协议</label>
<select
id="background-removal-api-mode"
aria-label="接口协议"
value={backgroundRemovalApiMode}
onChange={(event) => {
setBackgroundRemovalApiMode(event.target.value as BackgroundRemovalApiMode)
setModel('')
setModelsLoaded(false)
setModels([])
setTestResult({ status: 'idle', message: '' })
}}
className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] focus:ring-[#47646b]/40"
>
{(Object.entries(BACKGROUND_REMOVAL_API_MODE_META) as Array<[BackgroundRemovalApiMode, { label: string; desc: string }]>).map(([value, meta]) => (
<option key={value} value={value} className="bg-[#111719] text-[#edf4f2]">{meta.label}</option>
))}
</select>
<p className="mt-1 text-[10px] leading-4 text-[#5a6a6d]">{BACKGROUND_REMOVAL_API_MODE_META[backgroundRemovalApiMode].desc}</p>
</div>
)}
<div>
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]">服务地址</label>
<input value={baseUrl} onChange={(e) => { setBaseUrl(e.target.value); setModel(''); setModelsLoaded(false); setModels([]) }} placeholder="https://ai.yydd.ai" className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] placeholder:text-[#4a5a5d] focus:ring-[#47646b]/40" />
@ -221,7 +278,15 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
</Button>
<div className="flex-1" />
<Button variant="gray" size="small" onClick={onCancel} className="h-7 text-[10px]">取消</Button>
<Button size="small" onClick={() => onSave({ name: name.trim(), baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), model: model.trim() })} disabled={saving || !name.trim() || !baseUrl.trim() || !apiKey.trim() || !model.trim()} className="h-7 text-[10px]">
<Button size="small" onClick={() => onSave({
name: name.trim(),
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim(),
model: model.trim(),
...(serviceType === 'image-background-removal'
? { extra: { ...(config?.extra ?? {}), apiMode: backgroundRemovalApiMode } }
: {}),
})} disabled={saving || !name.trim() || !baseUrl.trim() || !apiKey.trim() || !model.trim()} className="h-7 text-[10px]">
<Save className="mr-1 h-3 w-3" /> {saving ? '保存中…' : '保存'}
</Button>
</div>
@ -258,7 +323,7 @@ export default function AiRenderConfigClient() {
if (canManage) fetchConfigs()
}, [canManage, fetchConfigs])
async function handleSave(formData: { name: string; baseUrl: string; apiKey: string; model: string }) {
async function handleSave(formData: { name: string; baseUrl: string; apiKey: string; model: string; extra?: Record<string, unknown> }) {
setSaving(true)
try {
const url = editing ? `/api/admin/provider-configs/${editing.id}` : '/api/admin/provider-configs'

View File

@ -7,6 +7,7 @@ import { providerModelCatalogService } from '@/server/services'
const schema = z.object({
baseUrl: z.string().url('地址格式不正确'),
serviceType: z.enum(['ai-render', 'prompt-optimizer', 'image-background-removal']),
apiMode: z.enum(['external-image-gateway', 'openai-compatible-image-edit']).optional(),
})
export async function POST(request: NextRequest) {
@ -28,6 +29,7 @@ export async function POST(request: NextRequest) {
baseUrl: parsed.data.baseUrl,
serviceType: parsed.data.serviceType,
apiKey,
apiMode: parsed.data.apiMode,
})
return jsonSuccess(result as unknown as Record<string, unknown>, 200, ctx.requestId)
},

View File

@ -164,6 +164,30 @@ describe('ProviderModelCatalogService', () => {
})
})
it('商品抠图可切换为 OpenAI 兼容模型目录', async () => {
const fetchMock = jest.fn().mockResolvedValue(createResponse(200, {
data: [{ id: 'image-edit-v3' }],
}))
global.fetch = fetchMock as typeof fetch
const result = await service.fetchCatalog({
serviceType: 'image-background-removal',
apiMode: 'openai-compatible-image-edit',
baseUrl: 'https://api.example.com/v1',
apiKey: 'secret',
})
expect(result).toMatchObject({
ok: true,
endpointPath: '/models',
models: [{ value: 'image-edit-v3', label: 'image-edit-v3' }],
})
expect(fetchMock).toHaveBeenCalledWith(
'https://api.example.com/v1/models',
expect.any(Object),
)
})
it('密钥无权限时返回明确错误且不继续尝试其他地址', async () => {
const fetchMock = jest.fn().mockResolvedValue(createResponse(401, {
error: { message: 'unauthorized' },

View File

@ -92,4 +92,55 @@ describe('BomCostingImageBackgroundRemovalService', () => {
const metadata = await sharp(first!.buffer).metadata()
expect(metadata).toMatchObject({ width: 1024, height: 1024, format: 'jpeg' })
})
it('uses the active OpenAI-compatible image-edit config without the image gateway', async () => {
const generatedPng = await sharp({
create: {
width: 24,
height: 36,
channels: 4,
background: { r: 90, g: 60, b: 30, alpha: 1 },
},
}).png().toBuffer()
const generate = jest.fn()
const fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: [{ b64_json: generatedPng.toString('base64') }],
}),
text: async () => '',
} as unknown as Response)
const config = {
...activeConfig(),
baseUrl: 'https://api.example.com/v1',
model: 'image-edit-v3',
extra: { apiMode: 'openai-compatible-image-edit' },
}
const service = new BomCostingImageBackgroundRemovalService({
getActiveConfig: jest.fn().mockResolvedValue(config),
generate,
fetch,
})
const result = await service.prepare({
buffer: Buffer.from('openai-compatible-source'),
contentType: 'image/png',
})
expect(generate).not.toHaveBeenCalled()
expect(fetch).toHaveBeenCalledTimes(1)
const [endpoint, requestInit] = fetch.mock.calls[0] as [string, RequestInit]
expect(endpoint).toBe('https://api.example.com/v1/images/edits')
expect(requestInit.headers).toMatchObject({ Authorization: 'Bearer secret' })
const formData = requestInit.body as FormData
expect(formData.get('model')).toBe('image-edit-v3')
expect(formData.get('response_format')).toBe('b64_json')
expect(result).toMatchObject({ model: 'image-edit-v3', contentType: 'image/jpeg' })
await expect(sharp(result!.buffer).metadata()).resolves.toMatchObject({
width: 1024,
height: 1024,
format: 'jpeg',
})
})
})

View File

@ -12,6 +12,10 @@ const BACKGROUND_REMOVAL_RESULT_TIMEOUT_MS = 45_000
const BACKGROUND_REMOVAL_MAX_RESULT_BYTES = 10 * 1024 * 1024
const BACKGROUND_REMOVAL_CACHE_LIMIT = 24
type BackgroundRemovalApiMode =
| 'external-image-gateway'
| 'openai-compatible-image-edit'
export const BOM_COSTING_BACKGROUND_REMOVAL_PROMPT = [
'Create a faithful product cutout from the reference image.',
'Keep exactly one complete furniture product and remove all promotional text, prices, badges, borders, people, props, room scenery, floor, and decorative background.',
@ -78,6 +82,24 @@ function buildCacheKey(
.digest('hex')
}
function getApiMode(config: NonNullable<ActiveBackgroundRemovalConfig>): BackgroundRemovalApiMode {
return config.extra?.apiMode === 'openai-compatible-image-edit'
? 'openai-compatible-image-edit'
: 'external-image-gateway'
}
function decodeBase64Image(value: string): Buffer {
const normalized = value.replace(/^data:image\/[a-zA-Z0-9.+-]+;base64,/, '').trim()
if (!normalized || normalized.length > Math.ceil(BACKGROUND_REMOVAL_MAX_RESULT_BYTES * 4 / 3) + 8) {
throw new Error('商品抠图结果为空或超过大小限制')
}
const buffer = Buffer.from(normalized, 'base64')
if (buffer.length === 0 || buffer.length > BACKGROUND_REMOVAL_MAX_RESULT_BYTES) {
throw new Error('商品抠图结果为空或超过大小限制')
}
return buffer
}
export class BomCostingImageBackgroundRemovalService {
private readonly cache = new Map<string, Buffer>()
@ -85,6 +107,57 @@ export class BomCostingImageBackgroundRemovalService {
private readonly dependencies: BomCostingBackgroundRemovalDependencies = defaultDependencies,
) {}
private async downloadGeneratedImage(url: string): Promise<Buffer> {
const safeUrl = parseSafeCompetitorUrl(url)
const response = await this.dependencies.fetch(safeUrl, {
method: 'GET',
headers: { Accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8' },
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.timeout(BACKGROUND_REMOVAL_RESULT_TIMEOUT_MS),
})
if (!response.ok) throw new Error(`商品抠图结果读取失败(${response.status})`)
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
if (!contentType.startsWith('image/')) throw new Error('商品抠图结果不是图片')
return readBoundedImage(response)
}
private async editWithOpenAiCompatibleApi(
config: NonNullable<ActiveBackgroundRemovalConfig>,
input: { buffer: Buffer; contentType: string },
): Promise<Buffer> {
const formData = new FormData()
formData.append('model', config.model!)
formData.append('prompt', BOM_COSTING_BACKGROUND_REMOVAL_PROMPT)
formData.append('size', '1024x1024')
formData.append('response_format', 'b64_json')
formData.append(
'image',
new Blob([new Uint8Array(input.buffer)], { type: input.contentType }),
sourceFileName(input.contentType),
)
const endpoint = `${config.baseUrl.replace(/\/+$/, '')}/images/edits`
const response = await this.dependencies.fetch(endpoint, {
method: 'POST',
headers: { Authorization: `Bearer ${config.apiKey}` },
body: formData,
cache: 'no-store',
signal: AbortSignal.timeout(BACKGROUND_REMOVAL_TIMEOUT_MS),
})
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(`商品抠图服务返回 ${response.status}: ${body.slice(0, 200)}`)
}
const payload = await response.json() as {
data?: Array<{ b64_json?: string; url?: string }>
}
const image = payload.data?.[0]
if (image?.b64_json) return decodeBase64Image(image.b64_json)
if (image?.url) return this.downloadGeneratedImage(image.url)
throw new Error('商品抠图服务没有返回可用图片')
}
async prepare(input: {
buffer: Buffer
contentType: string
@ -105,37 +178,28 @@ export class BomCostingImageBackgroundRemovalService {
}
}
const generated = await this.dependencies.generate({
serviceType: BACKGROUND_REMOVAL_SERVICE_TYPE,
prompt: BOM_COSTING_BACKGROUND_REMOVAL_PROMPT,
imageCount: 1,
aspectRatio: '1:1',
imageSize: '1K',
timeoutMs: BACKGROUND_REMOVAL_TIMEOUT_MS,
maxAttempts: 1,
referenceImage: {
buffer: input.buffer,
contentType: input.contentType,
fileName: sourceFileName(input.contentType),
},
disabledMessage: '商品抠图服务未启用或未配置',
})
const generatedUrl = generated.images.find((image) => image.originalUrl.trim())?.originalUrl
if (!generatedUrl) throw new Error('商品抠图服务没有返回可用图片')
const safeUrl = parseSafeCompetitorUrl(generatedUrl)
const response = await this.dependencies.fetch(safeUrl, {
method: 'GET',
headers: { Accept: 'image/avif,image/webp,image/png,image/jpeg,image/*;q=0.8' },
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.timeout(BACKGROUND_REMOVAL_RESULT_TIMEOUT_MS),
})
if (!response.ok) throw new Error(`商品抠图结果读取失败(${response.status})`)
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
if (!contentType.startsWith('image/')) throw new Error('商品抠图结果不是图片')
const generatedBuffer = await readBoundedImage(response)
const generatedBuffer = getApiMode(config) === 'openai-compatible-image-edit'
? await this.editWithOpenAiCompatibleApi(config, input)
: await (async () => {
const generated = await this.dependencies.generate({
serviceType: BACKGROUND_REMOVAL_SERVICE_TYPE,
prompt: BOM_COSTING_BACKGROUND_REMOVAL_PROMPT,
imageCount: 1,
aspectRatio: '1:1',
imageSize: '1K',
timeoutMs: BACKGROUND_REMOVAL_TIMEOUT_MS,
maxAttempts: 1,
referenceImage: {
buffer: input.buffer,
contentType: input.contentType,
fileName: sourceFileName(input.contentType),
},
disabledMessage: '商品抠图服务未启用或未配置',
})
const generatedUrl = generated.images.find((image) => image.originalUrl.trim())?.originalUrl
if (!generatedUrl) throw new Error('商品抠图服务没有返回可用图片')
return this.downloadGeneratedImage(generatedUrl)
})()
const normalized = await sharp(generatedBuffer)
.rotate()
.flatten({ background: '#ffffff' })

View File

@ -5,6 +5,10 @@ export type ProviderModelServiceType =
| 'prompt-optimizer'
| 'image-background-removal'
export type ImageBackgroundRemovalApiMode =
| 'external-image-gateway'
| 'openai-compatible-image-edit'
export interface ProviderModelOption {
value: string
label: string
@ -23,6 +27,7 @@ interface FetchProviderModelCatalogInput {
serviceType: ProviderModelServiceType
baseUrl: string
apiKey: string
apiMode?: ImageBackgroundRemovalApiMode
}
interface CatalogAttempt {
@ -128,8 +133,12 @@ function joinUrl(baseUrl: string, path: string): string {
function buildEndpointCandidates(
baseUrl: string,
serviceType: ProviderModelServiceType,
apiMode?: ImageBackgroundRemovalApiMode,
): Array<{ url: string; path: string }> {
if (serviceType === 'ai-render' || serviceType === 'image-background-removal') {
if (
serviceType === 'ai-render' ||
(serviceType === 'image-background-removal' && apiMode !== 'openai-compatible-image-edit')
) {
return [{
url: joinUrl(baseUrl, '/api/external/image/models'),
path: '/api/external/image/models',
@ -221,7 +230,7 @@ export class ProviderModelCatalogService {
async fetchCatalog(input: FetchProviderModelCatalogInput): Promise<ProviderModelCatalogResult> {
const baseUrl = normalizeBaseUrl(input.baseUrl)
const apiKey = normalizeRequiredText(input.apiKey, 'API 密钥')
const candidates = buildEndpointCandidates(baseUrl, input.serviceType)
const candidates = buildEndpointCandidates(baseUrl, input.serviceType, input.apiMode)
const startedAt = Date.now()
let lastAttempt: CatalogAttempt | null = null
let reachedProvider = false