merge: 商品抠图配置合并上游专用抠图模型目录 + BOM 核算 agent 旁路引擎与文本脱敏契约

- ai-config: 任一协议模式合并网关与异步两份模型目录,抠图模型置顶标记,选中自动对齐协议
- bom-costing/agent: 估算引擎(契约/工具/循环/网关适配/组装器,27 测试)旁路试验版
- lib: 预估文本对外脱敏契约共享模块(public-estimate-text)
- scripts: shadow 偏差报表与 agent 试跑工具(工程域专用)
This commit is contained in:
WuYongJie 2026-09-16 15:17:03 +08:00
commit 9533c2e4ec
19 changed files with 2505 additions and 39 deletions

View File

@ -0,0 +1,326 @@
import { loadEnvConfig } from '@next/env'
import { Prisma } from '@prisma/client'
import type { DynamicBomRealtimeMarketQuote } from '../src/lib/bom-costing/dynamic-bom-cost-domain'
loadEnvConfig(process.cwd())
/**
* 工程侧 shadow 报表:对比每个 SKU 场景里“整件锚定预估”与“结构拆解预估”的偏差。
* 数据全部来自 BomCostingScenario.realtimeMarketQuote(计算时已持久化),不改动产品链路,
* 输出只进工程域(stdout / --json),不进入前端工作台。
*/
type CliOptions = {
json: boolean
topLimit: number
}
const DEFAULT_TOP_LIMIT = 15
function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = { json: false, topLimit: DEFAULT_TOP_LIMIT }
for (const argument of argv) {
if (argument === '--json') options.json = true
else if (argument.startsWith('--top=')) {
const topLimit = Number(argument.slice('--top='.length))
if (!Number.isSafeInteger(topLimit) || topLimit < 1 || topLimit > 200) {
throw new Error('--top must be an integer between 1 and 200')
}
options.topLimit = topLimit
} else if (argument === '--help' || argument === '-h') {
console.log([
'用法: npm run report:bom-cost-shadow-divergence [--json] [--top=N]',
'',
' --json 以 JSON 输出完整报表(便于入库或对比)',
' --top=N 偏差排行条数,默认 15',
].join('\n'))
process.exit(0)
} else {
throw new Error(`未知参数:${argument}(--help 查看用法)`)
}
}
return options
}
function numberOrNull(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
type ScenarioClassification =
| 'ANCHORED_WITH_STRUCTURE'
| 'ANCHORED_STRUCTURE_MISSING'
| 'SIGNAL_UNAVAILABLE'
| 'SIGNAL_NOT_CONFIGURED'
type ScenarioRow = {
scenarioId: string
caseId: string
caseStatus: string
sourceHost: string
categorySkill: string
externalSkuId: string
createdAt: string
classification: ScenarioClassification
quoteStatus: string
anchorCost: number | null
structuralCost: number | null
divergencePct: number | null
}
function classifyScenario(quote: DynamicBomRealtimeMarketQuote): {
classification: ScenarioClassification
anchorCost: number | null
structuralCost: number | null
divergencePct: number | null
} {
const anchorCost = numberOrNull(quote.procurementCost)
const structuralCost = numberOrNull(quote.structuralCost)
if (quote.status === 'AVAILABLE' && anchorCost !== null && anchorCost > 0) {
if (structuralCost === null) {
return { classification: 'ANCHORED_STRUCTURE_MISSING', anchorCost, structuralCost, divergencePct: null }
}
return {
classification: 'ANCHORED_WITH_STRUCTURE',
anchorCost,
structuralCost,
divergencePct: Number((((structuralCost - anchorCost) / anchorCost) * 100).toFixed(2)),
}
}
return {
classification: quote.status === 'NOT_CONFIGURED' ? 'SIGNAL_NOT_CONFIGURED' : 'SIGNAL_UNAVAILABLE',
anchorCost,
structuralCost,
divergencePct: null,
}
}
function percentile(sortedValues: number[], p: number) {
if (sortedValues.length === 0) return null
const index = Math.min(
sortedValues.length - 1,
Math.max(0, Math.ceil((p / 100) * sortedValues.length) - 1),
)
return sortedValues[index]
}
function median(values: number[]) {
return percentile(values, 50)
}
function buildReport(rows: ScenarioRow[], topLimit: number) {
const byClassification = rows.reduce<Record<string, number>>((acc, row) => {
acc[row.classification] = (acc[row.classification] ?? 0) + 1
return acc
}, {})
const divergences = rows
.filter((row) => row.divergencePct !== null)
.map((row) => row.divergencePct as number)
const absSorted = [...divergences.map(Math.abs)].sort((left, right) => left - right)
const signedSorted = [...divergences].sort((left, right) => left - right)
const buckets = {
within10Pct: absSorted.filter((value) => value <= 10).length,
between10And30Pct: absSorted.filter((value) => value > 10 && value <= 30).length,
over30Pct: absSorted.filter((value) => value > 30).length,
}
const byCase = new Map<string, {
caseId: string
caseStatus: string
sourceHost: string
total: number
anchored: number
divergenceSum: number
divergenceCount: number
}>()
for (const row of rows) {
const entry = byCase.get(row.caseId) ?? {
caseId: row.caseId,
caseStatus: row.caseStatus,
sourceHost: row.sourceHost,
total: 0,
anchored: 0,
divergenceSum: 0,
divergenceCount: 0,
}
entry.total += 1
if (row.classification === 'ANCHORED_WITH_STRUCTURE' || row.classification === 'ANCHORED_STRUCTURE_MISSING') {
entry.anchored += 1
}
if (row.divergencePct !== null) {
entry.divergenceSum += Math.abs(row.divergencePct)
entry.divergenceCount += 1
}
byCase.set(row.caseId, entry)
}
const byCategorySkill = new Map<string, ScenarioRow[]>()
for (const row of rows) {
const key = row.categorySkill || '未解析品类'
const group = byCategorySkill.get(key) ?? []
group.push(row)
byCategorySkill.set(key, group)
}
const categorySkillRollups = Array.from(byCategorySkill.entries())
.map(([categorySkill, groupRows]) => {
const groupDivergences = groupRows
.flatMap((row) => row.divergencePct === null ? [] : [Math.abs(row.divergencePct)])
.sort((left, right) => left - right)
return {
categorySkill,
total: groupRows.length,
anchored: groupRows.filter((row) => row.classification === 'ANCHORED_WITH_STRUCTURE').length,
anchoredStructureMissing: groupRows.filter((row) => row.classification === 'ANCHORED_STRUCTURE_MISSING').length,
signalUnavailable: groupRows.filter((row) => row.classification === 'SIGNAL_UNAVAILABLE').length,
signalNotConfigured: groupRows.filter((row) => row.classification === 'SIGNAL_NOT_CONFIGURED').length,
comparableCount: groupDivergences.length,
meanAbsPct: groupDivergences.length === 0
? null
: Number((groupDivergences.reduce((sum, value) => sum + value, 0) / groupDivergences.length).toFixed(2)),
medianAbsPct: median(groupDivergences),
}
})
.sort((left, right) => right.total - left.total)
return {
scenarioCount: rows.length,
byClassification,
byCategorySkill: categorySkillRollups,
divergence: {
comparableCount: divergences.length,
meanAbsPct: divergences.length === 0
? null
: Number((absSorted.reduce((sum, value) => sum + value, 0) / absSorted.length).toFixed(2)),
medianAbsPct: median(absSorted),
p80AbsPct: percentile(absSorted, 80),
minSignedPct: signedSorted[0] ?? null,
maxSignedPct: signedSorted[signedSorted.length - 1] ?? null,
buckets,
},
topDivergences: [...rows]
.filter((row) => row.divergencePct !== null)
.sort((left, right) => Math.abs(right.divergencePct as number) - Math.abs(left.divergencePct as number))
.slice(0, topLimit),
byCase: [...byCase.values()].sort((left, right) => right.total - left.total),
generatedAt: new Date().toISOString(),
}
}
function printHumanReport(report: ReturnType<typeof buildReport>) {
console.log(`BOM 成本 shadow 偏差报表(生成于 ${report.generatedAt})`)
console.log('')
console.log(`场景总数:${report.scenarioCount}`)
for (const [classification, count] of Object.entries(report.byClassification)) {
console.log(` ${classification}: ${count}`)
}
const divergence = report.divergence
console.log('')
console.log(`可对比(锚定 + 结构双价齐全):${divergence.comparableCount} 个 SKU`)
if (divergence.comparableCount > 0) {
console.log(` 偏差(结构-锚定)/锚定 均值 |d| ${divergence.meanAbsPct}%,中位 ${divergence.medianAbsPct}%,P80 ${divergence.p80AbsPct}%`)
console.log(` 区间 ${divergence.minSignedPct}% ~ ${divergence.maxSignedPct}%`)
console.log(` 分布 |d|≤10%: ${divergence.buckets.within10Pct},10%<|d|≤30%: ${divergence.buckets.between10And30Pct},|d|>30%: ${divergence.buckets.over30Pct}`)
}
console.log('')
console.log('偏差排行(|d| 降序,前 ' + report.topDivergences.length + ' 条):')
for (const row of report.topDivergences) {
console.log(
` ${row.createdAt.slice(0, 10)} case=${row.caseId.slice(-8)} sku=${row.externalSkuId} 锚定=${row.anchorCost} 结构=${row.structuralCost} d=${row.divergencePct}%`,
)
}
console.log('')
console.log(`按品类 Skill 聚合(${report.byCategorySkill.length} 个):`)
console.log(' 品类 | SKU总数 | 双价可比 | 结构缺价靠锚定 | 无实时信号 | 未配置 | 中位|d|')
for (const rollup of report.byCategorySkill) {
console.log(
` ${rollup.categorySkill} | ${rollup.total} | ${rollup.comparableCount} | ${rollup.anchoredStructureMissing} | ${rollup.signalUnavailable} | ${rollup.signalNotConfigured} | ${rollup.medianAbsPct === null ? '--' : rollup.medianAbsPct + '%'}`,
)
}
console.log('')
console.log(`按案例聚合(${report.byCase.length} 个案例,前 20):`)
for (const entry of report.byCase.slice(0, 20)) {
const avgAbs = entry.divergenceCount === 0
? '--'
: (entry.divergenceSum / entry.divergenceCount).toFixed(2)
console.log(
` case=${entry.caseId.slice(-8)} [${entry.caseStatus}] ${entry.sourceHost} SKU=${entry.total} 锚定=${entry.anchored} 平均|d|=${avgAbs}%`,
)
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const { prisma } = await import('../src/lib/prisma')
try {
const scenarios = await prisma.bomCostingScenario.findMany({
where: {
kind: 'SAME_MATERIAL',
isCurrent: true,
case: { deletedAt: null },
realtimeMarketQuote: { not: Prisma.DbNull },
},
select: {
id: true,
createdAt: true,
realtimeMarketQuote: true,
case: { select: { id: true, status: true, sourceHost: true } },
sku: { select: { externalSkuId: true } },
},
orderBy: { createdAt: 'desc' },
})
const rows: ScenarioRow[] = []
let skippedMalformed = 0
const analysisRuns = await prisma.bomCostingAnalysisRun.findMany({
where: { caseId: { in: [...new Set(scenarios.map((scenario) => scenario.case.id))] } },
select: {
caseId: true,
status: true,
resolvedCategorySkillId: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
})
const categorySkillFallback = new Map<string, string>()
const categorySkillReady = new Map<string, string>()
for (const run of analysisRuns) {
if (!categorySkillFallback.has(run.caseId)) {
categorySkillFallback.set(run.caseId, run.resolvedCategorySkillId)
}
if (run.status === 'READY' && !categorySkillReady.has(run.caseId)) {
categorySkillReady.set(run.caseId, run.resolvedCategorySkillId)
}
}
const categorySkillByCase = new Map([...categorySkillFallback, ...categorySkillReady])
for (const scenario of scenarios) {
const quote = scenario.realtimeMarketQuote as unknown as DynamicBomRealtimeMarketQuote | null
if (!quote || typeof quote !== 'object' || typeof quote.status !== 'string') {
skippedMalformed += 1
continue
}
const classified = classifyScenario(quote)
rows.push({
scenarioId: scenario.id,
caseId: scenario.case.id,
caseStatus: scenario.case.status,
sourceHost: scenario.case.sourceHost,
categorySkill: categorySkillByCase.get(scenario.case.id) ?? '',
externalSkuId: scenario.sku.externalSkuId,
createdAt: scenario.createdAt.toISOString(),
quoteStatus: quote.status,
...classified,
})
}
const report = buildReport(rows, options.topLimit)
if (options.json) {
console.log(JSON.stringify({ ...report, skippedMalformed }, null, 2))
} else {
if (skippedMalformed > 0) {
console.log(`跳过无法解析的 quote 记录:${skippedMalformed} 条`)
}
printHumanReport(report)
}
} finally {
await prisma.$disconnect()
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})

View File

@ -0,0 +1,222 @@
import { loadEnvConfig } from '@next/env'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import type { DynamicBomRealtimeMarketQuote } from '../src/lib/bom-costing/dynamic-bom-cost-domain'
import type { BomCostingAgentRunResult } from '../src/server/services/bom-costing/agent/agent-contracts'
loadEnvConfig(process.cwd())
// server 服务模块带 server-only 守卫,脚本进程需要空 stub(与 sync-oa-board 相同模式)。
async function ensureServerOnlyStub() {
const stubDir = path.join(process.cwd(), 'node_modules', 'server-only')
await mkdir(stubDir, { recursive: true })
await writeFile(path.join(stubDir, 'index.js'), 'module.exports = {}\n', 'utf8')
}
/**
* BOM 成本预估 agent 旁路试跑(工程域专用,不写任何业务表、不进管线):
* 对指定案例/SKU 运行 agent 估算,并与现行引擎落库结果对比——
* 展示价(可能为锚定)、结构拆解价、锚定采购价三方对照。
* 替换 1688 方案前,必须先在本脚本上完成足够样本的实测验收。
*/
type CliOptions = {
caseId: string | null
latestCount: number | null
skuId: string | null
maxImages: number
json: boolean
}
const DEFAULT_MAX_IMAGES = 8
function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {
caseId: null,
latestCount: null,
skuId: null,
maxImages: DEFAULT_MAX_IMAGES,
json: false,
}
for (const argument of argv) {
if (argument === '--help' || argument === '-h') {
console.log([
'用法: npm run trial:bom-costing-agent -- --case=<案例ID> [--sku=<SKU记录ID>] [--max-images=N] [--json]',
' 或: npm run trial:bom-costing-agent -- --latest=N (最近 N 个 READY 案例的主 SKU)',
'',
' --case=<id> 指定案例 ID(与 --latest 二选一)',
' --sku=<id> 指定 SKU 记录 ID(默认取排序最前的 SKU)',
' --max-images=N 送入模型的图片上限,默认 8',
' --json 以 JSON 输出完整结果(含 trace)',
'',
'注意:本脚本会发起真实模型调用(有费用),并读取案例归档图片。',
].join('\n'))
process.exit(0)
} else if (argument.startsWith('--case=')) {
options.caseId = argument.slice('--case='.length).trim()
} else if (argument.startsWith('--latest=')) {
const count = Number(argument.slice('--latest='.length))
if (!Number.isSafeInteger(count) || count < 1 || count > 20) {
throw new Error('--latest must be an integer between 1 and 20')
}
options.latestCount = count
} else if (argument.startsWith('--sku=')) {
options.skuId = argument.slice('--sku='.length).trim()
} else if (argument.startsWith('--max-images=')) {
const maxImages = Number(argument.slice('--max-images='.length))
if (!Number.isSafeInteger(maxImages) || maxImages < 1 || maxImages > 16) {
throw new Error('--max-images must be an integer between 1 and 16')
}
options.maxImages = maxImages
} else if (argument === '--json') {
options.json = true
} else {
throw new Error(`未知参数:${argument}(--help 查看用法)`)
}
}
if (!options.caseId && options.latestCount === null) {
throw new Error('必须指定 --case=<案例ID> 或 --latest=N')
}
return options
}
function agentTotalFromResult(result: BomCostingAgentRunResult) {
if (!result.categoryTotals) return null
let total = 0
for (const [key, value] of Object.entries(result.categoryTotals)) {
if (key === 'unresolvedLineCount' || typeof value !== 'number') continue
total += value
}
return Math.round(total * 100) / 100
}
async function main() {
const options = parseArgs(process.argv.slice(2))
await ensureServerOnlyStub()
const { prisma } = await import('../src/lib/prisma')
const { runBomCostingAgentTrialForSku } = await import(
'../src/server/services/bom-costing/agent/bom-costing-agent-runner'
)
try {
let targets: Array<{ caseId: string; skuId: string | null }>
if (options.caseId) {
targets = [{ caseId: options.caseId, skuId: options.skuId }]
} else {
const cases = await prisma.bomCostingCase.findMany({
where: { status: 'READY', deletedAt: null },
orderBy: { updatedAt: 'desc' },
take: options.latestCount!,
select: { id: true, skus: { orderBy: { sortOrder: 'asc' }, take: 1, select: { id: true } } },
})
targets = cases.map((sourceCase) => ({
caseId: sourceCase.id,
skuId: sourceCase.skus[0]?.id ?? null,
}))
}
const reports: unknown[] = []
for (const [index, target] of targets.entries()) {
let skuId = target.skuId
if (!skuId) {
skuId = await prisma.bomCostingSku.findFirst({
where: { caseId: target.caseId },
orderBy: { sortOrder: 'asc' },
select: { id: true },
}).then((sku) => sku?.id ?? null)
}
if (!skuId) {
console.log(`[${index + 1}/${targets.length}] 案例 ${target.caseId} 没有 SKU,跳过`)
continue
}
const scenario = await prisma.bomCostingScenario.findFirst({
where: { skuId, kind: 'SAME_MATERIAL', isCurrent: true },
select: {
id: true,
status: true,
totalCost: true,
estimateLow: true,
estimateHigh: true,
realtimeMarketQuote: true,
createdAt: true,
},
})
const quote = (scenario?.realtimeMarketQuote ?? null) as unknown as DynamicBomRealtimeMarketQuote | null
const displayedTotal = scenario?.totalCost === null || scenario?.totalCost === undefined
? null
: Number(scenario.totalCost)
const structuralCost = quote?.structuralCost ?? null
const anchorCost = quote?.procurementCost ?? null
console.log(`\n[${index + 1}/${targets.length}] case=${target.caseId.slice(-8)} sku=${skuId.slice(-8)} 运行中…`)
try {
const trial = await runBomCostingAgentTrialForSku({
caseId: target.caseId,
skuId,
maxImages: options.maxImages,
})
const agentTotal = agentTotalFromResult(trial.result)
const divergence =
agentTotal !== null && displayedTotal !== null && displayedTotal > 0
? Number((((agentTotal - displayedTotal) / displayedTotal) * 100).toFixed(2))
: null
const report = {
caseId: trial.caseId,
skuId: trial.skuId,
externalSkuId: trial.externalSkuId,
skillCode: trial.skillCode,
selectedImageCount: trial.selectedImageCount,
agent: {
status: trial.result.status,
total: agentTotal,
estimateRange: trial.result.estimate?.estimateRange ?? null,
categoryTotals: trial.result.categoryTotals,
warnings: trial.result.warnings,
usage: trial.result.usage,
trace: trial.result.trace,
},
currentEngine: {
scenarioStatus: scenario?.status ?? null,
displayedTotal,
structuralCost,
anchorCost,
scenarioCreatedAt: scenario?.createdAt?.toISOString() ?? null,
},
divergenceVsDisplayedPct: divergence,
}
reports.push(report)
if (options.json) continue
console.log(` agent:${trial.result.status},总额 ¥${agentTotal ?? '--'},` +
`图片 ${trial.selectedImageCount} 张,skill=${trial.skillCode ?? '无'}`)
console.log(` agent 分类:${JSON.stringify(trial.result.categoryTotals)}`)
console.log(` 现行引擎:展示 ¥${displayedTotal ?? '--'},结构 ¥${structuralCost ?? '--'},锚定 ¥${anchorCost ?? '--'}`)
console.log(` agent vs 展示价偏差:${divergence === null ? '--' : `${divergence}%`}`)
if (trial.result.warnings.length > 0) {
console.log(` 警告:${trial.result.warnings.join(';').slice(0, 300)}`)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.log(` 运行失败:${message}`)
reports.push({
caseId: target.caseId,
skuId,
error: message,
currentEngine: { displayedTotal, structuralCost, anchorCost },
})
}
}
if (options.json) {
console.log(JSON.stringify({ generatedAt: new Date().toISOString(), reports }, null, 2))
} else {
const completed = reports.filter((report) => 'agent' in (report as Record<string, unknown>))
console.log(`\n完成:${completed.length}/${targets.length},明细可用 --json 查看(含 trace)`)
}
} finally {
await prisma.$disconnect()
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
})

View File

@ -62,11 +62,11 @@ describe('AI 服务配置模型选择', () => {
fireEvent.click(screen.getByRole('button', { name: '测试并获取模型' }))
await waitFor(() => expect(modelSelect).toBeEnabled())
fireEvent.click(modelSelect)
expect(screen.getByRole('option', { name: 'GPT-5.6' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'GPT-5.6 Mini' })).toBeInTheDocument()
expect(screen.queryByPlaceholderText('未获取到模型列表,可手动输入')).not.toBeInTheDocument()
fireEvent.change(modelSelect, { target: { value: 'gpt-5.6' } })
fireEvent.click(screen.getByRole('option', { name: 'GPT-5.6' }))
fireEvent.click(screen.getByRole('button', { name: '保存' }))
expect(fetchMock).toHaveBeenCalledWith('/api/admin/test-connection', {
@ -117,15 +117,16 @@ describe('AI 服务配置模型选择', () => {
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('combobox', { name: '接口协议' }))
expect(screen.getByRole('option', { name: '异步媒体任务' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('option', { name: 'OpenAI 兼容图片编辑' }))
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(modelSelect)
fireEvent.click(screen.getByRole('option', { name: 'Image Edit V3' }))
fireEvent.click(screen.getByRole('button', { name: '保存' }))
expect(fetchMock).toHaveBeenCalledWith('/api/admin/test-connection', expect.objectContaining({
@ -143,4 +144,59 @@ describe('AI 服务配置模型选择', () => {
extra: { apiMode: 'openai-compatible-image-edit' },
})
})
it('商品抠图目录合并上游抠图模型,选中后自动切换异步媒体任务协议', async () => {
const fetchMock = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
success: true,
data: {
ok: true,
status: 200,
latency: 150,
models: [
{ value: 'remove-background', label: '智能抠图(remove-background)', source: 'async-matting' },
{ value: 'gpt-image-2', label: 'GPT-Image-2', source: 'gateway' },
],
endpointPath: '/api/external/image/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.vip98k.com' } })
fireEvent.change(screen.getByPlaceholderText('extimg_xxx... / sk-...'), { target: { value: 'secret' } })
fireEvent.click(screen.getByRole('button', { name: '测试并获取模型' }))
const modelSelect = screen.getByRole('combobox', { name: '模型' })
await waitFor(() => expect(modelSelect).toBeEnabled())
fireEvent.click(modelSelect)
const mattingOption = screen.getByRole('option', { name: '【抠图】智能抠图(remove-background)' })
const gatewayOption = screen.getByRole('option', { name: 'GPT-Image-2' })
// 抠图模型排在通用模型之前,便于直接选用
expect(mattingOption.compareDocumentPosition(gatewayOption) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
fireEvent.click(mattingOption)
fireEvent.click(screen.getByRole('button', { name: '保存' }))
expect(onSave).toHaveBeenCalledWith({
name: '线上抠图',
baseUrl: 'https://api.vip98k.com',
apiKey: 'secret',
model: 'remove-background',
extra: { apiMode: 'async-media-task' },
})
})
})

View File

@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from 'react'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/Toast'
import { useAuth } from '@/hooks/useAuth'
import {
@ -44,7 +45,7 @@ interface ProviderConfig {
type ActiveTab = 'ai-render' | 'prompt-optimizer' | 'image-background-removal'
interface TestResult { status: 'idle' | 'testing' | 'success' | 'error'; message: string }
interface ModelOption { value: string; label: string }
interface ModelOption { value: string; label: string; source?: 'gateway' | 'async-matting' }
type BackgroundRemovalApiMode =
| 'external-image-gateway'
@ -176,6 +177,7 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
return typeof record.value === 'string' && typeof record.label === 'string'
})
: []
// 后端已按 抠图优先 排序;这里仅透传来源标记供选型时对齐协议。
setModels(fetchedModels)
setModelsLoaded(true)
setModel((currentModel) => {
@ -209,10 +211,13 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
</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"
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]">接口协议</label>
<Select
aria-label="接口协议"
tone="obsidian"
size="small"
containerClassName="space-y-0"
className="h-8 min-h-8 rounded-[6px] bg-white/5 text-xs ring-white/[0.06] focus:border-[#47646b]/40"
value={backgroundRemovalApiMode}
onChange={(event) => {
setBackgroundRemovalApiMode(event.target.value as BackgroundRemovalApiMode)
@ -221,12 +226,11 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
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>
options={(Object.entries(BACKGROUND_REMOVAL_API_MODE_META) as Array<[BackgroundRemovalApiMode, { label: string; desc: string }]>).map(([value, meta]) => ({
value,
label: meta.label,
}))}
/>
<p className="mt-1 text-[10px] leading-4 text-[#5a6a6d]">{BACKGROUND_REMOVAL_API_MODE_META[backgroundRemovalApiMode].desc}</p>
</div>
)}
@ -245,23 +249,43 @@ export function ConfigForm({ config, serviceType, onSave, onCancel, saving }: {
</div>
<div>
<label className="mb-1 block text-[10px] font-bold uppercase tracking-[0.1em] text-[#6a7a7d]">模型</label>
<select
<Select
aria-label="模型"
tone="obsidian"
size="small"
searchable
searchPlaceholder="搜索模型"
emptyText="没有匹配的模型"
containerClassName="space-y-0"
className="h-8 min-h-8 rounded-[6px] bg-white/5 text-xs ring-white/[0.06] focus:border-[#47646b]/40"
value={model}
onChange={(e) => setModel(e.target.value)}
onChange={(event) => {
const nextValue = event.target.value
setModel(nextValue)
const picked = models.find((item) => item.value === nextValue)
if (picked?.source === 'async-matting') {
setBackgroundRemovalApiMode('async-media-task')
} else if (picked?.source === 'gateway' && backgroundRemovalApiMode === 'async-media-task') {
setBackgroundRemovalApiMode('external-image-gateway')
}
}}
disabled={!modelsLoaded || models.length === 0}
className="h-8 w-full rounded-[6px] bg-white/5 px-3 text-xs text-[#edf4f2] outline-none ring-1 ring-white/[0.06] disabled:cursor-not-allowed disabled:text-[#6a7a7d] focus:ring-[#47646b]/40"
>
<option value="" className="bg-[#111719] text-[#6a7a7d]">
{modelsLoaded ? '-- 请选择模型 --' : '请先测试并获取模型列表'}
</option>
{!modelsLoaded && model && (
<option value={model} className="bg-[#111719] text-[#edf4f2]">当前配置:{model}</option>
)}
{models.map((item) => (
<option key={item.value} value={item.value} className="bg-[#111719] text-[#edf4f2]">{item.label}</option>
))}
</select>
placeholder={modelsLoaded ? '请选择模型' : '请先测试并获取模型列表'}
options={[
...(!modelsLoaded && model ? [{ value: model, label: `当前配置:${model}` }] : []),
...[...models]
.sort((left, right) => (
(left.source === 'async-matting' ? 0 : 1) - (right.source === 'async-matting' ? 0 : 1)
))
.map((item) => ({
value: item.value,
label: item.source === 'async-matting' ? `【抠图】${item.label}` : item.label,
})),
]}
/>
{serviceType === 'image-background-removal' && models.some((item) => item.source === 'async-matting') && (
<p className="mt-1 text-[10px] leading-4 text-[#5a6a6d]">标记【抠图】的是上游专用抠图模型,选用后自动切换为异步媒体任务协议。</p>
)}
</div>
</div>

View File

@ -76,6 +76,10 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
value,
onChange,
children,
// onClick/onKeyDown 由组件内部的触发按钮接管,不透传(其 select 元素
// 事件签名与 button 不兼容,且外部场景不应覆盖交互实现)。
onClick: _ignoredOnClick,
onKeyDown: _ignoredOnKeyDown,
...props
}, _ref) => {
@ -256,6 +260,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
aria-expanded={open}
aria-haspopup="listbox"
aria-label={label}
{...(props as unknown as React.ComponentProps<'button'>)}
>
<span className={cn(
'truncate text-left',

View File

@ -0,0 +1,167 @@
import { BomCostingReferenceSyncStatus, Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
export class BomCostingAgentRepository {
async findCaseForAgentTrial(caseId: string) {
const sourceCase = await prisma.bomCostingCase.findUnique({
where: { id: caseId },
select: {
id: true,
title: true,
sourceHost: true,
status: true,
deletedAt: true,
analysisRuns: {
select: { status: true, resolvedCategorySkillId: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 20,
},
},
})
if (!sourceCase || sourceCase.deletedAt !== null) return null
let skillCode: string | null = sourceCase.analysisRuns.find((run) => run.status === 'READY')?.resolvedCategorySkillId
?? sourceCase.analysisRuns[0]?.resolvedCategorySkillId
?? null
if (skillCode === '') skillCode = null
return {
id: sourceCase.id,
title: sourceCase.title,
sourceHost: sourceCase.sourceHost,
status: sourceCase.status,
skillCode,
}
}
async findSkuForAgentTrial(skuId: string) {
return prisma.bomCostingSku.findUnique({
where: { id: skuId },
select: {
id: true,
caseId: true,
externalSkuId: true,
name: true,
lengthMm: true,
widthMm: true,
heightMm: true,
weightKg: true,
packageWeightKg: true,
sortOrder: true,
},
})
}
async listPrimarySkuIdForCase(caseId: string) {
const sku = await prisma.bomCostingSku.findFirst({
where: { caseId },
orderBy: { sortOrder: 'asc' },
select: { id: true },
})
return sku?.id ?? null
}
async listArchivedAssetsForCase(caseId: string) {
const sourceCase = await prisma.bomCostingCase.findUnique({
where: { id: caseId },
select: { sourceSnapshotId: true, deletedAt: true },
})
if (!sourceCase || sourceCase.deletedAt !== null || !sourceCase.sourceSnapshotId) return []
return prisma.bomCostingSourceAsset.findMany({
where: {
sourceSnapshotId: sourceCase.sourceSnapshotId,
status: 'STORED',
},
select: {
id: true,
kind: true,
status: true,
originalUrl: true,
storedUrl: true,
externalSkuIds: true,
sortOrder: true,
},
orderBy: { sortOrder: 'asc' },
})
}
async searchReferenceRows(input: {
category?: string
materialKeywords?: string[]
limit?: number
}): Promise<Array<{
sourceRowId: string
sourceSku: string
category: string
categoryCode: string
partName: string
materialName: string
unit: string
unitPrice: Prisma.Decimal
amount: Prisma.Decimal | null
}>> {
const run = await prisma.bomCostingReferenceSyncRun.findFirst({
where: { status: BomCostingReferenceSyncStatus.SUCCEEDED },
orderBy: [{ completedAt: 'desc' }, { createdAt: 'desc' }],
select: { id: true },
})
if (!run) return []
const filters: Prisma.BomCostingReferenceRowWhereInput[] = []
if (input.category?.trim()) {
const category = input.category.trim()
filters.push({
OR: [
{ categoryCode: { equals: category, mode: 'insensitive' } },
{ category: { contains: category, mode: 'insensitive' } },
],
})
}
const keywords = (input.materialKeywords ?? [])
.map((keyword) => keyword.trim())
.filter(Boolean)
if (keywords.length > 0) {
filters.push({
OR: keywords.flatMap((keyword) => [
{ materialName: { contains: keyword, mode: 'insensitive' } },
{ materialCode: { contains: keyword, mode: 'insensitive' } },
]),
})
}
const limit = Math.min(Math.max(Math.trunc(input.limit ?? 20), 1), 50)
const rows = await prisma.bomCostingReferenceRow.findMany({
where: {
syncRunId: run.id,
unitPrice: { gt: 0 },
...(filters.length > 0 ? { AND: filters } : {}),
},
select: {
sourceRowId: true,
sourceSku: true,
category: true,
categoryCode: true,
partName: true,
materialName: true,
unit: true,
unitPrice: true,
amount: true,
},
orderBy: [{ materialName: 'asc' }, { sourceSku: 'asc' }],
take: limit,
})
return rows.flatMap((row) => row.unitPrice === null ? [] : [{ ...row, unitPrice: row.unitPrice }])
}
async listEnabledMaterialOptions() {
return prisma.bomCostingMaterialOption.findMany({
where: { enabled: true },
select: {
id: true,
code: true,
name: true,
category: true,
aliases: true,
},
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
})
}
}
export const bomCostingAgentRepository = new BomCostingAgentRepository()

View File

@ -188,6 +188,74 @@ describe('ProviderModelCatalogService', () => {
)
})
it('商品抠图默认协议合并网关与异步抠图目录,抠图模型排前且带来源', async () => {
const fetchMock = jest.fn()
.mockResolvedValueOnce(createResponse(200, {
data: {
models: [{ id: 'gpt-image-2', modelId: 'gpt-image-2', displayName: 'GPT Image 2' }],
},
}))
.mockResolvedValueOnce(createResponse(200, {
data: {
sections: [{
id: 'task',
models: [{
id: 'remove-background',
display_name: '智能抠图',
available: true,
input_modes: ['file', 'url'],
output_modes: ['image'],
invocation: { path: '/extensions/v1/tasks' },
}],
}],
},
}))
global.fetch = fetchMock as typeof fetch
const result = await service.fetchCatalog({
serviceType: 'image-background-removal',
baseUrl: 'https://api.vip98k.com',
apiKey: 'secret',
})
expect(result.ok).toBe(true)
expect(result.models).toEqual([
{ value: 'remove-background', label: '智能抠图(remove-background)', source: 'async-matting' },
{ value: 'gpt-image-2', label: 'GPT Image 2', source: 'gateway' },
])
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'https://api.vip98k.com/api/external/image/models',
expect.any(Object),
)
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'https://api.vip98k.com/api/v2/catalog/models',
expect.any(Object),
)
})
it('商品抠图目录密钥无权限时中断后续目录尝试', async () => {
const fetchMock = jest.fn().mockResolvedValue(createResponse(401, {
error: { message: 'unauthorized' },
}))
global.fetch = fetchMock as typeof fetch
const result = await service.fetchCatalog({
serviceType: 'image-background-removal',
baseUrl: 'https://image.example.com',
apiKey: 'bad-secret',
})
expect(result).toMatchObject({
ok: false,
status: 401,
models: [],
error: 'API 密钥无权读取模型列表,请检查密钥权限',
})
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('异步媒体任务目录会返回智能抠图等图片处理模型', async () => {
const fetchMock = jest.fn().mockResolvedValue(createResponse(200, {
data: {

View File

@ -0,0 +1,254 @@
import type { DynamicBomEstimateRange } from '@/lib/bom-costing/dynamic-bom-cost-domain'
export const BOM_COSTING_AGENT_PROTOCOL_VERSION = 'bom-costing-agent/v1' as const
export type BomCostingAgentEstimateCategory =
| 'BOARD'
| 'STEEL'
| 'ACCESSORY'
| 'PACKAGING'
| 'PROCESSING'
| 'LABOR'
export interface BomCostingAgentEstimateLine {
lineKey: string
category: BomCostingAgentEstimateCategory
partName: string
material: string | null
quantity: number
unit: string
unitPrice: number | null
evidence: string[]
}
export interface BomCostingAgentEstimate {
structureSummary: string
lines: BomCostingAgentEstimateLine[]
confidence: number
estimateRange: DynamicBomEstimateRange
openQuestions: string[]
}
export type BomCostingAgentCategoryTotals = Partial<
Record<BomCostingAgentEstimateCategory, number>
> & { unresolvedLineCount: number }
export interface BomCostingAgentActionTool {
type: 'tool'
tool: string
arguments: Record<string, unknown>
}
export interface BomCostingAgentActionFinal {
type: 'final'
estimate: BomCostingAgentEstimate
}
export type BomCostingAgentAction = BomCostingAgentActionTool | BomCostingAgentActionFinal
export interface BomCostingAgentBudget {
maxRounds: number
maxModelCalls: number
maxWallClockMs: number
}
export const DEFAULT_BOM_COSTING_AGENT_BUDGET: BomCostingAgentBudget = {
maxRounds: 12,
maxModelCalls: 16,
maxWallClockMs: 6 * 60_000,
}
export type BomCostingAgentStepActionType =
| 'tool'
| 'final'
| 'parse_error'
| 'budget_exhausted'
export interface BomCostingAgentStepTrace {
round: number
actionType: BomCostingAgentStepActionType
tool?: string
toolArguments?: unknown
toolResultSummary?: string
modelCallMs?: number
error?: string
}
export type BomCostingAgentRunStatus =
| 'COMPLETED'
| 'PARTIAL'
| 'BUDGET_EXHAUSTED'
| 'FAILED'
export interface BomCostingAgentRunResult {
status: BomCostingAgentRunStatus
estimate: BomCostingAgentEstimate | null
categoryTotals: BomCostingAgentCategoryTotals | null
warnings: string[]
trace: BomCostingAgentStepTrace[]
usage: {
rounds: number
modelCalls: number
elapsedMs: number
}
}
const ESTIMATE_CATEGORIES: ReadonlySet<string> = new Set([
'BOARD',
'STEEL',
'ACCESSORY',
'PACKAGING',
'PROCESSING',
'LABOR',
])
function extractJsonPayload(content: string) {
const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/i)
const candidate = (fenced ? fenced[1] : content).trim()
const start = candidate.indexOf('{')
if (start === -1) return null
// 模型可能在一轮里拼接多个 JSON 对象(网关不总是截断);用括号配平
// 取第一个完整对象,而不是首 { 到末 } 的非法整体。
let depth = 0
let inString = false
let escaped = false
for (let index = start; index < candidate.length; index += 1) {
const char = candidate[index]
if (escaped) {
escaped = false
continue
}
if (char === '\\') {
escaped = true
continue
}
if (char === '"') {
inString = !inString
continue
}
if (inString) continue
if (char === '{') depth += 1
else if (char === '}') {
depth -= 1
if (depth === 0) return candidate.slice(start, index + 1)
}
}
return null
}
function parseEstimateLine(value: unknown): BomCostingAgentEstimateLine | null {
if (typeof value !== 'object' || value === null) return null
const record = value as Record<string, unknown>
const category = typeof record.category === 'string' ? record.category.toUpperCase() : ''
if (!ESTIMATE_CATEGORIES.has(category)) return null
const quantity = typeof record.quantity === 'number' ? record.quantity : Number.NaN
const unitPrice =
typeof record.unitPrice === 'number' && Number.isFinite(record.unitPrice) && record.unitPrice >= 0
? record.unitPrice
: null
if (!Number.isFinite(quantity) || quantity <= 0) return null
return {
lineKey: typeof record.lineKey === 'string' && record.lineKey.trim() ? record.lineKey.trim() : '',
category: category as BomCostingAgentEstimateCategory,
partName: typeof record.partName === 'string' ? record.partName : '',
material: typeof record.material === 'string' && record.material.trim() ? record.material : null,
quantity,
unit: typeof record.unit === 'string' && record.unit.trim() ? record.unit : '',
unitPrice,
evidence: Array.isArray(record.evidence)
? record.evidence.filter((item): item is string => typeof item === 'string' && item.trim() !== '')
: [],
}
}
export function parseBomCostingAgentEstimate(value: unknown): BomCostingAgentEstimate | null {
if (typeof value !== 'object' || value === null) return null
const record = value as Record<string, unknown>
const confidence = typeof record.confidence === 'number' ? record.confidence : Number.NaN
const range = record.estimateRange
const min = typeof range === 'object' && range !== null ? (range as Record<string, unknown>).min : undefined
const max = typeof range === 'object' && range !== null ? (range as Record<string, unknown>).max : undefined
if (
!Number.isFinite(confidence) || confidence < 0 || confidence > 1 ||
typeof min !== 'number' || typeof max !== 'number' || !(min <= max)
) return null
const lines = Array.isArray(record.lines)
? record.lines.flatMap((line) => {
const parsed = parseEstimateLine(line)
return parsed ? [parsed] : []
})
: []
return {
structureSummary: typeof record.structureSummary === 'string' ? record.structureSummary : '',
lines,
confidence,
estimateRange: { min, max },
openQuestions: Array.isArray(record.openQuestions)
? record.openQuestions.filter((item): item is string => typeof item === 'string')
: [],
}
}
export function parseBomCostingAgentAction(content: string): {
action: BomCostingAgentAction | null
error: string | null
} {
const payload = extractJsonPayload(content)
if (!payload) {
return { action: null, error: '输出中未找到 JSON 对象' }
}
let parsed: unknown
try {
parsed = JSON.parse(payload)
} catch (error) {
return { action: null, error: `JSON 解析失败:${error instanceof Error ? error.message : '未知错误'}` }
}
if (typeof parsed !== 'object' || parsed === null) {
return { action: null, error: '动作必须是 JSON 对象' }
}
const record = parsed as Record<string, unknown>
if (record.type === 'tool') {
if (typeof record.tool !== 'string' || !record.tool.trim()) {
return { action: null, error: 'tool 动作缺少 tool 字段' }
}
return {
action: {
type: 'tool',
tool: record.tool.trim(),
arguments:
typeof record.arguments === 'object' && record.arguments !== null
? record.arguments as Record<string, unknown>
: {},
},
error: null,
}
}
if (record.type === 'final') {
const estimate = parseBomCostingAgentEstimate(record.estimate)
if (!estimate) {
return { action: null, error: 'final 动作的 estimate 未通过契约校验(confidence/区间/行格式)' }
}
return { action: { type: 'final', estimate }, error: null }
}
return { action: null, error: '动作 type 只能是 tool 或 final' }
}
// 总额由代码从行汇总,模型只给行;缺价行计入 unresolved,不参与总额。
export function summarizeBomCostingAgentEstimate(
estimate: BomCostingAgentEstimate,
): BomCostingAgentCategoryTotals {
const totals: BomCostingAgentCategoryTotals = { unresolvedLineCount: 0 }
for (const line of estimate.lines) {
if (line.unitPrice === null) {
totals.unresolvedLineCount += 1
continue
}
const subtotal = line.unitPrice * line.quantity
totals[line.category] = roundMoney((totals[line.category] ?? 0) + subtotal)
}
return totals
}
function roundMoney(value: number) {
return Math.round(value * 100) / 100
}

View File

@ -0,0 +1,82 @@
import {
callBomCostingMultimodalModel,
getBomCostingMultimodalProviderConfig,
type BomCostingMultimodalProviderConfig,
} from '../bom-costing-multimodal-gateway'
import { createGatewayBomCostingAgentModelCaller } from './agent-model-caller'
jest.mock('../bom-costing-multimodal-gateway', () => ({
callBomCostingMultimodalModel: jest.fn(),
getBomCostingMultimodalProviderConfig: jest.fn(),
}))
const callMock = jest.mocked(callBomCostingMultimodalModel)
const resolveConfigMock = jest.mocked(getBomCostingMultimodalProviderConfig)
const fixtureConfig: BomCostingMultimodalProviderConfig = {
baseUrl: 'https://model.internal/v1',
apiKey: 'test-key',
model: 'test-model',
}
function successResult(content: string) {
return {
id: null,
model: 'test-model',
content,
promptTokens: null,
completionTokens: null,
durationMs: 10,
finishReason: null,
}
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('createGatewayBomCostingAgentModelCaller', () => {
it('带图片的轮次按 IMAGE_FACTS 调用,纯文本轮次按 SYNTHESIS 调用', async () => {
callMock.mockResolvedValue(successResult('{"type":"final","estimate":{}}'))
const caller = createGatewayBomCostingAgentModelCaller({ config: fixtureConfig })
await caller({
systemPrompt: 's',
userContent: [
{ type: 'text', text: '商品资料' },
{ type: 'image_url', image_url: { url: 'https://img' } },
],
})
await caller({ systemPrompt: 's', userContent: [{ type: 'text', text: '工具结果…' }] })
expect(callMock).toHaveBeenCalledTimes(2)
expect(callMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ operation: 'IMAGE_FACTS' }))
expect(callMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ operation: 'SYNTHESIS' }))
})
it('透传 systemPrompt/userContent,并带默认 maxTokens 与显式超时', async () => {
callMock.mockResolvedValue(successResult('{}'))
const caller = createGatewayBomCostingAgentModelCaller({ config: fixtureConfig })
const result = await caller({ systemPrompt: 'sys', userContent: 'plain', timeoutMs: 5_000 })
expect(callMock).toHaveBeenCalledWith(expect.objectContaining({
config: fixtureConfig,
systemPrompt: 'sys',
userContent: 'plain',
maxTokens: 4096,
timeoutMs: 5_000,
}))
expect(result).toEqual({ content: '{}', durationMs: 10 })
})
it('未显式给 config 时惰性解析,服务未配置则抛错', async () => {
resolveConfigMock.mockResolvedValue(null)
const caller = createGatewayBomCostingAgentModelCaller()
await expect(caller({ systemPrompt: 's', userContent: 't' }))
.rejects.toThrow('多模态分析服务未配置')
resolveConfigMock.mockResolvedValue(fixtureConfig)
callMock.mockResolvedValue(successResult('{}'))
await caller({ systemPrompt: 's', userContent: 't' })
expect(resolveConfigMock).toHaveBeenCalledTimes(2)
expect(callMock).toHaveBeenCalledWith(expect.objectContaining({ config: fixtureConfig }))
})
})

View File

@ -0,0 +1,39 @@
import {
callBomCostingMultimodalModel,
getBomCostingMultimodalProviderConfig,
type BomCostingMultimodalProviderConfig,
type BomCostingMultimodalUserContent,
} from '../bom-costing-multimodal-gateway'
import type { BomCostingAgentModelCaller } from './bom-costing-agent-loop'
export const BOM_COSTING_AGENT_MAX_TOKENS = 4096
export const BOM_COSTING_AGENT_TIMEOUT_MS = 120_000
function hasImageContent(userContent: BomCostingMultimodalUserContent) {
return Array.isArray(userContent) && userContent.some((part) => part.type === 'image_url')
}
// agent 每轮输出是紧凑 JSON 动作;带图片的轮次按 IMAGE_FACTS 语义调用,
// 纯文本(工具观察回填后的续轮)按 SYNTHESIS 语义调用,复用网关的
// 推理力度与重试策略。
export function createGatewayBomCostingAgentModelCaller(input?: {
config?: BomCostingMultimodalProviderConfig
maxTokens?: number
defaultTimeoutMs?: number
}): BomCostingAgentModelCaller {
return async ({ systemPrompt, userContent, timeoutMs }) => {
const config = input?.config ?? await getBomCostingMultimodalProviderConfig()
if (!config) {
throw new Error('多模态分析服务未配置,无法运行 agent 估算')
}
const result = await callBomCostingMultimodalModel({
config,
operation: hasImageContent(userContent) ? 'IMAGE_FACTS' : 'SYNTHESIS',
systemPrompt,
userContent,
maxTokens: input?.maxTokens ?? BOM_COSTING_AGENT_MAX_TOKENS,
timeoutMs: timeoutMs ?? input?.defaultTimeoutMs ?? BOM_COSTING_AGENT_TIMEOUT_MS,
})
return { content: result.content, durationMs: result.durationMs }
}
}

View File

@ -0,0 +1,59 @@
import { getBomCostingCategorySkill } from '../category-skills'
import { renderBomCostingAgentSkillKnowledge, renderBomCostingAgentSkillKnowledgeByCode } from './agent-skill-knowledge'
describe('renderBomCostingAgentSkillKnowledge', () => {
it('渲染真实 registry skill 的完整知识块', () => {
const skill = getBomCostingCategorySkill('CABINET_BOOKSHELF')
expect(skill).not.toBeNull()
const knowledge = renderBomCostingAgentSkillKnowledge(skill!)
expect(knowledge).toContain('【品类】')
expect(knowledge).toContain(skill!.label)
expect(knowledge).toContain(`Skill ${skill!.version}`)
expect(knowledge).toContain('【识别信号】')
expect(knowledge).toContain('【必备组件】')
expect(skill!.requiredComponents.length).toBeGreaterThan(0)
const firstRequired = skill!.requiredComponents[0]!
expect(knowledge).toContain(`${firstRequired.partName}×${firstRequired.stableQuantity}`)
if (skill!.optionalComponents.length > 0) {
expect(knowledge).toContain('【可选组件】')
}
expect(knowledge).toContain('【校验规则】')
})
it('必备组件行包含数量与尺寸规则的关键信息', () => {
const skill = getBomCostingCategorySkill('WARDROBE')
expect(skill).not.toBeNull()
const knowledge = renderBomCostingAgentSkillKnowledge(skill!)
const withDimensionRule = skill!.requiredComponents.find((component) => component.dimensionRule)
if (withDimensionRule) {
expect(knowledge).toContain(withDimensionRule.dimensionRule)
}
const withMaterialScope = skill!.requiredComponents.find((component) => component.materialScope)
if (withMaterialScope) {
expect(knowledge).toContain(withMaterialScope.materialScope)
}
})
it('结构差异只在存在时渲染', () => {
const skill = getBomCostingCategorySkill('STORAGE_RACK')
expect(skill).not.toBeNull()
const knowledge = renderBomCostingAgentSkillKnowledge(skill!)
if (skill!.differences.some((difference) => difference.groupKind === 'STRUCTURE')) {
expect(knowledge).toContain('【结构差异】')
} else {
expect(knowledge).not.toContain('【结构差异】')
}
})
it('未知品类编码返回 null', () => {
expect(renderBomCostingAgentSkillKnowledgeByCode('NOT_A_SKILL')).toBeNull()
expect(renderBomCostingAgentSkillKnowledgeByCode(null)).toBeNull()
})
it('按编码渲染与直接传入 skill 等价', () => {
const skill = getBomCostingCategorySkill('TABLE')
expect(renderBomCostingAgentSkillKnowledgeByCode('TABLE'))
.toBe(renderBomCostingAgentSkillKnowledge(skill!))
})
})

View File

@ -0,0 +1,76 @@
import {
getBomCostingCategorySkill,
type BomCostingCategorySkill,
} from '../category-skills'
function renderComponent(skill: BomCostingCategorySkill['requiredComponents'][number]) {
const parts = [
`- ${skill.partName}×${skill.stableQuantity}${skill.unit ? `(${skill.unit})` : ''}`,
]
if (skill.quantityRule) parts.push(`数量规则:${skill.quantityRule}`)
if (skill.dimensionRule) parts.push(`尺寸规则:${skill.dimensionRule}`)
if (skill.materialScope) parts.push(`材质:${skill.materialScope}`)
if (skill.primaryMaterialSlot) parts.push('继承主材质槽位')
return ` ${parts.join(';')}`
}
export function renderBomCostingAgentSkillKnowledge(skill: BomCostingCategorySkill): string {
const sections: string[] = []
sections.push(`【品类】${skill.label}(${skill.code},Skill ${skill.version})`)
const positiveTerms = skill.positiveSignals
.flatMap((signal) => signal.terms.slice(0, 4))
.slice(0, 16)
const exclusionTerms = skill.exclusionSignals
.flatMap((signal) => signal.terms.slice(0, 3))
.slice(0, 10)
sections.push(`【识别信号】命中示例:${positiveTerms.join('、') || '无'}`)
if (exclusionTerms.length > 0) {
sections.push(`【排除信号】出现即不应判为本品类:${exclusionTerms.join('、')}`)
}
if (skill.requiredComponents.length > 0) {
sections.push(
['【必备组件】(缺任一项需在 openQuestions 说明)']
.concat(skill.requiredComponents.map(renderComponent))
.join('\n'),
)
}
if (skill.optionalComponents.length > 0) {
const optionalLines = skill.optionalComponents.map((component) => {
const rendered = renderComponent(component).trim()
const triggers = component.keywords.slice(0, 4).join('、')
return ` ${rendered}${triggers ? `;激活线索:${triggers}` : ''}`
})
sections.push(['【可选组件】(必须有可见证据或激活线索才计入)'].concat(optionalLines).join('\n'))
}
const structureDifferences = skill.differences
.filter((difference) => difference.groupKind === 'STRUCTURE')
.slice(0, 12)
if (structureDifferences.length > 0) {
sections.push(
['【结构差异】(改拓扑的变体,按商品文字/图片确认)']
.concat(structureDifferences.map((difference) =>
` ${difference.description}${difference.terms.length > 0 ? `(线索:${difference.terms.slice(0, 4).join('、')})` : ''}`,
))
.join('\n'),
)
}
if (skill.validations.length > 0) {
sections.push(
['【校验规则】(final 前自查)']
.concat(skill.validations.map((validation) =>
` [${validation.severity}] ${validation.rule}`,
))
.join('\n'),
)
}
return sections.join('\n\n')
}
export function renderBomCostingAgentSkillKnowledgeByCode(code: string | null | undefined) {
const skill = getBomCostingCategorySkill(code)
return skill ? renderBomCostingAgentSkillKnowledge(skill) : null
}

View File

@ -0,0 +1,117 @@
import { Prisma } from '@prisma/client'
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
import { bomCosting1688ImageSearchService } from '../bom-costing-1688-image-search'
import { createDefaultBomCostingAgentToolDeps } from './agent-tool-providers'
jest.mock('@/server/repositories/bom-costing-agent.repository', () => ({
bomCostingAgentRepository: {
listArchivedAssetsForCase: jest.fn(),
searchReferenceRows: jest.fn(),
listEnabledMaterialOptions: jest.fn(),
},
}))
jest.mock('../bom-costing-1688-image-search', () => ({
bomCosting1688ImageSearchService: {
findRealtimeQuote: jest.fn(),
},
}))
const repositoryMock = jest.mocked(bomCostingAgentRepository)
const realtimeMock = jest.mocked(bomCosting1688ImageSearchService)
describe('createDefaultBomCostingAgentToolDeps', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('listArchivedImages 映射归档素材,存储地址优先于原始地址并带 SKU 标签', async () => {
repositoryMock.listArchivedAssetsForCase.mockResolvedValue([
{ id: 'a1', kind: 'MAIN' as const, status: 'STORED' as const, originalUrl: 'https://expiring/original', storedUrl: 'https://oss/stored', externalSkuIds: ['SKU-1'], sortOrder: 0 },
{ id: 'a2', kind: 'DETAIL' as const, status: 'STORED' as const, originalUrl: 'https://expiring/only', storedUrl: '', externalSkuIds: [], sortOrder: 1 },
])
const deps = createDefaultBomCostingAgentToolDeps()
const images = await deps.listArchivedImages({ caseId: 'case-1' })
expect(repositoryMock.listArchivedAssetsForCase).toHaveBeenCalledWith('case-1')
expect(images).toEqual([
{ assetId: 'a1', kind: 'MAIN', url: 'https://oss/stored', status: 'STORED', skuTags: ['SKU-1'] },
{ assetId: 'a2', kind: 'DETAIL', url: 'https://expiring/only', status: 'STORED', skuTags: [] },
])
})
it('searchReferencePrices 把 Decimal 映射为数字并保留 null 金额', async () => {
repositoryMock.searchReferenceRows.mockResolvedValue([
{
sourceRowId: 'row-1',
sourceSku: 'SKU-A',
category: '板材',
categoryCode: '01',
partName: '侧板',
materialName: '颗粒板 18mm',
unit: '块',
unitPrice: new Prisma.Decimal('25.50'),
amount: new Prisma.Decimal('51.00'),
},
{
sourceRowId: 'row-2',
sourceSku: 'SKU-B',
category: '五金',
categoryCode: '02',
partName: '三合一连接件',
materialName: '锌合金',
unit: '个',
unitPrice: new Prisma.Decimal('0.4'),
amount: null,
},
])
const deps = createDefaultBomCostingAgentToolDeps()
const candidates = await deps.searchReferencePrices({ category: 'BOARD', materialKeywords: ['颗粒板'] }, { caseId: 'c' })
expect(repositoryMock.searchReferenceRows).toHaveBeenCalledWith({
category: 'BOARD',
materialKeywords: ['颗粒板'],
limit: undefined,
})
expect(candidates).toEqual([
{ referenceSku: 'SKU-A', partName: '侧板', materialName: '颗粒板 18mm', unit: '块', unitPrice: 25.5, amount: 51, sourceRowId: 'row-1' },
{ referenceSku: 'SKU-B', partName: '三合一连接件', materialName: '锌合金', unit: '个', unitPrice: 0.4, amount: null, sourceRowId: 'row-2' },
])
})
it('listMaterialOptions 过滤掉非字符串别名', async () => {
repositoryMock.listEnabledMaterialOptions.mockResolvedValue([
{ id: 'm1', code: 'PB18', name: '颗粒板 18mm', category: 'BOARD', aliases: ['刨花板', '', 42] },
])
const deps = createDefaultBomCostingAgentToolDeps()
const options = await deps.listMaterialOptions({ caseId: 'c' })
expect(options).toEqual([{ id: 'm1', name: '颗粒板 18mm', aliases: ['刨花板'] }])
})
it('searchRealtimeMarket 只透出摘要字段,warnings 与 offers 不进转录', async () => {
realtimeMock.findRealtimeQuote.mockResolvedValue({
schemaVersion: 'bom-costing-1688-realtime/v1',
source: '1688_IMAGE_SEARCH',
status: 'AVAILABLE',
sourceImageUrl: 'https://img',
searchedAt: '2026-08-21T00:00:00.000Z',
procurementRate: 0.9,
marketPrice: 100,
procurementCost: 90,
estimateRange: { min: 85, max: 95 },
confidence: 0.8,
comparableOfferCount: 5,
returnedOfferCount: 8,
warnings: ['1688 实时同款价格中位数 ¥100'],
offers: [{ supplier: '某店铺' }],
} as never)
const deps = createDefaultBomCostingAgentToolDeps()
const summary = await deps.searchRealtimeMarket({ imageUrl: 'https://img' }, { caseId: 'c' })
expect(realtimeMock.findRealtimeQuote).toHaveBeenCalledWith({ imageUrl: 'https://img' })
expect(summary).toEqual({
status: 'AVAILABLE',
procurementCost: 90,
estimateRange: { min: 85, max: 95 },
comparableOfferCount: 5,
})
expect(Object.keys(summary as Record<string, unknown>)).not.toContain('warnings')
})
})

View File

@ -0,0 +1,56 @@
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
import { bomCosting1688ImageSearchService } from '../bom-costing-1688-image-search'
import type { BomCostingAgentToolDeps } from './agent-tools'
// 工具接线层:repository 只出原始行,这里负责映射成 agent 可消费的摘要。
// 实时行情工具刻意只透出状态/采购参考价/区间/可比数,warnings 与 offers
// 留在服务端内部,避免进入 agent 转录后流向前端。
export function createDefaultBomCostingAgentToolDeps(): BomCostingAgentToolDeps {
return {
listArchivedImages: async (context) => {
const assets = await bomCostingAgentRepository.listArchivedAssetsForCase(context.caseId)
return assets.map((asset) => ({
assetId: asset.id,
kind: asset.kind,
url: asset.storedUrl || asset.originalUrl,
status: asset.status,
skuTags: Array.isArray(asset.externalSkuIds)
? (asset.externalSkuIds as unknown[]).filter((item): item is string =>
typeof item === 'string' && item.trim() !== '')
: [],
}))
},
searchReferencePrices: async (input) => {
const rows = await bomCostingAgentRepository.searchReferenceRows(input)
return rows.map((row) => ({
referenceSku: row.sourceSku,
partName: row.partName,
materialName: row.materialName,
unit: row.unit,
unitPrice: Number(row.unitPrice),
amount: row.amount === null ? null : Number(row.amount),
sourceRowId: row.sourceRowId,
}))
},
listMaterialOptions: async () => {
const options = await bomCostingAgentRepository.listEnabledMaterialOptions()
return options.map((option) => ({
id: option.id,
name: option.name,
aliases: Array.isArray(option.aliases)
? (option.aliases as unknown[]).filter((item): item is string =>
typeof item === 'string' && item.trim() !== '')
: [],
}))
},
searchRealtimeMarket: async ({ imageUrl }) => {
const quote = await bomCosting1688ImageSearchService.findRealtimeQuote({ imageUrl })
return {
status: quote.status,
procurementCost: quote.procurementCost,
estimateRange: quote.estimateRange,
comparableOfferCount: quote.comparableOfferCount,
}
},
}
}

View File

@ -0,0 +1,185 @@
import {
calculateBoardBatchCuttingPlan,
type BoardBatchCuttingPlan,
type BoardCuttingPartInput,
type BoardSheetInput,
} from '@/lib/bom-costing/board-sheet-cutting'
export interface BomCostingAgentToolContext {
caseId: string
skuId?: string
}
export interface BomCostingAgentToolRuntime {
name: string
description: string
execute: (input: unknown, context: BomCostingAgentToolContext) => Promise<unknown>
}
export interface BomCostingAgentArchivedImage {
assetId: string
kind: 'MAIN' | 'SKU' | 'DETAIL'
url: string
status: string
skuTags: string[]
}
export interface BomCostingAgentReferenceCandidate {
referenceSku: string
partName: string
materialName: string
unit: string
unitPrice: number
amount: number | null
sourceRowId: string
}
export interface BomCostingAgentMaterialOption {
id: string
name: string
aliases: string[]
}
export interface BomCostingAgentRealtimeQuoteSummary {
status: 'AVAILABLE' | 'NO_COMPARABLE_OFFER' | 'NOT_CONFIGURED' | 'IMAGE_UNAVAILABLE' | 'FAILED'
procurementCost: number | null
estimateRange: { min: number; max: number } | null
comparableOfferCount: number
}
export interface BomCostingAgentToolDeps {
listArchivedImages: (context: BomCostingAgentToolContext) => Promise<BomCostingAgentArchivedImage[]>
searchReferencePrices: (
input: { category?: string; materialKeywords?: string[]; limit?: number },
context: BomCostingAgentToolContext,
) => Promise<BomCostingAgentReferenceCandidate[]>
listMaterialOptions: (context: BomCostingAgentToolContext) => Promise<BomCostingAgentMaterialOption[]>
searchRealtimeMarket: (
input: { imageUrl: string },
context: BomCostingAgentToolContext,
) => Promise<BomCostingAgentRealtimeQuoteSummary>
}
interface BoardSheetEstimateInput {
sheet?: {
substrateId?: unknown
materialName?: unknown
sheetLengthMm?: unknown
sheetWidthMm?: unknown
fullSheetPrice?: unknown
}
panels?: Array<Record<string, unknown>>
}
function positiveNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
}
function parseBoardSheetEstimateInput(input: unknown): {
sheet: BoardSheetInput
panels: BoardCuttingPartInput[]
} | null {
if (typeof input !== 'object' || input === null) return null
const record = input as BoardSheetEstimateInput
const sheetRecord = record.sheet
if (typeof sheetRecord !== 'object' || sheetRecord === null) return null
const sheetLengthMm = positiveNumber(sheetRecord.sheetLengthMm)
const sheetWidthMm = positiveNumber(sheetRecord.sheetWidthMm)
const fullSheetPrice = positiveNumber(sheetRecord.fullSheetPrice)
if (sheetLengthMm === null || sheetWidthMm === null || fullSheetPrice === null) return null
const panels = Array.isArray(record.panels)
? record.panels.flatMap((panel, index) => {
const lengthMm = positiveNumber(panel.lengthMm)
const widthMm = positiveNumber(panel.widthMm)
const quantity = positiveNumber(panel.quantity)
if (lengthMm === null || widthMm === null || quantity === null) return []
return [{
lineId: typeof panel.lineId === 'string' && panel.lineId ? panel.lineId : `panel-${index + 1}`,
lengthMm,
widthMm,
quantity,
}]
})
: []
if (panels.length === 0) return null
return {
sheet: {
substrateId: typeof sheetRecord.substrateId === 'string' ? sheetRecord.substrateId : '',
materialName: typeof sheetRecord.materialName === 'string' ? sheetRecord.materialName : '',
sheetLengthMm,
sheetWidthMm,
fullSheetPrice,
},
panels,
}
}
export function createEstimateBoardSheetsTool(): BomCostingAgentToolRuntime {
return {
name: 'estimate_board_sheets',
description:
'按整张开料估算板材成本。输入 sheet(板材长宽 mm 与整张价格)与 panels(每块板件长宽 mm 与数量),' +
'返回张数、利用率、每行分摊成本。用于 BOARD 类行的定价与交叉验证。',
execute: async (input) => {
const parsed = parseBoardSheetEstimateInput(input)
if (!parsed) {
return { error: '输入不合法:需要 sheet{sheetLengthMm,sheetWidthMm,fullSheetPrice} 与 panels[{lengthMm,widthMm,quantity}]' }
}
const plan: BoardBatchCuttingPlan | null = calculateBoardBatchCuttingPlan(parsed.sheet, parsed.panels)
if (!plan) return { error: '开料计算未返回有效方案(板件过多或尺寸超出安全上限)' }
return {
rulesetVersion: plan.rulesetVersion,
selectedBatchSize: plan.selectedBatchSize,
sheetCount: plan.selectedBatch.sheetCount,
totalSheetCost: plan.selectedBatch.totalSheetCost,
perUnitSheetCost: plan.selectedBatch.perUnitSheetCost,
utilization: plan.selectedBatch.utilization,
allocations: plan.selectedBatch.allocations,
warnings: plan.warnings,
}
},
}
}
export function createBomCostingAgentTools(deps: BomCostingAgentToolDeps) {
return [
{
name: 'list_archived_images',
description: '列出本案例已归档的商品图片(主图/SKU 图/详情图)与对象存储访问地址。',
execute: (_input: unknown, context: BomCostingAgentToolContext) => deps.listArchivedImages(context),
},
{
name: 'search_reference_prices',
description:
'按分类或材质关键词检索公司历史 BOM 价格候选(来源为本地镜像)。输入 {category?, materialKeywords?, limit?}。',
execute: (input: unknown, context: BomCostingAgentToolContext) => {
const record = typeof input === 'object' && input !== null ? input as Record<string, unknown> : {}
return deps.searchReferencePrices({
category: typeof record.category === 'string' ? record.category : undefined,
materialKeywords: Array.isArray(record.materialKeywords)
? record.materialKeywords.filter((item): item is string => typeof item === 'string')
: undefined,
limit: typeof record.limit === 'number' ? record.limit : undefined,
}, context)
},
},
{
name: 'list_material_options',
description: '列出公司启用材质目录(材质身份的唯一来源,含别名)。',
execute: (_input: unknown, context: BomCostingAgentToolContext) => deps.listMaterialOptions(context),
},
{
name: 'search_realtime_market',
description:
'按商品图检索实时市场同款行情摘要(状态、整件采购参考价、区间、可比报价数)。仅作为交叉验证信号,不能替代逐行定价。',
execute: (input: unknown, context: BomCostingAgentToolContext) => {
const record = typeof input === 'object' && input !== null ? input as Record<string, unknown> : {}
if (typeof record.imageUrl !== 'string' || !record.imageUrl) {
return Promise.resolve({ error: '输入不合法:需要 imageUrl' })
}
return deps.searchRealtimeMarket({ imageUrl: record.imageUrl }, context)
},
},
createEstimateBoardSheetsTool(),
]
}

View File

@ -0,0 +1,270 @@
import {
parseBomCostingAgentAction,
summarizeBomCostingAgentEstimate,
type BomCostingAgentEstimate,
} from './agent-contracts'
import { createBomCostingAgentTools, createEstimateBoardSheetsTool } from './agent-tools'
import { runBomCostingAgentLoop, type BomCostingAgentModelCaller } from './bom-costing-agent-loop'
function validEstimate(overrides: Partial<BomCostingAgentEstimate> = {}): BomCostingAgentEstimate {
return {
structureSummary: '三块侧板 + 底板 + 背板的简易柜体',
lines: [
{
lineKey: 'board-side',
category: 'BOARD',
partName: '侧板',
material: '颗粒板 18mm',
quantity: 2,
unit: '块',
unitPrice: 25,
evidence: ['search_reference_prices 结果第 1 条'],
},
{
lineKey: 'hardware-cam',
category: 'ACCESSORY',
partName: '三合一连接件',
material: null,
quantity: 12,
unit: '个',
unitPrice: 0.4,
evidence: ['search_reference_prices 结果第 3 条'],
},
{
lineKey: 'labor-tbd',
category: 'LABOR',
partName: '组装人工',
material: null,
quantity: 1,
unit: '套',
unitPrice: null,
evidence: ['无价格依据,待补'],
},
],
confidence: 0.55,
estimateRange: { min: 55, max: 70 },
openQuestions: ['板厚未能从图片确认'],
...overrides,
}
}
describe('parseBomCostingAgentAction', () => {
it('解析带 ```json 围栏的工具动作', () => {
const { action, error } = parseBomCostingAgentAction(
'说明文字\n```json\n{"type":"tool","tool":"search_reference_prices","arguments":{"category":"BOARD"}}\n```',
)
expect(error).toBeNull()
expect(action).toEqual({
type: 'tool',
tool: 'search_reference_prices',
arguments: { category: 'BOARD' },
})
})
it('解析 final 动作并校验 estimate 契约', () => {
const { action } = parseBomCostingAgentAction(
JSON.stringify({ type: 'final', estimate: validEstimate() }),
)
expect(action?.type).toBe('final')
expect(action?.type === 'final' && action.estimate.lines).toHaveLength(3)
})
it('拒绝缺 confidence 或区间颠倒的 estimate', () => {
expect(parseBomCostingAgentAction(
JSON.stringify({ type: 'final', estimate: { ...validEstimate(), confidence: 1.5 } }),
).action).toBeNull()
expect(parseBomCostingAgentAction(
JSON.stringify({ type: 'final', estimate: { ...validEstimate(), estimateRange: { min: 90, max: 20 } } }),
).action).toBeNull()
})
it('拒绝非 JSON 输出与未知 type', () => {
expect(parseBomCostingAgentAction('我认为成本大概是 60 元').action).toBeNull()
expect(parseBomCostingAgentAction('{"type":"thinking"}').action).toBeNull()
})
it('多对象拼接时取第一个完整 JSON 对象', () => {
const { action } = parseBomCostingAgentAction(
'{"type":"tool","tool":"list_material_options","arguments":{}}{"type":"tool","tool":"list_archived_images","arguments":{}}',
)
expect(action).toEqual({
type: 'tool',
tool: 'list_material_options',
arguments: {},
})
})
it('字符串内的花括号不影响配平提取', () => {
const { action } = parseBomCostingAgentAction(
'前置说明 {"type":"tool","tool":"search_reference_prices","arguments":{"materialKeywords":["{特殊}字符"]}} 尾部',
)
expect(action?.type).toBe('tool')
expect(action?.type === 'tool' && action.arguments.materialKeywords).toEqual(['{特殊}字符'])
})
})
describe('summarizeBomCostingAgentEstimate', () => {
it('按分类汇总有价行,缺价行只计入未解决数', () => {
const totals = summarizeBomCostingAgentEstimate(validEstimate())
expect(totals.BOARD).toBe(50)
expect(totals.ACCESSORY).toBe(4.8)
expect(totals.LABOR).toBeUndefined()
expect(totals.unresolvedLineCount).toBe(1)
})
})
function fakeModelCaller(responses: string[], calls: { userContent: unknown }[] = []): BomCostingAgentModelCaller {
let index = 0
return async (input) => {
calls.push({ userContent: input.userContent })
const content = responses[Math.min(index, responses.length - 1)]
index += 1
return { content: typeof content === 'string' ? content : JSON.stringify(content), durationMs: 5 }
}
}
const mission = () => ({
mission: { userContent: [{ type: 'text' as const, text: '商品图片与文字资料…' }], toolContext: { caseId: 'case-1', skuId: 'sku-1' } },
})
describe('runBomCostingAgentLoop', () => {
it('工具调用后产出 final:完成态、工具收到解析后的参数与上下文、总额由代码汇总', async () => {
const executed: Array<{ input: unknown; context: unknown }> = []
const tools = createBomCostingAgentTools({
listArchivedImages: async () => [],
searchReferencePrices: async (query, context) => {
executed.push({ input: query, context })
return [{
referenceSku: 'SKU-1',
partName: '侧板',
materialName: '颗粒板',
unit: '块',
unitPrice: 25,
amount: 25,
sourceRowId: 'row-1',
}]
},
listMaterialOptions: async () => [],
searchRealtimeMarket: async () => ({
status: 'NOT_CONFIGURED' as const,
procurementCost: null,
estimateRange: null,
comparableOfferCount: 0,
}),
})
const result = await runBomCostingAgentLoop({
...mission(),
toolRuntimes: tools,
callModel: fakeModelCaller([
{ type: 'tool', tool: 'search_reference_prices', arguments: { category: 'BOARD' } },
{ type: 'final', estimate: validEstimate() },
]),
})
expect(result.status).toBe('COMPLETED')
expect(executed).toHaveLength(1)
expect(executed[0]!.input).toEqual({ category: 'BOARD', materialKeywords: undefined, limit: undefined })
expect(executed[0]!.context).toEqual({ caseId: 'case-1', skuId: 'sku-1' })
expect(result.categoryTotals?.BOARD).toBe(50)
expect(result.usage.modelCalls).toBe(2)
expect(result.trace.map((step) => step.actionType)).toEqual(['tool', 'final'])
})
it('未知工具会以系统提示回填观察,下一轮可继续', async () => {
const result = await runBomCostingAgentLoop({
...mission(),
toolRuntimes: [],
callModel: fakeModelCaller([
{ type: 'tool', tool: 'not_a_tool', arguments: {} },
{ type: 'final', estimate: validEstimate() },
]),
})
expect(result.status).toBe('COMPLETED')
expect(result.trace[0]!.actionType).toBe('tool')
expect(result.trace[0]!.tool).toBe('not_a_tool')
})
it('连续两轮无法解析则失败', async () => {
const result = await runBomCostingAgentLoop({
...mission(),
toolRuntimes: [],
callModel: fakeModelCaller(['我觉得大概 60 元', '还是没法给出 JSON']),
})
expect(result.status).toBe('FAILED')
expect(result.warnings[0]).toContain('无法解析为合法动作')
})
it('预算耗尽返回 BUDGET_EXHAUSTED 并守住轮次上限', async () => {
const result = await runBomCostingAgentLoop({
mission: {
...mission().mission,
budget: { maxRounds: 3, maxModelCalls: 3, maxWallClockMs: 60_000 },
},
toolRuntimes: [],
callModel: fakeModelCaller([
{ type: 'tool', tool: 'unknown', arguments: {} },
{ type: 'tool', tool: 'unknown', arguments: {} },
{ type: 'tool', tool: 'unknown', arguments: {} },
]),
})
expect(result.status).toBe('BUDGET_EXHAUSTED')
expect(result.usage.rounds).toBe(3)
expect(result.usage.modelCalls).toBe(3)
})
it('final 无有效行视为部分结果', async () => {
const result = await runBomCostingAgentLoop({
...mission(),
toolRuntimes: [],
callModel: fakeModelCaller([
{ type: 'final', estimate: validEstimate({ lines: [] }) },
]),
})
expect(result.status).toBe('PARTIAL')
expect(result.categoryTotals?.unresolvedLineCount).toBe(0)
})
it('工具抛错会以错误观察继续而不是中断', async () => {
const boom = createEstimateBoardSheetsTool()
expect(boom.name).toBe('estimate_board_sheets')
const failing = {
name: 'failing_tool',
description: 'always fails',
execute: async () => {
throw new Error('依赖不可用')
},
}
const result = await runBomCostingAgentLoop({
...mission(),
toolRuntimes: [failing],
callModel: fakeModelCaller([
{ type: 'tool', tool: 'failing_tool', arguments: {} },
{ type: 'final', estimate: validEstimate() },
]),
})
expect(result.status).toBe('COMPLETED')
expect(result.trace[0]!.toolResultSummary).toContain('依赖不可用')
})
})
describe('estimate_board_sheets 工具', () => {
it('合法输入返回开料方案摘要', async () => {
const tool = createEstimateBoardSheetsTool()
const payload = await tool.execute({
sheet: { substrateId: 'sub-1', materialName: '颗粒板 18mm', sheetLengthMm: 2440, sheetWidthMm: 1220, fullSheetPrice: 120 },
panels: [
{ lineId: 'p1', lengthMm: 800, widthMm: 400, quantity: 4 },
{ lineId: 'p2', lengthMm: 600, widthMm: 300, quantity: 2 },
],
}, { caseId: 'c' }) as Record<string, unknown>
expect(payload.error).toBeUndefined()
expect(typeof payload.sheetCount).toBe('number')
expect(typeof payload.perUnitSheetCost).toBe('number')
expect(payload.utilization).toBeGreaterThan(0)
})
it('非法输入返回结构化错误而不是抛出', async () => {
const tool = createEstimateBoardSheetsTool()
const payload = await tool.execute({ sheet: { sheetLengthMm: -1 } }, { caseId: 'c' }) as Record<string, unknown>
expect(typeof payload.error).toBe('string')
})
})

View File

@ -0,0 +1,253 @@
import type { BomCostingMultimodalUserContent } from '../bom-costing-multimodal-gateway'
import {
DEFAULT_BOM_COSTING_AGENT_BUDGET,
parseBomCostingAgentAction,
summarizeBomCostingAgentEstimate,
type BomCostingAgentBudget,
type BomCostingAgentRunResult,
type BomCostingAgentStepTrace,
} from './agent-contracts'
import type { BomCostingAgentToolContext, BomCostingAgentToolRuntime } from './agent-tools'
export type BomCostingAgentModelCaller = (input: {
systemPrompt: string
userContent: BomCostingMultimodalUserContent
timeoutMs?: number
}) => Promise<{ content: string; durationMs: number }>
const MAX_CONSECUTIVE_PARSE_FAILURES = 2
const MAX_MODEL_CALL_ATTEMPTS = 3
const MODEL_CALL_RETRY_DELAY_MS = 3_000
export interface BomCostingAgentMission {
userContent: BomCostingMultimodalUserContent
toolContext?: BomCostingAgentToolContext
budget?: Partial<BomCostingAgentBudget>
signal?: AbortSignal
}
function renderTranscript(
baseContent: BomCostingMultimodalUserContent,
observations: string[],
): BomCostingMultimodalUserContent {
if (observations.length === 0) return baseContent
if (typeof baseContent === 'string') {
return [baseContent, ...observations].join('\n\n')
}
return [...baseContent, ...observations.map((text) => ({ type: 'text' as const, text }))]
}
function observationText(tool: string, payload: unknown) {
return `[工具结果 ${tool}] ${safeJsonStringify(payload)}`
}
// 网关在流中检测到第一个完整 JSON 就会截断返回,但模型仍可能在一轮里
// 拼接多个动作对象;这里检测"首个对象之后是否还有动作对象",用于回填协议提醒。
function containsExtraActionObjects(content: string) {
const firstEnd = content.indexOf('}')
if (firstEnd === -1) return false
return /"\s*type"\s*:\s*"(?:tool|final)"/.test(content.slice(firstEnd + 1))
}
function safeJsonStringify(payload: unknown) {
try {
return JSON.stringify(payload)
} catch {
return '{"error":"工具结果无法序列化"}'
}
}
export function buildBomCostingAgentSystemPrompt(input: {
toolRuntimes: BomCostingAgentToolRuntime[]
skillKnowledge?: string
}) {
const toolLines = input.toolRuntimes
.map((tool) => `- ${tool.name}: ${tool.description}`)
.join('\n')
return [
'你是家具竞品成本预估 agent。目标:基于已归档的商品图片/文字与工具结果,产出同材质复刻的 BOM 成本“预估”(带区间与置信度),不是精确核算。',
'',
'工作方式(严格遵守):',
'- 每一轮回复只输出一个 JSON 对象,前后不允许有其他文字,也不允许输出多个 JSON 对象;',
'- 调用工具:{"type":"tool","tool":"<工具名>","arguments":{...}},然后停下等待下一轮的 [工具结果];',
'- 只有在需要的工具结果都已拿到之后,才输出最终结果:{"type":"final","estimate":{...}};禁止在未看到工具结果时就输出 final;',
'- search_realtime_market 需要 imageUrl 参数:先用 list_archived_images 取得图片地址再调用它。',
'',
'estimate 契约(必须满足,否则会被拒绝):',
'- structureSummary:字符串,结构拆解的一句话说明',
'- lines:数组,每行 {lineKey, category(BOARD|STEEL|ACCESSORY|PACKAGING|PROCESSING|LABOR), partName, material|null, quantity(>0), unit, unitPrice(数字或 null), evidence[]};unitPrice 只能来自工具结果,没有依据就填 null,禁止编造价格',
'- evidence:每行至少一条,说明价格或数量来自哪个工具结果/图片',
'- confidence:0-1;estimateRange:{min,max} 且 min<=max',
'- openQuestions:字符串数组,列出影响精度但无法从现有信息确认的问题',
'',
'可用工具:',
toolLines,
'',
input.skillKnowledge
? `品类知识与结构先验(优先按此推断缺失结构,但可见证据优先于先验):\n${input.skillKnowledge}`
: '未注入品类先验:按通用板式家具结构常识推断,并在 openQuestions 中说明置信度折扣。',
'',
'纪律:先看图和文字,再查价格工具,最后才出 final;工具结果不满足时可以多轮检索;总额不需要你计算(系统按行汇总)。',
].join('\n')
}
export async function runBomCostingAgentLoop(input: {
mission: BomCostingAgentMission
toolRuntimes: BomCostingAgentToolRuntime[]
callModel: BomCostingAgentModelCaller
}): Promise<BomCostingAgentRunResult> {
const budget: BomCostingAgentBudget = {
...DEFAULT_BOM_COSTING_AGENT_BUDGET,
...input.mission.budget,
}
const startedAt = Date.now()
const trace: BomCostingAgentStepTrace[] = []
const warnings: string[] = []
const observations: string[] = []
const toolsByName = new Map(input.toolRuntimes.map((tool) => [tool.name, tool]))
let rounds = 0
let modelCalls = 0
let consecutiveParseFailures = 0
while (true) {
const elapsedMs = Date.now() - startedAt
if (rounds >= budget.maxRounds || modelCalls >= budget.maxModelCalls || elapsedMs >= budget.maxWallClockMs) {
warnings.push(
`预算耗尽:rounds=${rounds}/${budget.maxRounds},modelCalls=${modelCalls}/${budget.maxModelCalls},` +
`elapsedMs=${Math.round(elapsedMs)}/${budget.maxWallClockMs},未得到 final 结果`,
)
trace.push({ round: rounds + 1, actionType: 'budget_exhausted' })
return {
status: 'BUDGET_EXHAUSTED',
estimate: null,
categoryTotals: null,
warnings,
trace,
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
}
}
if (input.mission.signal?.aborted) {
warnings.push('任务被外部中止')
return {
status: 'FAILED',
estimate: null,
categoryTotals: null,
warnings,
trace,
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
}
}
rounds += 1
const callStartedAt = Date.now()
let content: string
try {
let attempt = 0
while (true) {
attempt += 1
try {
const result = await input.callModel({
systemPrompt: buildBomCostingAgentSystemPrompt({ toolRuntimes: input.toolRuntimes }),
userContent: renderTranscript(input.mission.userContent, observations),
})
content = result.content
modelCalls += 1
break
} catch (error) {
// 上游抓取图片超时等瞬时错误常见(服务商跨境拉存储对象),有界重试。
if (attempt >= MAX_MODEL_CALL_ATTEMPTS) throw error
await new Promise((resolve) => setTimeout(resolve, MODEL_CALL_RETRY_DELAY_MS))
}
}
} catch (error) {
warnings.push(`模型调用失败:${error instanceof Error ? error.message : '未知错误'}`)
return {
status: 'FAILED',
estimate: null,
categoryTotals: null,
warnings,
trace,
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
}
}
const modelCallMs = Date.now() - callStartedAt
const { action, error } = parseBomCostingAgentAction(content)
if (!action) {
consecutiveParseFailures += 1
trace.push({
round: rounds,
actionType: 'parse_error',
modelCallMs,
error: `${error ?? '未知原因'};内容预览:${content.slice(0, 160)}`,
})
if (consecutiveParseFailures >= MAX_CONSECUTIVE_PARSE_FAILURES) {
warnings.push(`连续 ${consecutiveParseFailures} 轮输出无法解析为合法动作,任务终止`)
return {
status: 'FAILED',
estimate: null,
categoryTotals: null,
warnings,
trace,
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
}
}
observations.push(
`[系统提示] 上一轮输出无法解析(${error ?? '未知原因'})。请只输出一个 JSON 对象:` +
'{"type":"tool","tool":...,"arguments":{...}} 或 {"type":"final","estimate":{...}}',
)
continue
}
consecutiveParseFailures = 0
if (containsExtraActionObjects(content)) {
observations.push('[系统提示] 检测到本轮输出了多个 JSON 对象,已只执行第一个;后续每轮请严格只输出一个动作。')
}
if (action.type === 'final') {
trace.push({ round: rounds, actionType: 'final', modelCallMs })
if (action.estimate.lines.length === 0) {
warnings.push('final 结果没有任何有效行,视为部分结果')
return {
status: 'PARTIAL',
estimate: action.estimate,
categoryTotals: summarizeBomCostingAgentEstimate(action.estimate),
warnings,
trace,
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
}
}
return {
status: 'COMPLETED',
estimate: action.estimate,
categoryTotals: summarizeBomCostingAgentEstimate(action.estimate),
warnings,
trace,
usage: { rounds, modelCalls, elapsedMs: Date.now() - startedAt },
}
}
trace.push({
round: rounds,
actionType: 'tool',
tool: action.tool,
toolArguments: action.arguments,
modelCallMs,
})
const tool = toolsByName.get(action.tool)
if (!tool) {
observations.push(
`[系统提示] 未知工具 "${action.tool}"。可用工具:${[...toolsByName.keys()].join(', ')}`,
)
continue
}
let payload: unknown
try {
payload = await tool.execute(action.arguments, input.mission.toolContext ?? { caseId: '' })
} catch (error) {
payload = { error: `工具执行失败:${error instanceof Error ? error.message : '未知错误'}` }
}
const summary = safeJsonStringify(payload)
trace[trace.length - 1]!.toolResultSummary = summary.length > 400 ? `${summary.slice(0, 400)}…` : summary
observations.push(observationText(action.tool, payload))
}
}

View File

@ -0,0 +1,138 @@
import { bomCostingAgentRepository } from '@/server/repositories/bom-costing-agent.repository'
import { resolveBomCostingImageSearchSourceUrl } from '../bom-costing-1688-image-search'
import type { BomCostingMultimodalUserContent } from '../bom-costing-multimodal-gateway'
import type { BomCostingAgentBudget, BomCostingAgentRunResult } from './agent-contracts'
import { runBomCostingAgentLoop } from './bom-costing-agent-loop'
import { createGatewayBomCostingAgentModelCaller } from './agent-model-caller'
import { renderBomCostingAgentSkillKnowledgeByCode } from './agent-skill-knowledge'
import { createBomCostingAgentTools } from './agent-tools'
import { createDefaultBomCostingAgentToolDeps } from './agent-tool-providers'
// 旁路试验入口:只读案例数据、只调工具与模型,不写任何业务表、
// 不进处理管线。对比与验收在工程侧脚本里做,替换锚定前必须先过实测。
const DEFAULT_MAX_IMAGES = 8
export interface BomCostingAgentTrialImageAsset {
assetId: string
kind: 'MAIN' | 'SKU' | 'DETAIL'
url: string
externalSkuIds: unknown
}
function assetSkuTags(asset: BomCostingAgentTrialImageAsset) {
return Array.isArray(asset.externalSkuIds)
? asset.externalSkuIds.filter((item): item is string => typeof item === 'string')
: []
}
// 图片优先级:本 SKU 专属图 > 主图 > 详情图;同优先级保持归档顺序。
export function selectAgentTrialImages(
assets: readonly BomCostingAgentTrialImageAsset[],
externalSkuId: string,
maxImages: number,
) {
const skuTagged = assets.filter((asset) => assetSkuTags(asset).includes(externalSkuId))
const main = assets.filter((asset) => asset.kind === 'MAIN')
const detail = assets.filter((asset) => asset.kind === 'DETAIL')
const seen = new Set<string>()
const selected: BomCostingAgentTrialImageAsset[] = []
for (const asset of [...skuTagged, ...main, ...detail]) {
if (selected.length >= Math.max(1, maxImages)) break
if (seen.has(asset.assetId) || !asset.url) continue
seen.add(asset.assetId)
selected.push(asset)
}
return selected
}
function decimalText(value: unknown) {
return value === null ? null : Number(value)
}
// 归档图片是站内相对路径或私有桶地址,模型网关需要可抓取的签名绝对 URL;
// 与生产 1688 图搜使用同一转换,失败时退回原始值由调用方报错。
function toModelImageSrc(rawValue: string) {
try {
return resolveBomCostingImageSearchSourceUrl(rawValue).toString()
} catch {
return rawValue
}
}
export async function runBomCostingAgentTrialForSku(input: {
caseId: string
skuId: string
maxImages?: number
budget?: Partial<BomCostingAgentBudget>
signal?: AbortSignal
}): Promise<{
caseId: string
skuId: string
externalSkuId: string
skillCode: string | null
selectedImageCount: number
result: BomCostingAgentRunResult
}> {
const [sourceCase, sku] = await Promise.all([
bomCostingAgentRepository.findCaseForAgentTrial(input.caseId),
bomCostingAgentRepository.findSkuForAgentTrial(input.skuId),
])
if (!sourceCase) throw new Error(`案例不存在或已删除:${input.caseId}`)
if (!sku || sku.caseId !== input.caseId) throw new Error(`SKU 不属于该案例:${input.skuId}`)
const assets = await bomCostingAgentRepository.listArchivedAssetsForCase(input.caseId)
const images = selectAgentTrialImages(
assets.map((asset) => ({
assetId: asset.id,
kind: asset.kind,
url: toModelImageSrc(asset.storedUrl || asset.originalUrl),
externalSkuIds: asset.externalSkuIds as unknown,
})),
sku.externalSkuId,
input.maxImages ?? DEFAULT_MAX_IMAGES,
)
const dimensionParts = [
sku.lengthMm !== null ? `长 ${decimalText(sku.lengthMm)}mm` : null,
sku.widthMm !== null ? `宽 ${decimalText(sku.widthMm)}mm` : null,
sku.heightMm !== null ? `高 ${decimalText(sku.heightMm)}mm` : null,
sku.weightKg !== null ? `毛重 ${decimalText(sku.weightKg)}kg` : null,
].filter((part): part is string => part !== null)
const userText = [
`商品标题:${sourceCase.title || '(无标题)'}`,
`来源渠道:${sourceCase.sourceHost}`,
`目标 SKU:${sku.externalSkuId}${sku.name ? `(${sku.name})` : ''}`,
dimensionParts.length > 0 ? `规格:${dimensionParts.join(',')}` : null,
'',
'任务:基于以上文字与随附图片,预估该 SKU 的同材质复刻 BOM 成本。'
+ '先查看图片与材质目录,再用工具检索公司历史价格,最后给出 final。',
].filter((part): part is string => part !== null).join('\n')
const userContent: BomCostingMultimodalUserContent = [
{ type: 'text', text: userText },
...images.map((image) => ({
type: 'image_url' as const,
image_url: { url: image.url, detail: 'auto' as const },
})),
]
const skillKnowledge = renderBomCostingAgentSkillKnowledgeByCode(sourceCase.skillCode)
const result = await runBomCostingAgentLoop({
mission: {
userContent,
toolContext: { caseId: input.caseId, skuId: input.skuId },
budget: input.budget,
signal: input.signal,
},
toolRuntimes: createBomCostingAgentTools(createDefaultBomCostingAgentToolDeps()),
callModel: createGatewayBomCostingAgentModelCaller(),
})
return {
caseId: input.caseId,
skuId: input.skuId,
externalSkuId: sku.externalSkuId,
skillCode: sourceCase.skillCode,
selectedImageCount: images.length,
result,
}
}

View File

@ -13,6 +13,8 @@ export type ImageBackgroundRemovalApiMode =
export interface ProviderModelOption {
value: string
label: string
/** 模型目录来源:gateway=通用图像网关目录;async-matting=上游异步媒体任务的专用抠图目录。 */
source?: 'gateway' | 'async-matting'
}
export interface ProviderModelCatalogResult {
@ -43,6 +45,7 @@ const MODEL_ID_FIELDS = ['id', 'modelId', 'model', 'value', 'name'] as const
const MODEL_LABEL_FIELDS = ['displayName', 'label', 'name', 'id', 'modelId', 'model', 'value'] as const
const MODEL_COLLECTION_FIELDS = ['data', 'models', 'items', 'results', 'result'] as const
const MAX_COLLECTION_DEPTH = 4
const MATTING_CATALOG_PATH = '/api/v2/catalog/models'
function normalizeRequiredText(value: unknown, label: string): string {
if (typeof value !== 'string' || !value.trim()) {
@ -176,17 +179,32 @@ function buildEndpointCandidates(
serviceType: ProviderModelServiceType,
apiMode?: ImageBackgroundRemovalApiMode,
): Array<{ url: string; path: string }> {
if (serviceType === 'image-background-removal' && apiMode === 'async-media-task') {
return [{
url: joinUrl(baseUrl, '/api/v2/catalog/models'),
path: '/api/v2/catalog/models',
if (serviceType === 'image-background-removal') {
// 非 async-media-task 协议也要携带专用抠图目录候选:fetchCatalog 会合并
// 两份目录,让默认协议下同样能看到 remove-background 等抠图模型。
if (apiMode === 'async-media-task') {
return [{ url: joinUrl(baseUrl, MATTING_CATALOG_PATH), path: MATTING_CATALOG_PATH }]
}
if (apiMode === 'openai-compatible-image-edit') {
const openAiCandidates: Array<{ url: string; path: string }> = []
const parsedUrl = new URL(baseUrl)
const normalizedPath = parsedUrl.pathname.replace(/\/+$/, '')
openAiCandidates.push({ url: joinUrl(baseUrl, '/models'), path: '/models' })
if (!normalizedPath.endsWith('/v1')) {
openAiCandidates.push({ url: joinUrl(baseUrl, '/v1/models'), path: '/v1/models' })
}
openAiCandidates.push({ url: joinUrl(baseUrl, MATTING_CATALOG_PATH), path: MATTING_CATALOG_PATH })
return openAiCandidates
}
const candidates = [{
url: joinUrl(baseUrl, '/api/external/image/models'),
path: '/api/external/image/models',
}]
candidates.push({ url: joinUrl(baseUrl, MATTING_CATALOG_PATH), path: MATTING_CATALOG_PATH })
return candidates
}
if (
serviceType === 'ai-render' ||
(serviceType === 'image-background-removal' && apiMode !== 'openai-compatible-image-edit')
) {
if (serviceType === 'ai-render') {
return [{
url: joinUrl(baseUrl, '/api/external/image/models'),
path: '/api/external/image/models',
@ -248,7 +266,7 @@ async function requestCatalog(
}
const models = response.ok
? apiMode === 'async-media-task'
? endpoint.path === MATTING_CATALOG_PATH
? extractAsyncMediaTaskModelOptions(payload)
: extractProviderModelOptions(payload, {
preferExternalImageModelId:
@ -286,6 +304,57 @@ export class ProviderModelCatalogService {
let lastAttempt: CatalogAttempt | null = null
let reachedProvider = false
// 商品抠图需要同时看到两份目录:通用图像网关模型(以文改图)和上游
// 异步媒体任务的专用抠图模型(remove-background 等)。任一协议模式下
// 都合并两份目录并标记来源,避免“默认协议看不到抠图模型”的死角。
if (input.serviceType === 'image-background-removal') {
const merged = new Map<string, ProviderModelOption>()
let firstEndpointWithModels: string | null = null
for (const candidate of candidates) {
const attempt = await requestCatalog(candidate, apiKey, input.serviceType, input.apiMode)
lastAttempt = attempt
reachedProvider ||= attempt.status >= 200 && attempt.status < 500
if (attempt.models.length > 0 && firstEndpointWithModels === null) {
firstEndpointWithModels = attempt.endpointPath
}
const source: ProviderModelOption['source'] = candidate.path === MATTING_CATALOG_PATH
? 'async-matting'
: 'gateway'
for (const model of attempt.models) {
if (!merged.has(model.value)) merged.set(model.value, { ...model, source })
}
if ([401, 403].includes(attempt.status)) break
}
const models = Array.from(merged.values()).sort((left, right) => {
if (left.source !== right.source) return left.source === 'async-matting' ? -1 : 1
return 0
})
const status = lastAttempt?.status ?? 0
if (models.length > 0) {
return {
ok: true,
status: status === 0 ? 200 : status,
latency: Date.now() - startedAt,
models,
endpointPath: firstEndpointWithModels,
error: null,
}
}
const error = [401, 403].includes(status)
? 'API 密钥无权读取模型列表,请检查密钥权限'
: reachedProvider
? '服务可以连接,但没有返回可选择的模型。请确认服务地址包含正确的 API 前缀'
: lastAttempt?.error ?? '无法连接模型服务'
return {
ok: false,
status,
latency: Date.now() - startedAt,
models: [],
endpointPath: lastAttempt?.endpointPath ?? null,
error,
}
}
for (const candidate of candidates) {
const attempt = await requestCatalog(candidate, apiKey, input.serviceType, input.apiMode)
lastAttempt = attempt