feat(bom-costing): 接入商品抠图并完善图片浏览
This commit is contained in:
parent
33a13a8abb
commit
4ce03262c0
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ConfigForm } from './AiRenderConfigClient'
|
||||
import { ConfigForm, TAB_META } from './AiRenderConfigClient'
|
||||
|
||||
describe('AI 服务配置模型选择', () => {
|
||||
const originalFetch = global.fetch
|
||||
@ -9,6 +9,13 @@ describe('AI 服务配置模型选择', () => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('提供独立的商品抠图服务配置入口', () => {
|
||||
expect(TAB_META['image-background-removal']).toMatchObject({
|
||||
label: '商品抠图',
|
||||
serviceType: 'image-background-removal',
|
||||
})
|
||||
})
|
||||
|
||||
it('连接成功后只能从 API Key 可用模型列表中选择', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ScanLine,
|
||||
Plug,
|
||||
Save,
|
||||
X,
|
||||
@ -39,14 +40,20 @@ interface ProviderConfig {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type ActiveTab = 'ai-render' | 'prompt-optimizer'
|
||||
type ActiveTab = 'ai-render' | 'prompt-optimizer' | 'image-background-removal'
|
||||
|
||||
interface TestResult { status: 'idle' | 'testing' | 'success' | 'error'; message: string }
|
||||
interface ModelOption { value: string; label: string }
|
||||
|
||||
const TAB_META: Record<ActiveTab, { label: string; icon: typeof Sparkles; desc: string; serviceType: string }> = {
|
||||
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' },
|
||||
'image-background-removal': {
|
||||
label: '商品抠图',
|
||||
icon: ScanLine,
|
||||
desc: '为商品以图搜图生成去除促销文字和场景背景的纯白底主体图',
|
||||
serviceType: 'image-background-removal',
|
||||
},
|
||||
}
|
||||
|
||||
function ConfigCard({ config, onActivate, onEdit, onDelete }: {
|
||||
@ -322,7 +329,7 @@ export default function AiRenderConfigClient() {
|
||||
}
|
||||
rail={
|
||||
<ObsidianRailGroup title="服务">
|
||||
{(Object.entries(TAB_META) as Array<[ActiveTab, typeof TAB_META['ai-render']]>).map(([key, meta]) => (
|
||||
{(Object.entries(TAB_META) as Array<[ActiveTab, typeof TAB_META[ActiveTab]]>).map(([key, meta]) => (
|
||||
<ObsidianRailButton key={key} active={activeTab === key} onClick={() => { setActiveTab(key); setShowForm(false); setEditing(null) }} icon={meta.icon} label={meta.label} />
|
||||
))}
|
||||
</ObsidianRailGroup>
|
||||
|
||||
@ -4,8 +4,8 @@ import { RequireAuth } from '@/components/auth/RequireAuth'
|
||||
import AiRenderConfigClient from './AiRenderConfigClient'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI 生图配置 | 后台设置',
|
||||
description: '配置 AI 生图和提示词优化服务',
|
||||
title: 'AI 服务配置 | 后台设置',
|
||||
description: '配置 AI 生图、提示词优化和商品抠图服务',
|
||||
}
|
||||
|
||||
export default function AiRenderConfigPage() {
|
||||
|
||||
@ -6,7 +6,7 @@ import { providerModelCatalogService } from '@/server/services'
|
||||
|
||||
const schema = z.object({
|
||||
baseUrl: z.string().url('地址格式不正确'),
|
||||
serviceType: z.enum(['ai-render', 'prompt-optimizer']),
|
||||
serviceType: z.enum(['ai-render', 'prompt-optimizer', 'image-background-removal']),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
|
||||
@ -34,10 +34,11 @@ function detail({ costBreakdownAvailable = false, status = 'READY' }: {
|
||||
statusMessage: status === 'READY' ? '预估完成' : '正在生成预估',
|
||||
sourceCacheHit: false,
|
||||
sourceSnapshot: {
|
||||
status: 'READY', skuCount: 1, storedImageCount: 2, failedImageCount: 0,
|
||||
status: 'READY', skuCount: 1, storedImageCount: 3, failedImageCount: 0,
|
||||
assets: [
|
||||
{ id: 'asset-main', kind: 'MAIN', status: 'STORED', storedUrl: 'https://images.example.com/main.jpg', sortOrder: 0, externalSkuIds: [] },
|
||||
{ id: 'asset-detail', kind: 'DETAIL', status: 'STORED', storedUrl: 'https://images.example.com/detail.jpg', sortOrder: 1, externalSkuIds: [] },
|
||||
{ id: 'asset-sku', kind: 'SKU', status: 'STORED', storedUrl: 'https://images.example.com/sku.jpg', sortOrder: 1, externalSkuIds: ['EXT-1'] },
|
||||
{ id: 'asset-detail', kind: 'DETAIL', status: 'STORED', storedUrl: 'https://images.example.com/detail.jpg', sortOrder: 2, externalSkuIds: [] },
|
||||
],
|
||||
},
|
||||
requiresRecalculation: false, hasFailedRecalculation: false, needsPriceReview: false,
|
||||
@ -102,6 +103,23 @@ describe('BomCostingWorkbenchClient', () => {
|
||||
expect(screen.queryByText('成本构成')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('左侧图片按主图、当前 SKU 图和详情图分组切换', async () => {
|
||||
mockCaseResponse(detail())
|
||||
render(<BomCostingWorkbenchClient initialCapabilities={capabilities} initialCases={[summary()]} />)
|
||||
await screen.findByText('约 ¥1,350.00')
|
||||
|
||||
expect(screen.getByRole('tab', { name: '主图 1' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: '当前 SKU 图 1' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('tab', { name: '详情图 1' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: '黑胡桃色 120cm SKU 图' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '主图 1' }))
|
||||
expect(screen.getByRole('img', { name: '商品主图' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '详情图 1' }))
|
||||
expect(screen.getByRole('img', { name: '商品详情图 1' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('整件临时价的详情只展示结构事实,不展示另一套结构金额', async () => {
|
||||
mockCaseResponse(detail())
|
||||
render(<BomCostingWorkbenchClient initialCapabilities={capabilities} initialCases={[summary()]} />)
|
||||
|
||||
@ -28,6 +28,7 @@ import type {
|
||||
BomCostingCaseDetail,
|
||||
BomCostingCaseSummary,
|
||||
BomCostingSkuDetail,
|
||||
BomCostingSourceAssetDetail,
|
||||
} from '@/lib/bom-costing/client-types'
|
||||
import { getBomCostingCurrentTaskEvents } from './BomCostingAssetWorkspace'
|
||||
import {
|
||||
@ -51,6 +52,15 @@ type RecalculationMode =
|
||||
| 'REANALYZE_SELECTED'
|
||||
| 'REANALYZE_ALL'
|
||||
|
||||
type ProductImageKind = BomCostingSourceAssetDetail['kind']
|
||||
|
||||
const PRODUCT_IMAGE_KINDS: ProductImageKind[] = ['MAIN', 'SKU', 'DETAIL']
|
||||
const PRODUCT_IMAGE_KIND_LABELS: Record<ProductImageKind, string> = {
|
||||
MAIN: '主图',
|
||||
SKU: '当前 SKU 图',
|
||||
DETAIL: '详情图',
|
||||
}
|
||||
|
||||
interface RecalculationRequest {
|
||||
mode: RecalculationMode
|
||||
selectedAssetIds: string[]
|
||||
@ -460,14 +470,24 @@ function ResultCanvas({
|
||||
const skuAssets = sourceAssets.filter((asset) => (
|
||||
asset.kind === 'SKU' && asset.externalSkuIds.includes(selectedSku.externalSkuId)
|
||||
))
|
||||
const galleryAssets = [
|
||||
...skuAssets,
|
||||
...sourceAssets.filter((asset) => asset.kind === 'MAIN'),
|
||||
]
|
||||
const mainAssets = sourceAssets.filter((asset) => asset.kind === 'MAIN')
|
||||
const detailAssets = sourceAssets.filter((asset) => asset.kind === 'DETAIL')
|
||||
const imageGroups: Record<ProductImageKind, BomCostingSourceAssetDetail[]> = {
|
||||
MAIN: mainAssets,
|
||||
SKU: skuAssets,
|
||||
DETAIL: detailAssets,
|
||||
}
|
||||
const [preferredImageKind, setPreferredImageKind] = useState<ProductImageKind>('SKU')
|
||||
const activeImageKind = imageGroups[preferredImageKind].length > 0
|
||||
? preferredImageKind
|
||||
: PRODUCT_IMAGE_KINDS.find((kind) => imageGroups[kind].length > 0) ?? preferredImageKind
|
||||
const galleryAssets = imageGroups[activeImageKind]
|
||||
const [selectedAssetId, setSelectedAssetId] = useState('')
|
||||
const selectedAsset = galleryAssets.find((asset) => asset.id === selectedAssetId) ??
|
||||
galleryAssets[0] ?? null
|
||||
const selectedAssetIndex = selectedAsset
|
||||
? galleryAssets.findIndex((asset) => asset.id === selectedAsset.id)
|
||||
: -1
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [recalculationMode, setRecalculationMode] = useState<RecalculationMode>('REUSE_FACTS')
|
||||
@ -554,11 +574,58 @@ function ResultCanvas({
|
||||
|
||||
<div className="grid flex-1 items-center gap-8 lg:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)] lg:gap-12">
|
||||
<section id="bom-product-gallery" className="min-w-0">
|
||||
<div className="relative aspect-[4/3] min-h-[280px] overflow-hidden bg-[#f2f4f3] sm:min-h-[420px]">
|
||||
<div className="mb-3 flex items-end justify-between gap-4 border-b border-black/[0.08]">
|
||||
<div className="flex min-w-0 gap-4 overflow-x-auto" role="tablist" aria-label="商品图片分类">
|
||||
{PRODUCT_IMAGE_KINDS.map((kind) => {
|
||||
const assets = imageGroups[kind]
|
||||
const active = activeImageKind === kind
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls="bom-product-gallery-panel"
|
||||
disabled={assets.length === 0}
|
||||
onClick={() => {
|
||||
setPreferredImageKind(kind)
|
||||
setSelectedAssetId(assets[0]?.id ?? '')
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-9 shrink-0 items-center gap-1.5 border-b-2 px-0 text-[11px] font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#526a70] focus-visible:ring-offset-2',
|
||||
active
|
||||
? 'border-[#526a70] text-[#2f3d40]'
|
||||
: 'border-transparent text-[#6f797b] hover:text-[#3f4d50]',
|
||||
assets.length === 0 && 'cursor-not-allowed text-[#a3aaac] hover:text-[#a3aaac]',
|
||||
)}
|
||||
>
|
||||
<span>{PRODUCT_IMAGE_KIND_LABELS[kind]}</span>
|
||||
<span className="tabular-nums text-[10px] text-current/70">{assets.length}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{galleryAssets.length > 0 ? (
|
||||
<span className="mb-2.5 shrink-0 text-[10px] tabular-nums text-[#7a8486]">
|
||||
{selectedAssetIndex + 1} / {galleryAssets.length}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="bom-product-gallery-panel"
|
||||
role="tabpanel"
|
||||
aria-label={PRODUCT_IMAGE_KIND_LABELS[activeImageKind]}
|
||||
className="relative aspect-[4/3] min-h-[280px] overflow-hidden bg-[#f2f4f3] sm:min-h-[420px]"
|
||||
>
|
||||
{selectedAsset?.storedUrl ? (
|
||||
<Image
|
||||
src={selectedAsset.storedUrl}
|
||||
alt={selectedAsset.kind === 'MAIN' ? '商品主图' : `${selectedSku.name} 图片`}
|
||||
alt={selectedAsset.kind === 'MAIN'
|
||||
? '商品主图'
|
||||
: selectedAsset.kind === 'SKU'
|
||||
? `${selectedSku.name} SKU 图`
|
||||
: `商品详情图 ${selectedAssetIndex + 1}`}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 62vw, 100vw"
|
||||
priority
|
||||
@ -570,19 +637,20 @@ function ResultCanvas({
|
||||
)}
|
||||
</div>
|
||||
{galleryAssets.length > 1 ? (
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto pb-1" aria-label="商品图片">
|
||||
{galleryAssets.map((asset) => (
|
||||
<div className="mt-3 flex gap-2 overflow-x-auto pb-1" aria-label={`${PRODUCT_IMAGE_KIND_LABELS[activeImageKind]}缩略图`}>
|
||||
{galleryAssets.map((asset, index) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedAssetId(asset.id)}
|
||||
aria-label={`查看商品图片 ${asset.sortOrder + 1}`}
|
||||
aria-label={`查看${PRODUCT_IMAGE_KIND_LABELS[activeImageKind]} ${index + 1}`}
|
||||
aria-pressed={selectedAsset?.id === asset.id}
|
||||
className={cn(
|
||||
'relative h-14 w-14 shrink-0 overflow-hidden border bg-[#f2f4f3] outline-none focus-visible:ring-2 focus-visible:ring-[#526a70]',
|
||||
selectedAsset?.id === asset.id ? 'border-[#526a70]' : 'border-black/[0.08]',
|
||||
)}
|
||||
>
|
||||
<Image src={asset.storedUrl!} alt="" fill sizes="56px" unoptimized className="object-cover" />
|
||||
<Image src={asset.storedUrl!} alt="" fill sizes="56px" unoptimized className="object-contain" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -123,6 +123,56 @@ describe("callExternalImageGenerateApi", () => {
|
||||
expect(requestInit.body).toBeInstanceOf(FormData);
|
||||
});
|
||||
|
||||
it("uses the independent background-removal config and sends its selected model", async () => {
|
||||
mockedGetActiveProviderConfig.mockResolvedValue({
|
||||
enabled: true,
|
||||
baseUrl: "https://cutout.example.com",
|
||||
apiKey: "cutout-key",
|
||||
model: "gpt-image-2",
|
||||
} as never);
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
requestId: "req-cutout",
|
||||
model: "gpt-image-2",
|
||||
images: [{ originalUrl: "https://cdn.example.com/cutout.png" }],
|
||||
},
|
||||
}),
|
||||
} as never);
|
||||
|
||||
await callExternalImageGenerateApi({
|
||||
serviceType: "image-background-removal",
|
||||
prompt: "remove background",
|
||||
imageCount: 1,
|
||||
aspectRatio: "1:1",
|
||||
imageSize: "1K",
|
||||
maxAttempts: 1,
|
||||
timeoutMs: 60_000,
|
||||
referenceImage: {
|
||||
buffer: Buffer.from([1, 2, 3]),
|
||||
contentType: "image/png",
|
||||
fileName: "source.png",
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedGetActiveProviderConfig).toHaveBeenCalledWith(
|
||||
"image-background-removal",
|
||||
);
|
||||
const [endpoint, requestInit] = mockedFetch.mock.calls[0] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(endpoint).toBe(
|
||||
"https://cutout.example.com/api/external/image/generate",
|
||||
);
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
Authorization: "Bearer cutout-key",
|
||||
});
|
||||
const formData = requestInit.body as FormData;
|
||||
expect(formData.get("model")).toBe("gpt-image-2");
|
||||
});
|
||||
|
||||
it("sends file uploads and image URLs together in multipart mode", async () => {
|
||||
mockedFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@ -139,6 +139,31 @@ describe('ProviderModelCatalogService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('商品抠图使用图片网关公开 modelId,不把内部配置 UUID 当成模型名', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue(createResponse(200, {
|
||||
data: {
|
||||
models: [{
|
||||
id: 'internal-config-uuid',
|
||||
modelId: 'gpt-image-2',
|
||||
displayName: 'GPT Image 2',
|
||||
}],
|
||||
},
|
||||
}))
|
||||
global.fetch = fetchMock as typeof fetch
|
||||
|
||||
const result = await service.fetchCatalog({
|
||||
serviceType: 'image-background-removal',
|
||||
baseUrl: 'https://image.example.com',
|
||||
apiKey: 'secret',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
endpointPath: '/api/external/image/models',
|
||||
models: [{ value: 'gpt-image-2', label: 'GPT Image 2' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('密钥无权限时返回明确错误且不继续尝试其他地址', async () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue(createResponse(401, {
|
||||
error: { message: 'unauthorized' },
|
||||
|
||||
@ -234,6 +234,48 @@ describe('BomCosting1688ImageSearchService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the configured product cutout for 1688 search and keeps the original source URL for audit', async () => {
|
||||
const imageResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'image/jpeg' }),
|
||||
arrayBuffer: async () => Buffer.from('poster-image'),
|
||||
} as unknown as Response
|
||||
const apiResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ success: true, model: { count: 0, data: [] } }),
|
||||
} as unknown as Response
|
||||
const fetch = jest.fn()
|
||||
.mockResolvedValueOnce(imageResponse)
|
||||
.mockResolvedValueOnce(apiResponse)
|
||||
const prepareImageForSearch = jest.fn().mockResolvedValue({
|
||||
buffer: Buffer.from('white-background-product'),
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
const service = new BomCosting1688ImageSearchService({
|
||||
getConfig: () => config,
|
||||
fetch,
|
||||
now: () => new Date('2026-08-16T10:00:00.000Z'),
|
||||
randomBytes: () => Buffer.from([1, 2, 3, 4]),
|
||||
prepareImageForSearch,
|
||||
})
|
||||
|
||||
const quote = await service.findRealtimeQuote({
|
||||
imageUrl: 'https://images.example.com/sku-poster.jpg',
|
||||
})
|
||||
|
||||
expect(prepareImageForSearch).toHaveBeenCalledWith({
|
||||
buffer: Buffer.from('poster-image'),
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
const requestBody = JSON.parse(String(fetch.mock.calls[1]?.[1]?.body))
|
||||
expect(requestBody.imgBase64).toBe(
|
||||
Buffer.from('white-background-product').toString('base64'),
|
||||
)
|
||||
expect(quote.sourceImageUrl).toBe('https://images.example.com/sku-poster.jpg')
|
||||
})
|
||||
|
||||
it('degrades without a hidden browser fallback when the service is not configured', async () => {
|
||||
const fetch = jest.fn()
|
||||
const service = new BomCosting1688ImageSearchService({
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
DynamicBomRealtimeMarketOffer,
|
||||
DynamicBomRealtimeMarketQuote,
|
||||
} from '@/lib/bom-costing/dynamic-bom-cost-domain'
|
||||
import { bomCostingImageBackgroundRemovalService } from './bom-costing-image-background-removal.service'
|
||||
|
||||
const OFFICIAL_IMAGE_SEARCH_ENDPOINT = 'https://skills-gateway.1688.com/api/find_product/1.0.0'
|
||||
const IMAGE_SEARCH_PATH = '/api/find_product/1.0.0'
|
||||
@ -71,6 +72,10 @@ export interface BomCosting1688ImageSearchGateway {
|
||||
now(): Date
|
||||
randomBytes(size: number): Buffer
|
||||
createStoredObjectReadUrl?: (key: string) => string
|
||||
prepareImageForSearch?: (input: {
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
}) => Promise<{ buffer: Buffer; contentType: string } | null>
|
||||
}
|
||||
|
||||
function boundedPositiveInteger(
|
||||
@ -193,6 +198,7 @@ const defaultGateway: BomCosting1688ImageSearchGateway = {
|
||||
now: () => new Date(),
|
||||
randomBytes: (size) => randomBytes(size),
|
||||
createStoredObjectReadUrl: createObjectReadUrl,
|
||||
prepareImageForSearch: (input) => bomCostingImageBackgroundRemovalService.prepare(input),
|
||||
}
|
||||
|
||||
interface Alibaba1688AkKeys {
|
||||
@ -538,8 +544,17 @@ export class BomCosting1688ImageSearchService {
|
||||
const contentType = imageResponse.headers.get('content-type')?.split(';')[0]?.trim() ?? ''
|
||||
if (!contentType.startsWith('image/')) throw new Error('SKU 主图不是可检索图片')
|
||||
const image = await readBoundedImage(imageResponse, config.maxImageBytes)
|
||||
let searchImage: Uint8Array = image
|
||||
if (this.gateway.prepareImageForSearch) {
|
||||
try {
|
||||
const prepared = await this.gateway.prepareImageForSearch({ buffer: image, contentType })
|
||||
if (prepared?.buffer.length) searchImage = prepared.buffer
|
||||
} catch {
|
||||
console.warn('[BOM costing] product cutout unavailable; using original search image')
|
||||
}
|
||||
}
|
||||
const body = JSON.stringify({
|
||||
imgBase64: image.toString('base64'),
|
||||
imgBase64: Buffer.from(searchImage).toString('base64'),
|
||||
pageSize: config.pageSize,
|
||||
purchaseAmount: config.purchaseAmount,
|
||||
sortType: 'yx_desc',
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
import sharp from 'sharp'
|
||||
import {
|
||||
BOM_COSTING_BACKGROUND_REMOVAL_PROMPT,
|
||||
BomCostingImageBackgroundRemovalService,
|
||||
} from './bom-costing-image-background-removal.service'
|
||||
|
||||
function activeConfig() {
|
||||
return {
|
||||
id: 'cutout-config-1',
|
||||
serviceType: 'image-background-removal',
|
||||
name: '商品抠图',
|
||||
baseUrl: 'https://ai.example.com',
|
||||
apiKey: 'secret',
|
||||
model: 'gpt-image-2',
|
||||
extra: null,
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
createdAt: '2026-09-03T00:00:00.000Z',
|
||||
updatedAt: '2026-09-03T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
describe('BomCostingImageBackgroundRemovalService', () => {
|
||||
it('returns null without an active dedicated cutout model', async () => {
|
||||
const generate = jest.fn()
|
||||
const service = new BomCostingImageBackgroundRemovalService({
|
||||
getActiveConfig: jest.fn().mockResolvedValue(null),
|
||||
generate,
|
||||
fetch: jest.fn(),
|
||||
})
|
||||
|
||||
await expect(service.prepare({
|
||||
buffer: Buffer.from('source'),
|
||||
contentType: 'image/jpeg',
|
||||
})).resolves.toBeNull()
|
||||
expect(generate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the selected image-edit model, normalizes a white canvas, and caches the result', async () => {
|
||||
const generatedPng = await sharp({
|
||||
create: {
|
||||
width: 32,
|
||||
height: 48,
|
||||
channels: 4,
|
||||
background: { r: 120, g: 80, b: 40, alpha: 0.8 },
|
||||
},
|
||||
}).png().toBuffer()
|
||||
const generate = jest.fn().mockResolvedValue({
|
||||
requestId: 'request-1',
|
||||
model: 'gpt-image-2',
|
||||
images: [{ originalUrl: 'https://cdn.example.com/cutout.png' }],
|
||||
generatedImageIds: [],
|
||||
generatedAssetIds: [],
|
||||
attempts: [],
|
||||
})
|
||||
const fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'image/png' }),
|
||||
arrayBuffer: async () => generatedPng,
|
||||
} as unknown as Response)
|
||||
const service = new BomCostingImageBackgroundRemovalService({
|
||||
getActiveConfig: jest.fn().mockResolvedValue(activeConfig()),
|
||||
generate,
|
||||
fetch,
|
||||
})
|
||||
const source = Buffer.from('same-source-image')
|
||||
|
||||
const first = await service.prepare({ buffer: source, contentType: 'image/jpeg' })
|
||||
const second = await service.prepare({ buffer: source, contentType: 'image/jpeg' })
|
||||
|
||||
expect(generate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
serviceType: 'image-background-removal',
|
||||
prompt: BOM_COSTING_BACKGROUND_REMOVAL_PROMPT,
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
maxAttempts: 1,
|
||||
timeoutMs: 60_000,
|
||||
}))
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
new URL('https://cdn.example.com/cutout.png'),
|
||||
expect.objectContaining({ redirect: 'error' }),
|
||||
)
|
||||
expect(first).toMatchObject({
|
||||
contentType: 'image/jpeg',
|
||||
model: 'gpt-image-2',
|
||||
cached: false,
|
||||
})
|
||||
expect(second).toMatchObject({ cached: true })
|
||||
expect(generate).toHaveBeenCalledTimes(1)
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const metadata = await sharp(first!.buffer).metadata()
|
||||
expect(metadata).toMatchObject({ width: 1024, height: 1024, format: 'jpeg' })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,163 @@
|
||||
import 'server-only'
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import sharp from 'sharp'
|
||||
import { parseSafeCompetitorUrl } from '@/lib/bom-costing/url-safety'
|
||||
import { providerConfigService } from '@/server/services/provider-config.service'
|
||||
import { callExternalImageGenerateApi } from '@/server/services/external-image-generate.service'
|
||||
|
||||
const BACKGROUND_REMOVAL_SERVICE_TYPE = 'image-background-removal'
|
||||
const BACKGROUND_REMOVAL_TIMEOUT_MS = 60_000
|
||||
const BACKGROUND_REMOVAL_RESULT_TIMEOUT_MS = 45_000
|
||||
const BACKGROUND_REMOVAL_MAX_RESULT_BYTES = 10 * 1024 * 1024
|
||||
const BACKGROUND_REMOVAL_CACHE_LIMIT = 24
|
||||
|
||||
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.',
|
||||
'Preserve the original silhouette, proportions, perspective, panel and door count, openings, shelves, hardware, materials, colors, and every visible structural detail.',
|
||||
'Do not add, remove, rebuild, beautify, recolor, repair, or reinterpret any product part.',
|
||||
'Center the unchanged product on a pure white #FFFFFF square canvas with a small even margin and no text, watermark, badge, floor, reflection, or decorative shadow.',
|
||||
].join(' ')
|
||||
|
||||
type ActiveBackgroundRemovalConfig = Awaited<
|
||||
ReturnType<typeof providerConfigService.getActive>
|
||||
>
|
||||
|
||||
export interface BomCostingBackgroundRemovalResult {
|
||||
buffer: Buffer
|
||||
contentType: 'image/jpeg'
|
||||
model: string
|
||||
cached: boolean
|
||||
}
|
||||
|
||||
interface BomCostingBackgroundRemovalDependencies {
|
||||
getActiveConfig(): Promise<ActiveBackgroundRemovalConfig>
|
||||
generate: typeof callExternalImageGenerateApi
|
||||
fetch: typeof fetch
|
||||
}
|
||||
|
||||
const defaultDependencies: BomCostingBackgroundRemovalDependencies = {
|
||||
getActiveConfig: () => providerConfigService.getActive(BACKGROUND_REMOVAL_SERVICE_TYPE),
|
||||
generate: callExternalImageGenerateApi,
|
||||
fetch: (input, init) => fetch(input, init),
|
||||
}
|
||||
|
||||
function sourceFileName(contentType: string) {
|
||||
if (contentType === 'image/jpeg') return 'bom-search-source.jpg'
|
||||
if (contentType === 'image/webp') return 'bom-search-source.webp'
|
||||
if (contentType === 'image/avif') return 'bom-search-source.avif'
|
||||
return 'bom-search-source.png'
|
||||
}
|
||||
|
||||
async function readBoundedImage(response: Response) {
|
||||
const declaredLength = Number(response.headers.get('content-length') || '0')
|
||||
if (Number.isFinite(declaredLength) && declaredLength > BACKGROUND_REMOVAL_MAX_RESULT_BYTES) {
|
||||
throw new Error('抠图结果超过大小限制')
|
||||
}
|
||||
const bytes = Buffer.from(await response.arrayBuffer())
|
||||
if (bytes.length === 0 || bytes.length > BACKGROUND_REMOVAL_MAX_RESULT_BYTES) {
|
||||
throw new Error('抠图结果为空或超过大小限制')
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function buildCacheKey(
|
||||
source: Buffer,
|
||||
config: NonNullable<ActiveBackgroundRemovalConfig>,
|
||||
) {
|
||||
return createHash('sha256')
|
||||
.update('bom-costing-background-removal/v1\0')
|
||||
.update(config.id)
|
||||
.update('\0')
|
||||
.update(config.updatedAt)
|
||||
.update('\0')
|
||||
.update(config.model ?? '')
|
||||
.update('\0')
|
||||
.update(source)
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
export class BomCostingImageBackgroundRemovalService {
|
||||
private readonly cache = new Map<string, Buffer>()
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: BomCostingBackgroundRemovalDependencies = defaultDependencies,
|
||||
) {}
|
||||
|
||||
async prepare(input: {
|
||||
buffer: Buffer
|
||||
contentType: string
|
||||
}): Promise<BomCostingBackgroundRemovalResult | null> {
|
||||
const config = await this.dependencies.getActiveConfig()
|
||||
if (!config?.baseUrl || !config.apiKey || !config.model) return null
|
||||
|
||||
const cacheKey = buildCacheKey(input.buffer, config)
|
||||
const cached = this.cache.get(cacheKey)
|
||||
if (cached) {
|
||||
this.cache.delete(cacheKey)
|
||||
this.cache.set(cacheKey, cached)
|
||||
return {
|
||||
buffer: cached,
|
||||
contentType: 'image/jpeg',
|
||||
model: config.model,
|
||||
cached: true,
|
||||
}
|
||||
}
|
||||
|
||||
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 normalized = await sharp(generatedBuffer)
|
||||
.rotate()
|
||||
.flatten({ background: '#ffffff' })
|
||||
.resize({ width: 1024, height: 1024, fit: 'contain', background: '#ffffff' })
|
||||
.jpeg({ quality: 92, mozjpeg: true })
|
||||
.toBuffer()
|
||||
|
||||
this.cache.set(cacheKey, normalized)
|
||||
while (this.cache.size > BACKGROUND_REMOVAL_CACHE_LIMIT) {
|
||||
const oldestKey = this.cache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
this.cache.delete(oldestKey)
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: normalized,
|
||||
contentType: 'image/jpeg',
|
||||
model: config.model,
|
||||
cached: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const bomCostingImageBackgroundRemovalService =
|
||||
new BomCostingImageBackgroundRemovalService()
|
||||
@ -10,8 +10,13 @@ export interface ExternalImageApiConfig {
|
||||
enabled: boolean
|
||||
baseUrl: string | null
|
||||
apiKey: string | null
|
||||
model: string | null
|
||||
}
|
||||
|
||||
export type ExternalImageProviderServiceType =
|
||||
| 'ai-render'
|
||||
| 'image-background-removal'
|
||||
|
||||
export interface ExternalImageGenerateImageDTO {
|
||||
id?: string | number
|
||||
originalUrl: string
|
||||
@ -35,6 +40,7 @@ type ExternalImageGenerateReferenceImage = {
|
||||
}
|
||||
|
||||
export type CallExternalImageGenerateApiInput = {
|
||||
serviceType?: ExternalImageProviderServiceType
|
||||
prompt: string
|
||||
negativePrompt?: string | null
|
||||
imageCount: number
|
||||
@ -47,6 +53,8 @@ export type CallExternalImageGenerateApiInput = {
|
||||
/** 参考图 URL 数组 */
|
||||
referenceImageUrls?: string[]
|
||||
disabledMessage?: string
|
||||
timeoutMs?: number
|
||||
maxAttempts?: number
|
||||
}
|
||||
|
||||
const CURL_HTTP_STATUS_MARKER = '__CODEX_HTTP_STATUS__'
|
||||
@ -57,9 +65,6 @@ const EXTERNAL_IMAGE_GENERATE_FETCH_TIMEOUT_MS = readPositiveIntegerEnv(
|
||||
DEFAULT_EXTERNAL_IMAGE_GENERATE_TIMEOUT_MS,
|
||||
)
|
||||
const EXTERNAL_IMAGE_GENERATE_CURL_CONNECT_TIMEOUT_SECONDS = 10
|
||||
const EXTERNAL_IMAGE_GENERATE_CURL_MAX_TIME_SECONDS = Math.ceil(
|
||||
EXTERNAL_IMAGE_GENERATE_FETCH_TIMEOUT_MS / 1000,
|
||||
)
|
||||
|
||||
function readPositiveIntegerEnv(name: string, fallback: number): number {
|
||||
const rawValue = process.env[name]?.trim()
|
||||
@ -310,8 +315,10 @@ async function runCurl(args: string[]) {
|
||||
async function requestExternalImageGenerateViaCurl(params: {
|
||||
endpoint: string
|
||||
apiKey: string
|
||||
model: string | null
|
||||
input: CallExternalImageGenerateApiInput
|
||||
normalizedReferenceImageUrls: string[]
|
||||
timeoutMs: number
|
||||
}) {
|
||||
const tempDirectory = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'external-image-generate-'),
|
||||
@ -324,7 +331,7 @@ async function requestExternalImageGenerateViaCurl(params: {
|
||||
'--connect-timeout',
|
||||
String(EXTERNAL_IMAGE_GENERATE_CURL_CONNECT_TIMEOUT_SECONDS),
|
||||
'--max-time',
|
||||
String(EXTERNAL_IMAGE_GENERATE_CURL_MAX_TIME_SECONDS),
|
||||
String(Math.max(1, Math.ceil(params.timeoutMs / 1000))),
|
||||
'-X',
|
||||
'POST',
|
||||
params.endpoint,
|
||||
@ -343,6 +350,7 @@ async function requestExternalImageGenerateViaCurl(params: {
|
||||
await writeFile(
|
||||
bodyPath,
|
||||
JSON.stringify({
|
||||
...(params.model ? { model: params.model } : {}),
|
||||
prompt: params.input.prompt,
|
||||
...(trimToNull(params.input.negativePrompt)
|
||||
? { negativePrompt: trimToNull(params.input.negativePrompt)! }
|
||||
@ -367,6 +375,7 @@ async function requestExternalImageGenerateViaCurl(params: {
|
||||
}
|
||||
|
||||
args.push(
|
||||
...(params.model ? ['-F', `model=${params.model}`] : []),
|
||||
'-F',
|
||||
`prompt=${params.input.prompt}`,
|
||||
)
|
||||
@ -439,10 +448,12 @@ async function requestExternalImageGenerateViaCurl(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExternalImageApiConfig(): Promise<ExternalImageApiConfig> {
|
||||
export async function getExternalImageApiConfig(
|
||||
serviceType: ExternalImageProviderServiceType = 'ai-render',
|
||||
): Promise<ExternalImageApiConfig> {
|
||||
let active: Awaited<ReturnType<typeof providerConfigService.getActive>> = null
|
||||
try {
|
||||
active = await providerConfigService.getActive('ai-render')
|
||||
active = await providerConfigService.getActive(serviceType)
|
||||
} catch (error) {
|
||||
if (!isMissingPrismaTableError(error, 'ProviderConfig')) {
|
||||
throw error
|
||||
@ -454,9 +465,14 @@ export async function getExternalImageApiConfig(): Promise<ExternalImageApiConfi
|
||||
enabled: true,
|
||||
baseUrl: trimToNull(active.baseUrl),
|
||||
apiKey: trimToNull(active.apiKey),
|
||||
model: trimToNull(active.model),
|
||||
}
|
||||
}
|
||||
|
||||
if (serviceType !== 'ai-render') {
|
||||
return { enabled: false, baseUrl: null, apiKey: null, model: null }
|
||||
}
|
||||
|
||||
let dbConfig: {
|
||||
enabled?: boolean
|
||||
baseUrl?: string
|
||||
@ -479,6 +495,7 @@ export async function getExternalImageApiConfig(): Promise<ExternalImageApiConfi
|
||||
enabled: true,
|
||||
baseUrl: trimToNull(dbConfig.baseUrl ?? null),
|
||||
apiKey: trimToNull(dbConfig.apiKey ?? null),
|
||||
model: null,
|
||||
}
|
||||
}
|
||||
|
||||
@ -488,14 +505,19 @@ export async function getExternalImageApiConfig(): Promise<ExternalImageApiConfi
|
||||
),
|
||||
baseUrl: trimToNull(process.env.ASSISTED_ASSEMBLY_IMAGE_API_BASE_URL),
|
||||
apiKey: trimToNull(process.env.ASSISTED_ASSEMBLY_IMAGE_API_KEY),
|
||||
model: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function callExternalImageGenerateApi(
|
||||
input: CallExternalImageGenerateApiInput,
|
||||
): Promise<ExternalImageGenerateResult> {
|
||||
const config = await getExternalImageApiConfig()
|
||||
if (!config.enabled || !config.baseUrl || !config.apiKey) {
|
||||
const serviceType = input.serviceType ?? 'ai-render'
|
||||
const config = await getExternalImageApiConfig(serviceType)
|
||||
if (
|
||||
!config.enabled || !config.baseUrl || !config.apiKey ||
|
||||
(serviceType === 'image-background-removal' && !config.model)
|
||||
) {
|
||||
throw new AppError(
|
||||
ErrorCode.SYSTEM_INTERNAL,
|
||||
input.disabledMessage ?? 'AI 生图服务未启用或未配置',
|
||||
@ -530,6 +552,7 @@ export async function callExternalImageGenerateApi(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(config.model ? { model: config.model } : {}),
|
||||
prompt: input.prompt,
|
||||
...(trimToNull(input.negativePrompt)
|
||||
? { negativePrompt: trimToNull(input.negativePrompt)! }
|
||||
@ -553,6 +576,7 @@ export async function callExternalImageGenerateApi(
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
if (config.model) formData.append('model', config.model)
|
||||
formData.append('prompt', input.prompt)
|
||||
if (trimToNull(input.negativePrompt)) {
|
||||
formData.append('negativePrompt', trimToNull(input.negativePrompt)!)
|
||||
@ -602,17 +626,23 @@ export async function callExternalImageGenerateApi(
|
||||
})()
|
||||
|
||||
let lastError: unknown = null
|
||||
const timeoutMs = Number.isInteger(input.timeoutMs) && input.timeoutMs! >= 5_000
|
||||
? Math.min(input.timeoutMs!, EXTERNAL_IMAGE_GENERATE_FETCH_TIMEOUT_MS)
|
||||
: EXTERNAL_IMAGE_GENERATE_FETCH_TIMEOUT_MS
|
||||
const maxAttempts = Number.isInteger(input.maxAttempts) && input.maxAttempts! >= 1
|
||||
? Math.min(input.maxAttempts!, EXTERNAL_IMAGE_GENERATE_MAX_ATTEMPTS)
|
||||
: EXTERNAL_IMAGE_GENERATE_MAX_ATTEMPTS
|
||||
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= EXTERNAL_IMAGE_GENERATE_MAX_ATTEMPTS;
|
||||
attempt <= maxAttempts;
|
||||
attempt += 1
|
||||
) {
|
||||
try {
|
||||
const response = await fetchExternalImageGenerateWithTimeout(
|
||||
endpoint,
|
||||
requestInit,
|
||||
EXTERNAL_IMAGE_GENERATE_FETCH_TIMEOUT_MS,
|
||||
timeoutMs,
|
||||
)
|
||||
const payload = await response.json().catch(() => null)
|
||||
|
||||
@ -629,7 +659,7 @@ export async function callExternalImageGenerateApi(
|
||||
|
||||
if (!isNetworkFetchFailure(error)) {
|
||||
if (
|
||||
attempt < EXTERNAL_IMAGE_GENERATE_MAX_ATTEMPTS &&
|
||||
attempt < maxAttempts &&
|
||||
isRetryableExternalImageError(error)
|
||||
) {
|
||||
await sleep(1500 * attempt)
|
||||
@ -641,8 +671,10 @@ export async function callExternalImageGenerateApi(
|
||||
const curlResponse = await requestExternalImageGenerateViaCurl({
|
||||
endpoint,
|
||||
apiKey: config.apiKey,
|
||||
model: config.model,
|
||||
input,
|
||||
normalizedReferenceImageUrls,
|
||||
timeoutMs,
|
||||
}).catch((curlError) => {
|
||||
const details =
|
||||
curlError instanceof Error && curlError.message.trim()
|
||||
@ -670,7 +702,7 @@ export async function callExternalImageGenerateApi(
|
||||
lastError = curlError
|
||||
|
||||
if (
|
||||
attempt < EXTERNAL_IMAGE_GENERATE_MAX_ATTEMPTS &&
|
||||
attempt < maxAttempts &&
|
||||
isRetryableExternalImageError(curlError)
|
||||
) {
|
||||
await sleep(1500 * attempt)
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { AppError, ErrorCode } from '@/server/core/errors'
|
||||
|
||||
export type ProviderModelServiceType = 'ai-render' | 'prompt-optimizer'
|
||||
export type ProviderModelServiceType =
|
||||
| 'ai-render'
|
||||
| 'prompt-optimizer'
|
||||
| 'image-background-removal'
|
||||
|
||||
export interface ProviderModelOption {
|
||||
value: string
|
||||
@ -64,7 +67,10 @@ function readStringField(
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeModelEntry(value: unknown): ProviderModelOption | null {
|
||||
function normalizeModelEntry(
|
||||
value: unknown,
|
||||
preferExternalImageModelId = false,
|
||||
): ProviderModelOption | null {
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim()
|
||||
return normalized ? { value: normalized, label: normalized } : null
|
||||
@ -72,7 +78,12 @@ function normalizeModelEntry(value: unknown): ProviderModelOption | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
const modelId = readStringField(record, MODEL_ID_FIELDS)
|
||||
const modelId = readStringField(
|
||||
record,
|
||||
preferExternalImageModelId
|
||||
? ['modelId', 'model', 'value', 'name', 'id']
|
||||
: MODEL_ID_FIELDS,
|
||||
)
|
||||
if (!modelId) return null
|
||||
|
||||
return {
|
||||
@ -92,12 +103,15 @@ function collectModelCollections(value: unknown, depth = 0): unknown[][] {
|
||||
)
|
||||
}
|
||||
|
||||
export function extractProviderModelOptions(payload: unknown): ProviderModelOption[] {
|
||||
export function extractProviderModelOptions(
|
||||
payload: unknown,
|
||||
options?: { preferExternalImageModelId?: boolean },
|
||||
): ProviderModelOption[] {
|
||||
const deduplicated = new Map<string, ProviderModelOption>()
|
||||
|
||||
for (const collection of collectModelCollections(payload)) {
|
||||
for (const entry of collection) {
|
||||
const model = normalizeModelEntry(entry)
|
||||
const model = normalizeModelEntry(entry, options?.preferExternalImageModelId)
|
||||
if (model && !deduplicated.has(model.value)) {
|
||||
deduplicated.set(model.value, model)
|
||||
}
|
||||
@ -115,7 +129,7 @@ function buildEndpointCandidates(
|
||||
baseUrl: string,
|
||||
serviceType: ProviderModelServiceType,
|
||||
): Array<{ url: string; path: string }> {
|
||||
if (serviceType === 'ai-render') {
|
||||
if (serviceType === 'ai-render' || serviceType === 'image-background-removal') {
|
||||
return [{
|
||||
url: joinUrl(baseUrl, '/api/external/image/models'),
|
||||
path: '/api/external/image/models',
|
||||
@ -151,6 +165,7 @@ function extractErrorMessage(payload: unknown): string | null {
|
||||
async function requestCatalog(
|
||||
endpoint: { url: string; path: string },
|
||||
apiKey: string,
|
||||
serviceType: ProviderModelServiceType,
|
||||
): Promise<CatalogAttempt> {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
@ -174,7 +189,12 @@ async function requestCatalog(
|
||||
}
|
||||
}
|
||||
|
||||
const models = response.ok ? extractProviderModelOptions(payload) : []
|
||||
const models = response.ok
|
||||
? extractProviderModelOptions(payload, {
|
||||
preferExternalImageModelId:
|
||||
serviceType === 'ai-render' || serviceType === 'image-background-removal',
|
||||
})
|
||||
: []
|
||||
return {
|
||||
status: response.status,
|
||||
latency,
|
||||
@ -207,7 +227,7 @@ export class ProviderModelCatalogService {
|
||||
let reachedProvider = false
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const attempt = await requestCatalog(candidate, apiKey)
|
||||
const attempt = await requestCatalog(candidate, apiKey, input.serviceType)
|
||||
lastAttempt = attempt
|
||||
reachedProvider ||= attempt.status >= 200 && attempt.status < 500
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user